diff options
59 files changed, 4481 insertions, 71 deletions
diff --git a/server/sonar-web/src/main/js/api/application.ts b/server/sonar-web/src/main/js/api/application.ts index d3636ce817a..e26af6755df 100644 --- a/server/sonar-web/src/main/js/api/application.ts +++ b/server/sonar-web/src/main/js/api/application.ts @@ -17,9 +17,10 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { getJSON } from 'sonar-ui-common/helpers/request'; +import { getJSON, post, postJSON } from 'sonar-ui-common/helpers/request'; import throwGlobalError from '../app/utils/throwGlobalError'; -import { Application, ApplicationPeriod } from '../types/application'; +import { Application, ApplicationPeriod, ApplicationProject } from '../types/application'; +import { Visibility } from '../types/component'; export function getApplicationLeak( application: string, @@ -37,3 +38,76 @@ export function getApplicationDetails(application: string, branch?: string): Pro throwGlobalError ); } + +export function addApplicationBranch(data: { + application: string; + branch: string; + project: string[]; + projectBranch: string[]; +}) { + return post('/api/applications/create_branch', data).catch(throwGlobalError); +} + +export function updateApplicationBranch(data: { + application: string; + branch: string; + name: string; + project: string[]; + projectBranch: string[]; +}) { + return post('/api/applications/update_branch', data).catch(throwGlobalError); +} + +export function deleteApplicationBranch(application: string, branch: string) { + return post('/api/applications/delete_branch', { application, branch }).catch(throwGlobalError); +} + +export function getApplicationProjects(data: { + application: string; + p?: number; + ps?: number; + q?: string; + selected: string; +}): Promise<{ paging: T.Paging; projects: ApplicationProject[] }> { + return getJSON('/api/applications/search_projects', data).catch(throwGlobalError); +} + +export function addProjectToApplication(application: string, project: string) { + return post('/api/applications/add_project', { application, project }).catch(throwGlobalError); +} + +export function removeProjectFromApplication(application: string, project: string) { + return post('/api/applications/remove_project', { application, project }).catch(throwGlobalError); +} + +export function refreshApplication(key: string) { + return post('/api/applications/refresh', { key }).catch(throwGlobalError); +} + +export function createApplication( + name: string, + description: string, + key: string | undefined, + visibility: string +): Promise<{ + application: { + description?: string; + key: string; + name: string; + visibility: Visibility; + }; +}> { + return postJSON('/api/applications/create', { description, key, name, visibility }).catch( + throwGlobalError + ); +} + +export function deleteApplication(application: string) { + return post('/api/applications/delete', { application }).catch(throwGlobalError); +} + +export function editApplication(application: string, name: string, description: string) { + return post('/api/applications/update', { name, description, application }).catch( + throwGlobalError + ); +} diff --git a/server/sonar-web/src/main/js/app/components/extensions/CreateApplicationForm.tsx b/server/sonar-web/src/main/js/app/components/extensions/CreateApplicationForm.tsx new file mode 100644 index 00000000000..9a64dd8c304 --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/extensions/CreateApplicationForm.tsx @@ -0,0 +1,185 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import { ResetButtonLink, SubmitButton } from 'sonar-ui-common/components/controls/buttons'; +import Radio from 'sonar-ui-common/components/controls/Radio'; +import SimpleModal from 'sonar-ui-common/components/controls/SimpleModal'; +import DeferredSpinner from 'sonar-ui-common/components/ui/DeferredSpinner'; +import { translate } from 'sonar-ui-common/helpers/l10n'; +import { createApplication } from '../../../api/application'; +import { ComponentQualifier, Visibility } from '../../../types/component'; + +interface Props { + onClose: () => void; + onCreate: (application: { key: string; qualifier: ComponentQualifier }) => Promise<void>; +} + +interface State { + description: string; + key: string; + name: string; + visibility: Visibility; +} + +export default class CreateApplicationForm extends React.PureComponent<Props, State> { + mounted = false; + + constructor(props: Props) { + super(props); + this.state = { + description: '', + key: '', + name: '', + visibility: Visibility.Public + }; + } + + componentDidMount() { + this.mounted = true; + } + + componentWillUnmount() { + this.mounted = false; + } + + handleDescriptionChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => { + this.setState({ description: event.currentTarget.value }); + }; + + handleKeyChange = (event: React.ChangeEvent<HTMLInputElement>) => { + this.setState({ key: event.currentTarget.value }); + }; + + handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => { + this.setState({ name: event.currentTarget.value }); + }; + + handleVisibilityChange = (visibility: Visibility) => { + this.setState({ visibility }); + }; + + handleFormSubmit = () => { + const { name, description, key, visibility } = this.state; + return createApplication(name, description, key.length > 0 ? key : undefined, visibility).then( + ({ application }) => { + if (this.mounted) { + this.props.onCreate({ + key: application.key, + qualifier: ComponentQualifier.Application + }); + } + } + ); + }; + + render() { + const { name, description, key, visibility } = this.state; + const header = translate('qualifiers.create.APP'); + const submitDisabled = !this.state.name.length; + + return ( + <SimpleModal + header={header} + onClose={this.props.onClose} + onSubmit={this.handleFormSubmit} + size="small"> + {({ onCloseClick, onFormSubmit, submitting }) => ( + <form className="views-form" onSubmit={onFormSubmit}> + <div className="modal-head"> + <h2>{header}</h2> + </div> + + <div className="modal-body"> + <div className="modal-field"> + <label htmlFor="view-edit-name"> + {translate('name')} <em className="mandatory">*</em> + </label> + <input + autoFocus={true} + id="view-edit-name" + maxLength={100} + name="name" + onChange={this.handleNameChange} + size={50} + type="text" + value={name} + /> + </div> + <div className="modal-field"> + <label htmlFor="view-edit-description">{translate('description')}</label> + <textarea + id="view-edit-description" + name="description" + onChange={this.handleDescriptionChange} + value={description} + /> + </div> + <div className="modal-field"> + <label htmlFor="view-edit-key">{translate('key')}</label> + <input + autoComplete="off" + id="view-edit-key" + maxLength={256} + name="key" + onChange={this.handleKeyChange} + size={256} + type="text" + value={key} + /> + <p className="modal-field-description"> + {translate('onboarding.create_application.key.description')} + </p> + </div> + + <div className="modal-field"> + <label>{translate('visibility')}</label> + <div className="little-spacer-top"> + {[Visibility.Public, Visibility.Private].map(v => ( + <Radio + className={`big-spacer-right visibility-${v}`} + key={v} + checked={visibility === v} + value={v} + onCheck={this.handleVisibilityChange}> + {translate('visibility', v)} + </Radio> + ))} + </div> + </div> + </div> + + <div className="modal-foot"> + <DeferredSpinner className="spacer-right" loading={submitting} /> + <SubmitButton disabled={submitting || submitDisabled}> + {translate('create')} + </SubmitButton> + <ResetButtonLink + className="js-modal-close" + id="view-edit-cancel" + onClick={onCloseClick}> + {translate('cancel')} + </ResetButtonLink> + </div> + </form> + )} + </SimpleModal> + ); + } +} diff --git a/server/sonar-web/src/main/js/app/components/extensions/__tests__/CreateApplicationForm-test.tsx b/server/sonar-web/src/main/js/app/components/extensions/__tests__/CreateApplicationForm-test.tsx new file mode 100644 index 00000000000..e6fff6855bf --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/extensions/__tests__/CreateApplicationForm-test.tsx @@ -0,0 +1,80 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import { shallow } from 'enzyme'; +import * as React from 'react'; +import SimpleModal from 'sonar-ui-common/components/controls/SimpleModal'; +import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils'; +import { createApplication } from '../../../../api/application'; +import { mockEvent } from '../../../../helpers/testMocks'; +import { ComponentQualifier, Visibility } from '../../../../types/component'; +import CreateApplicationForm from '../CreateApplicationForm'; + +jest.mock('../../../../api/application', () => ({ + createApplication: jest.fn().mockResolvedValue({ application: { key: 'foo' } }) +})); + +beforeEach(jest.clearAllMocks); + +it('should render correctly', () => { + const wrapper = shallowRender(); + expect(wrapper).toMatchSnapshot('default'); + expect(wrapper.find(SimpleModal).dive()).toMatchSnapshot('form'); +}); + +it('should correctly create application on form submit', async () => { + const onCreate = jest.fn(); + const wrapper = shallowRender({ onCreate }); + const instance = wrapper.instance(); + + instance.handleDescriptionChange(mockEvent({ currentTarget: { value: 'description' } })); + instance.handleKeyChange(mockEvent({ currentTarget: { value: 'key' } })); + instance.handleNameChange(mockEvent({ currentTarget: { value: 'name' } })); + instance.handleVisibilityChange(Visibility.Private); + + wrapper + .find(SimpleModal) + .props() + .onSubmit(); + expect(createApplication).toHaveBeenCalledWith('name', 'description', 'key', Visibility.Private); + await waitAndUpdate(wrapper); + + expect(onCreate).toHaveBeenCalledWith( + expect.objectContaining({ + key: 'foo', + qualifier: ComponentQualifier.Application + }) + ); + + // Can call the WS without any key. + instance.handleKeyChange(mockEvent({ currentTarget: { value: '' } })); + instance.handleFormSubmit(); + expect(createApplication).toHaveBeenCalledWith( + 'name', + 'description', + undefined, + Visibility.Private + ); +}); + +function shallowRender(props?: Partial<CreateApplicationForm['props']>) { + return shallow<CreateApplicationForm>( + <CreateApplicationForm onClose={jest.fn()} onCreate={jest.fn()} {...props} /> + ); +} diff --git a/server/sonar-web/src/main/js/app/components/extensions/__tests__/__snapshots__/CreateApplicationForm-test.tsx.snap b/server/sonar-web/src/main/js/app/components/extensions/__tests__/__snapshots__/CreateApplicationForm-test.tsx.snap new file mode 100644 index 00000000000..dae7cc69a98 --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/extensions/__tests__/__snapshots__/CreateApplicationForm-test.tsx.snap @@ -0,0 +1,150 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly: default 1`] = ` +<SimpleModal + header="qualifiers.create.APP" + onClose={[MockFunction]} + onSubmit={[Function]} + size="small" +> + <Component /> +</SimpleModal> +`; + +exports[`should render correctly: form 1`] = ` +<Modal + contentLabel="qualifiers.create.APP" + onRequestClose={[MockFunction]} + size="small" +> + <form + className="views-form" + onSubmit={[Function]} + > + <div + className="modal-head" + > + <h2> + qualifiers.create.APP + </h2> + </div> + <div + className="modal-body" + > + <div + className="modal-field" + > + <label + htmlFor="view-edit-name" + > + name + + <em + className="mandatory" + > + * + </em> + </label> + <input + autoFocus={true} + id="view-edit-name" + maxLength={100} + name="name" + onChange={[Function]} + size={50} + type="text" + value="" + /> + </div> + <div + className="modal-field" + > + <label + htmlFor="view-edit-description" + > + description + </label> + <textarea + id="view-edit-description" + name="description" + onChange={[Function]} + value="" + /> + </div> + <div + className="modal-field" + > + <label + htmlFor="view-edit-key" + > + key + </label> + <input + autoComplete="off" + id="view-edit-key" + maxLength={256} + name="key" + onChange={[Function]} + size={256} + type="text" + value="" + /> + <p + className="modal-field-description" + > + onboarding.create_application.key.description + </p> + </div> + <div + className="modal-field" + > + <label> + visibility + </label> + <div + className="little-spacer-top" + > + <Radio + checked={true} + className="big-spacer-right visibility-public" + key="public" + onCheck={[Function]} + value="public" + > + visibility.public + </Radio> + <Radio + checked={false} + className="big-spacer-right visibility-private" + key="private" + onCheck={[Function]} + value="private" + > + visibility.private + </Radio> + </div> + </div> + </div> + <div + className="modal-foot" + > + <DeferredSpinner + className="spacer-right" + loading={false} + /> + <SubmitButton + disabled={true} + > + create + </SubmitButton> + <ResetButtonLink + className="js-modal-close" + id="view-edit-cancel" + onClick={[Function]} + > + cancel + </ResetButtonLink> + </div> + </form> +</Modal> +`; diff --git a/server/sonar-web/src/main/js/app/components/nav/component/Menu.tsx b/server/sonar-web/src/main/js/app/components/nav/component/Menu.tsx index 8909e66a0cf..e801e964147 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/Menu.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/component/Menu.tsx @@ -35,6 +35,7 @@ import { ComponentQualifier, isPortfolioLike } from '../../../../types/component import './Menu.css'; const SETTINGS_URLS = [ + '/application/console', '/project/admin', '/project/baseline', '/project/branches', @@ -290,6 +291,8 @@ export class Menu extends React.PureComponent<Props> { this.renderSettingsLink(query, isApplication, isPortfolio), this.renderBranchesLink(query, isProject), this.renderBaselineLink(query, isApplication, isPortfolio), + this.renderConsoleAppLink(query, isApplication), + ...this.renderAdminExtensions(query, isApplication), this.renderProfilesLink(query), this.renderQualityGateLink(query), this.renderCustomMeasuresLink(query), @@ -298,7 +301,6 @@ export class Menu extends React.PureComponent<Props> { this.renderBackgroundTasksLink(query), this.renderUpdateKeyLink(query), this.renderWebhooksLink(query, isProject), - ...this.renderAdminExtensions(query), this.renderDeletionLink(query) ]; }; @@ -372,6 +374,19 @@ export class Menu extends React.PureComponent<Props> { ); }; + renderConsoleAppLink = (query: Query, isApplication: boolean) => { + if (!isApplication) { + return null; + } + return ( + <li key="app-console"> + <Link activeClassName="active" to={{ pathname: '/application/console', query }}> + {translate('application_console.page')} + </Link> + </li> + ); + }; + renderProfilesLink = (query: Query) => { if (!this.getConfiguration().showQualityProfiles) { return null; @@ -514,9 +529,11 @@ export class Menu extends React.PureComponent<Props> { ); }; - renderAdminExtensions = (query: Query) => { + renderAdminExtensions = (query: Query, isApplication: boolean) => { const extensions = this.getConfiguration().extensions || []; - return extensions.map(e => this.renderExtension(e, true, query)); + return extensions + .filter(e => !isApplication || e.key !== 'governance/console') + .map(e => this.renderExtension(e, true, query)); }; renderExtensions = (query: Query) => { diff --git a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/Menu-test.tsx.snap b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/Menu-test.tsx.snap index 2290dbe8089..a2426279952 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/Menu-test.tsx.snap +++ b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/Menu-test.tsx.snap @@ -1095,6 +1095,23 @@ exports[`should work for all qualifiers 4`] = ` style={Object {}} to={ Object { + "pathname": "/application/console", + "query": Object { + "id": "foo", + }, + } + } + > + application_console.page + </Link> + </li> + <li> + <Link + activeClassName="active" + onlyActiveOnIndex={false} + style={Object {}} + to={ + Object { "pathname": "/project/deletion", "query": Object { "id": "foo", @@ -1474,14 +1491,15 @@ exports[`should work with extensions 2`] = ` style={Object {}} to={ Object { - "pathname": "/project/webhooks", + "pathname": "/project/admin/extension/foo", "query": Object { "id": "foo", + "qualifier": "TRK", }, } } > - webhooks.page + Foo </Link> </li> <li> @@ -1491,15 +1509,14 @@ exports[`should work with extensions 2`] = ` style={Object {}} to={ Object { - "pathname": "/project/admin/extension/foo", + "pathname": "/project/webhooks", "query": Object { "id": "foo", - "qualifier": "TRK", }, } } > - Foo + webhooks.page </Link> </li> <li> @@ -1643,14 +1660,15 @@ exports[`should work with multiple extensions 2`] = ` style={Object {}} to={ Object { - "pathname": "/project/webhooks", + "pathname": "/project/admin/extension/foo", "query": Object { "id": "foo", + "qualifier": "TRK", }, } } > - webhooks.page + Foo </Link> </li> <li> @@ -1660,7 +1678,7 @@ exports[`should work with multiple extensions 2`] = ` style={Object {}} to={ Object { - "pathname": "/project/admin/extension/foo", + "pathname": "/project/admin/extension/bar", "query": Object { "id": "foo", "qualifier": "TRK", @@ -1668,7 +1686,7 @@ exports[`should work with multiple extensions 2`] = ` } } > - Foo + Bar </Link> </li> <li> @@ -1678,15 +1696,14 @@ exports[`should work with multiple extensions 2`] = ` style={Object {}} to={ Object { - "pathname": "/project/admin/extension/bar", + "pathname": "/project/webhooks", "query": Object { "id": "foo", - "qualifier": "TRK", }, } } > - Bar + webhooks.page </Link> </li> <li> diff --git a/server/sonar-web/src/main/js/app/components/nav/component/branch-like/CurrentBranchLike.tsx b/server/sonar-web/src/main/js/app/components/nav/component/branch-like/CurrentBranchLike.tsx index 120b5d76e9a..7b220ce42ec 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/branch-like/CurrentBranchLike.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/component/branch-like/CurrentBranchLike.tsx @@ -26,7 +26,7 @@ import { translate } from 'sonar-ui-common/helpers/l10n'; import DocumentationTooltip from '../../../../../components/common/DocumentationTooltip'; import BranchLikeIcon from '../../../../../components/icons/BranchLikeIcon'; import { getBranchLikeDisplayName } from '../../../../../helpers/branch-like'; -import { getPortfolioAdminUrl } from '../../../../../helpers/urls'; +import { getApplicationAdminUrl } from '../../../../../helpers/urls'; import { BranchLike } from '../../../../../types/branch-like'; import { ComponentQualifier } from '../../../../../types/component'; import { colors } from '../../../../theme'; @@ -66,7 +66,7 @@ export function CurrentBranchLike(props: CurrentBranchLikeProps) { <> <p>{translate('application.branches.help')}</p> <hr className="spacer-top spacer-bottom" /> - <Link to={getPortfolioAdminUrl(component.key, component.qualifier)}> + <Link to={getApplicationAdminUrl(component.key)}> {translate('application.branches.link')} </Link> </> diff --git a/server/sonar-web/src/main/js/app/components/nav/component/branch-like/__tests__/__snapshots__/CurrentBranchLike-test.tsx.snap b/server/sonar-web/src/main/js/app/components/nav/component/branch-like/__tests__/__snapshots__/CurrentBranchLike-test.tsx.snap index a76cb8e9184..dcad04e6221 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/branch-like/__tests__/__snapshots__/CurrentBranchLike-test.tsx.snap +++ b/server/sonar-web/src/main/js/app/components/nav/component/branch-like/__tests__/__snapshots__/CurrentBranchLike-test.tsx.snap @@ -81,10 +81,9 @@ exports[`CurrentBranchLikeRenderer should render correctly for application when style={Object {}} to={ Object { - "pathname": "/project/admin/extension/governance/console", + "pathname": "/application/console", "query": Object { "id": "my-project", - "qualifier": "APP", }, } } diff --git a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavPlus.tsx b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavPlus.tsx index 414500684cb..7226731ae5c 100644 --- a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavPlus.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavPlus.tsx @@ -26,10 +26,11 @@ import { getComponentNavigation } from '../../../../api/nav'; import CreateFormShim from '../../../../apps/portfolio/components/CreateFormShim'; import { Router, withRouter } from '../../../../components/hoc/withRouter'; import { getExtensionStart } from '../../../../helpers/extensions'; -import { getPortfolioAdminUrl, getPortfolioUrl } from '../../../../helpers/urls'; +import { getComponentAdminUrl, getComponentOverviewUrl } from '../../../../helpers/urls'; import { hasGlobalPermission } from '../../../../helpers/users'; import { AlmKeys, AlmSettingsInstance } from '../../../../types/alm-settings'; import { ComponentQualifier } from '../../../../types/component'; +import CreateApplicationForm from '../../extensions/CreateApplicationForm'; import GlobalNavPlusMenu from './GlobalNavPlusMenu'; interface Props { @@ -65,7 +66,7 @@ export class GlobalNavPlus extends React.PureComponent<Props, State> { this.fetchAlmBindings(); - if (this.props.appState.qualifiers.includes('VW')) { + if (this.props.appState.qualifiers.includes(ComponentQualifier.Portfolio)) { getExtensionStart('governance/console').then( () => { if (this.mounted) { @@ -121,17 +122,11 @@ export class GlobalNavPlus extends React.PureComponent<Props, State> { }; handleComponentCreate = ({ key, qualifier }: { key: string; qualifier: ComponentQualifier }) => { - return getComponentNavigation({ component: key }).then(data => { - if ( - data.configuration && - data.configuration.extensions && - data.configuration.extensions.find( - (item: { key: string; name: string }) => item.key === 'governance/console' - ) - ) { - this.props.router.push(getPortfolioAdminUrl(key, qualifier)); + return getComponentNavigation({ component: key }).then(({ configuration }) => { + if (configuration && configuration.showSettings) { + this.props.router.push(getComponentAdminUrl(key, qualifier)); } else { - this.props.router.push(getPortfolioUrl(key)); + this.props.router.push(getComponentOverviewUrl(key, qualifier)); } this.closeComponentCreationForm(); }); @@ -140,11 +135,12 @@ export class GlobalNavPlus extends React.PureComponent<Props, State> { render() { const { appState, currentUser } = this.props; const { boundAlms, governanceReady, creatingComponent } = this.state; - const governanceInstalled = appState.qualifiers.includes(ComponentQualifier.Portfolio); const canCreateApplication = - governanceInstalled && hasGlobalPermission(currentUser, 'applicationcreator'); + appState.qualifiers.includes(ComponentQualifier.Application) && + hasGlobalPermission(currentUser, 'applicationcreator'); const canCreatePortfolio = - governanceInstalled && hasGlobalPermission(currentUser, 'portfoliocreator'); + appState.qualifiers.includes(ComponentQualifier.Portfolio) && + hasGlobalPermission(currentUser, 'portfoliocreator'); const canCreateProject = hasGlobalPermission(currentUser, 'provisioning'); if (!canCreateProject && !canCreateApplication && !canCreatePortfolio) { @@ -172,7 +168,15 @@ export class GlobalNavPlus extends React.PureComponent<Props, State> { <PlusIcon /> </a> </Dropdown> - {governanceReady && creatingComponent && ( + + {canCreateApplication && creatingComponent === ComponentQualifier.Application && ( + <CreateApplicationForm + onClose={this.closeComponentCreationForm} + onCreate={this.handleComponentCreate} + /> + )} + + {governanceReady && creatingComponent === ComponentQualifier.Portfolio && ( <CreateFormShim defaultQualifier={creatingComponent} onClose={this.closeComponentCreationForm} diff --git a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavPlus-test.tsx b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavPlus-test.tsx index 9a58c0b2fc6..1a189c21c7d 100644 --- a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavPlus-test.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavPlus-test.tsx @@ -24,7 +24,7 @@ import { getAlmSettings } from '../../../../../api/alm-settings'; import { getComponentNavigation } from '../../../../../api/nav'; import CreateFormShim from '../../../../../apps/portfolio/components/CreateFormShim'; import { mockLoggedInUser, mockRouter } from '../../../../../helpers/testMocks'; -import { getPortfolioAdminUrl, getPortfolioUrl } from '../../../../../helpers/urls'; +import { getComponentAdminUrl, getComponentOverviewUrl } from '../../../../../helpers/urls'; import { AlmKeys } from '../../../../../types/alm-settings'; import { ComponentQualifier } from '../../../../../types/component'; import { GlobalNavPlus } from '../GlobalNavPlus'; @@ -33,6 +33,10 @@ const PROJECT_CREATION_RIGHT = 'provisioning'; const APP_CREATION_RIGHT = 'applicationcreator'; const PORTFOLIO_CREATION_RIGHT = 'portfoliocreator'; +jest.mock('../../../../../helpers/extensions', () => ({ + getExtensionStart: jest.fn().mockResolvedValue(null) +})); + jest.mock('../../../../../api/alm-settings', () => ({ getAlmSettings: jest.fn().mockResolvedValue([]) })); @@ -42,8 +46,8 @@ jest.mock('../../../../../api/nav', () => ({ })); jest.mock('../../../../../helpers/urls', () => ({ - getPortfolioUrl: jest.fn(), - getPortfolioAdminUrl: jest.fn() + getComponentOverviewUrl: jest.fn(), + getComponentAdminUrl: jest.fn() })); beforeEach(() => { @@ -64,7 +68,7 @@ it('should render correctly if branches not enabled', async () => { expect(getAlmSettings).not.toBeCalled(); }); -it('should render correctly', () => { +it('should render correctly', async () => { expect( shallowRender([APP_CREATION_RIGHT, PORTFOLIO_CREATION_RIGHT, PROJECT_CREATION_RIGHT], {}) ).toMatchSnapshot('no governance'); @@ -73,6 +77,7 @@ it('should render correctly', () => { [APP_CREATION_RIGHT, PORTFOLIO_CREATION_RIGHT, PROJECT_CREATION_RIGHT], { enableGovernance: true } ); + await waitAndUpdate(wrapper); wrapper.setState({ boundAlms: ['bitbucket'] }); expect(wrapper).toMatchSnapshot('full rights and alms'); }); @@ -116,7 +121,7 @@ it('should display component creation form', () => { describe('handleComponentCreate', () => { (getComponentNavigation as jest.Mock) .mockResolvedValueOnce({ - configuration: { extensions: [{ key: 'governance/console', name: 'governance' }] } + configuration: { showSettings: true } }) .mockResolvedValueOnce({}); @@ -127,7 +132,7 @@ describe('handleComponentCreate', () => { it('should redirect to admin', async () => { wrapper.instance().handleComponentCreate(portfolio); await waitAndUpdate(wrapper); - expect(getPortfolioAdminUrl).toBeCalledWith(portfolio.key, portfolio.qualifier); + expect(getComponentAdminUrl).toBeCalledWith(portfolio.key, portfolio.qualifier); expect(wrapper.state().creatingComponent).toBeUndefined(); }); @@ -135,7 +140,7 @@ describe('handleComponentCreate', () => { wrapper.instance().handleComponentCreate(portfolio); await waitAndUpdate(wrapper); - expect(getPortfolioUrl).toBeCalledWith(portfolio.key); + expect(getComponentOverviewUrl).toBeCalledWith(portfolio.key, portfolio.qualifier); }); }); @@ -143,11 +148,19 @@ function shallowRender( permissions: string[] = [], { enableGovernance = false, branchesEnabled = true } ) { + let qualifiers: ComponentQualifier[]; + if (enableGovernance) { + qualifiers = [ComponentQualifier.Portfolio, ComponentQualifier.Application]; + } else if (branchesEnabled) { + qualifiers = [ComponentQualifier.Application]; + } else { + qualifiers = []; + } return shallow<GlobalNavPlus>( <GlobalNavPlus appState={{ branchesEnabled, - qualifiers: enableGovernance ? [ComponentQualifier.Portfolio] : [] + qualifiers }} currentUser={mockLoggedInUser({ permissions: { global: permissions } })} router={mockRouter()} diff --git a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavPlus-test.tsx.snap b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavPlus-test.tsx.snap index 1d95d08cb45..e68e1c19a78 100644 --- a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavPlus-test.tsx.snap +++ b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavPlus-test.tsx.snap @@ -62,7 +62,7 @@ exports[`should render correctly: no governance 1`] = ` onOpen={[Function]} overlay={ <GlobalNavPlusMenu - canCreateApplication={false} + canCreateApplication={true} canCreatePortfolio={false} canCreateProject={true} compatibleAlms={Array []} diff --git a/server/sonar-web/src/main/js/app/utils/startReactApp.tsx b/server/sonar-web/src/main/js/app/utils/startReactApp.tsx index 8fd903ea286..c135674b791 100644 --- a/server/sonar-web/src/main/js/app/utils/startReactApp.tsx +++ b/server/sonar-web/src/main/js/app/utils/startReactApp.tsx @@ -31,6 +31,7 @@ import { ThemeProvider } from 'sonar-ui-common/components/theme'; import getHistory from 'sonar-ui-common/helpers/getHistory'; import aboutRoutes from '../../apps/about/routes'; import accountRoutes from '../../apps/account/routes'; +import applicationConsoleRoutes from '../../apps/application-console/routes'; import backgroundTasksRoutes from '../../apps/background-tasks/routes'; import codeRoutes from '../../apps/code/routes'; import codingRulesRoutes from '../../apps/coding-rules/routes'; @@ -200,6 +201,7 @@ function renderComponentRoutes() { <RouteWithChildRoutes path="project/branches" childRoutes={projectBranchesRoutes} /> <RouteWithChildRoutes path="project/settings" childRoutes={settingsRoutes} /> <RouteWithChildRoutes path="project_roles" childRoutes={projectPermissionsRoutes} /> + <RouteWithChildRoutes path="application/console" childRoutes={applicationConsoleRoutes} /> <RouteWithChildRoutes path="project/webhooks" childRoutes={webhooksRoutes} /> <Route path="project/deletion" diff --git a/server/sonar-web/src/main/js/apps/application-console/ApplicationBranches.tsx b/server/sonar-web/src/main/js/apps/application-console/ApplicationBranches.tsx new file mode 100644 index 00000000000..c2f53284abe --- /dev/null +++ b/server/sonar-web/src/main/js/apps/application-console/ApplicationBranches.tsx @@ -0,0 +1,120 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import * as React from 'react'; +import { Button } from 'sonar-ui-common/components/controls/buttons'; +import { translate } from 'sonar-ui-common/helpers/l10n'; +import { Application } from '../../types/application'; +import ApplicationProjectBranch from './ApplicationProjectBranch'; +import CreateBranchForm from './CreateBranchForm'; +import { ApplicationBranch } from './utils'; + +interface Props { + application: Application; + onUpdateBranches: (branches: ApplicationBranch[]) => void; +} + +interface State { + creating: boolean; +} + +export default class ApplicationBranches extends React.PureComponent<Props, State> { + state: State = { creating: false }; + + handleCreate = (branch: ApplicationBranch) => { + this.props.onUpdateBranches([...this.props.application.branches, branch]); + }; + + handleCreateFormClose = () => { + this.setState({ creating: false }); + }; + + handleCreateClick = () => { + this.setState({ creating: true }); + }; + + canCreateBranches = () => { + return ( + this.props.application.projects && + this.props.application.projects.some(p => Boolean(p.enabled)) + ); + }; + + renderBranches(createEnable: boolean) { + const { application } = this.props; + if (!createEnable) { + return ( + <div className="app-branches-list"> + <p className="text-center big-spacer-top"> + {translate('application_console.branches.no_branches')} + </p> + </div> + ); + } + return ( + <div className="app-branches-list"> + <table className="data zebra"> + <tbody> + {application.branches.map(branch => ( + <ApplicationProjectBranch + application={application} + branch={branch} + key={branch.name} + onUpdateBranches={this.props.onUpdateBranches} + /> + ))} + </tbody> + </table> + </div> + ); + } + + render() { + const { application } = this.props; + const createEnable = this.canCreateBranches(); + return ( + <div className="app-branches-console"> + <div className="boxed-group-actions"> + <Button disabled={!createEnable} onClick={this.handleCreateClick}> + {translate('application_console.branches.create')} + </Button> + </div> + <h2 + className="text-limited big-spacer-top" + title={translate('application_console.branches')}> + {translate('application_console.branches')} + </h2> + <p>{translate('application_console.branches.help')}</p> + + {this.renderBranches(createEnable)} + + {this.state.creating && ( + <CreateBranchForm + application={application} + enabledProjectsKey={application.projects.map(p => p.key)} + onClose={this.handleCreateFormClose} + onCreate={this.handleCreate} + onUpdate={() => {}} + /> + )} + </div> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/application-console/ApplicationDetails.tsx b/server/sonar-web/src/main/js/apps/application-console/ApplicationDetails.tsx new file mode 100644 index 00000000000..f5f952684cb --- /dev/null +++ b/server/sonar-web/src/main/js/apps/application-console/ApplicationDetails.tsx @@ -0,0 +1,180 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import * as React from 'react'; +import { Link } from 'react-router'; +import { Button } from 'sonar-ui-common/components/controls/buttons'; +import ConfirmButton from 'sonar-ui-common/components/controls/ConfirmButton'; +import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n'; +import { deleteApplication, editApplication, refreshApplication } from '../../api/application'; +import addGlobalSuccessMessage from '../../app/utils/addGlobalSuccessMessage'; +import { Application, ApplicationProject } from '../../types/application'; +import { Branch } from '../../types/branch-like'; +import ApplicationBranches from './ApplicationBranches'; +import ApplicationDetailsProjects from './ApplicationDetailsProjects'; +import EditForm from './EditForm'; + +interface Props { + application: Application; + canRecompute: boolean | undefined; + onAddProject: (project: ApplicationProject) => void; + onDelete: (key: string) => void; + onEdit: (key: string, name: string, description: string) => void; + onRemoveProject: (projectKey: string) => void; + onUpdateBranches: (branches: Branch[]) => void; + pathname: string; + single: boolean | undefined; +} + +interface State { + editing: boolean; + loading: boolean; +} + +export default class ApplicationDetails extends React.PureComponent<Props, State> { + mounted = false; + + state: State = { + editing: false, + loading: false + }; + + componentDidMount() { + this.mounted = true; + } + + componenWillUnmount() { + this.mounted = false; + } + + handleRefreshClick = () => { + this.setState({ loading: true }); + refreshApplication(this.props.application.key).then(() => { + addGlobalSuccessMessage(translate('application_console.refresh_started')); + this.stopLoading(); + }, this.stopLoading); + }; + + handleDelete = async () => { + await deleteApplication(this.props.application.key); + this.props.onDelete(this.props.application.key); + }; + + handleEditClick = () => { + this.setState({ editing: true }); + }; + + handleEditFormClose = () => { + this.setState({ editing: false }); + }; + + stopLoading = () => { + if (this.mounted) { + this.setState({ loading: false }); + } + }; + + render() { + const { loading } = this.state; + const { application } = this.props; + const canDelete = !this.props.single; + return ( + <div className="boxed-group portfolios-console-details" id="view-details"> + <div className="boxed-group-actions"> + <Button + className="little-spacer-right" + id="view-details-edit" + onClick={this.handleEditClick}> + {translate('edit')} + </Button> + {this.props.canRecompute && ( + <Button + className="little-spacer-right" + disabled={loading} + onClick={this.handleRefreshClick}> + {loading && <i className="little-spacer-right spinner" />} + {translate('application_console.recompute')} + </Button> + )} + {canDelete && ( + <ConfirmButton + confirmButtonText={translate('delete')} + isDestructive={true} + modalBody={translateWithParameters( + 'application_console.do_you_want_to_delete', + application.name + )} + modalHeader={translate('application_console.delete_application')} + onConfirm={this.handleDelete}> + {({ onClick }) => ( + <Button className="button-red" id="view-details-delete" onClick={onClick}> + {translate('delete')} + </Button> + )} + </ConfirmButton> + )} + </div> + + <header className="boxed-group-header" id="view-details-header"> + <h2 className="text-limited" title={application.name}> + {application.name} + </h2> + </header> + + <div className="boxed-group-inner" id="view-details-content"> + <div className="big-spacer-bottom"> + {application.description && ( + <div className="little-spacer-bottom">{application.description}</div> + )} + <div className="subtitle"> + {translate('key')}: {application.key} + <Link + className="spacer-left" + to={{ pathname: '/dashboard', query: { id: application.key } }}> + {translate('application_console.open_dashbard')} + </Link> + </div> + </div> + + <ApplicationDetailsProjects + onAddProject={this.props.onAddProject} + onRemoveProject={this.props.onRemoveProject} + application={this.props.application} + /> + + <ApplicationBranches + application={this.props.application} + onUpdateBranches={this.props.onUpdateBranches} + /> + </div> + + {this.state.editing && ( + <EditForm + header={translate('portfolios.edit_application')} + onChange={editApplication} + onClose={this.handleEditFormClose} + onEdit={this.props.onEdit} + application={application} + /> + )} + </div> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/application-console/ApplicationDetailsProjects.tsx b/server/sonar-web/src/main/js/apps/application-console/ApplicationDetailsProjects.tsx new file mode 100644 index 00000000000..88fa3a2a300 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/application-console/ApplicationDetailsProjects.tsx @@ -0,0 +1,221 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { find, without } from 'lodash'; +import * as React from 'react'; +import SelectList, { + SelectListFilter, + SelectListSearchParams +} from 'sonar-ui-common/components/controls/SelectList'; +import QualifierIcon from 'sonar-ui-common/components/icons/QualifierIcon'; +import { + addProjectToApplication, + getApplicationProjects, + removeProjectFromApplication +} from '../../api/application'; +import { Application, ApplicationProject } from '../../types/application'; + +interface Props { + onAddProject?: (project: ApplicationProject) => void; + onRemoveProject?: (projectKey: string) => void; + application: Application; +} + +interface State { + disabledProjects: string[]; + lastSearchParams: SelectListSearchParams & { applicationKey: string }; + needToReload: boolean; + projects: Array<ApplicationProject>; + projectsTotalCount?: number; + selectedProjects: string[]; +} + +export default class ApplicationDetailsProjects extends React.PureComponent<Props, State> { + mounted = false; + + constructor(props: Props) { + super(props); + + this.state = { + disabledProjects: [], + lastSearchParams: { + applicationKey: props.application.key, + query: '', + filter: SelectListFilter.Selected + }, + needToReload: false, + projects: [], + selectedProjects: [] + }; + } + + componentDidMount() { + this.mounted = true; + } + + componentDidUpdate(prevProps: Props) { + if (prevProps.application.key !== this.props.application.key) { + this.setState( + prevState => { + return { + lastSearchParams: { + ...prevState.lastSearchParams, + applicationKey: this.props.application.key + } + }; + }, + () => this.fetchProjects(this.state.lastSearchParams) + ); + } + } + + componentWillUnmount() { + this.mounted = false; + } + + loadApplicationProjects = (searchParams: SelectListSearchParams) => + getApplicationProjects({ + application: this.state.lastSearchParams.applicationKey, + p: searchParams.page, + ps: searchParams.pageSize, + q: searchParams.query !== '' ? searchParams.query : undefined, + selected: searchParams.filter + }); + + fetchProjects = (searchParams: SelectListSearchParams) => + this.loadApplicationProjects(searchParams).then(data => { + if (this.mounted) { + this.setState(prevState => { + const more = searchParams.page != null && searchParams.page > 1; + + const { projects, selectedProjects, disabledProjects } = this.dealWithProjects( + data, + more, + prevState + ); + + return { + disabledProjects, + lastSearchParams: { ...prevState.lastSearchParams, ...searchParams }, + needToReload: false, + projects, + projectsTotalCount: data.paging.total, + selectedProjects + }; + }); + } + }); + + dealWithProjects = ( + data: { projects: Array<ApplicationProject>; paging: T.Paging }, + more: boolean, + prevState: Readonly<State> + ) => { + const projects = more ? [...prevState.projects, ...data.projects] : data.projects; + + const newSelectedProjects = data.projects + .filter(project => project.selected) + .map(project => project.key); + const selectedProjects = more + ? [...prevState.selectedProjects, ...newSelectedProjects] + : newSelectedProjects; + + const disabledProjects = more ? [...prevState.disabledProjects] : []; + + return { + disabledProjects, + projects, + selectedProjects + }; + }; + + handleSelect = (projectKey: string) => { + return addProjectToApplication(this.props.application.key, projectKey).then(() => { + if (this.mounted) { + this.setState(state => { + const project = state.projects.find(p => p.key === projectKey); + if (project && this.props.onAddProject) { + this.props.onAddProject(project); + } + return { + needToReload: true, + selectedProjects: [...state.selectedProjects, projectKey] + }; + }); + } + }); + }; + + handleUnselect = (projectKey: string) => { + return removeProjectFromApplication(this.props.application.key, projectKey).then(() => { + if (this.mounted) { + this.setState(state => { + if (this.props.onRemoveProject) { + this.props.onRemoveProject(projectKey); + } + return { + needToReload: true, + selectedProjects: without(state.selectedProjects, projectKey) + }; + }); + } + }); + }; + + renderElement = (projectKey: string) => { + const project = find(this.state.projects, { key: projectKey }); + if (project === undefined) { + return ''; + } + + return ( + <div className="views-project-item display-flex-center"> + <QualifierIcon className="spacer-right" qualifier="TRK" /> + <div> + <div title={project.name}>{project.name}</div> + <div className="note">{project.key}</div> + </div> + </div> + ); + }; + + render() { + const { projects, selectedProjects } = this.state; + + return ( + <SelectList + disabledElements={this.state.disabledProjects} + elements={projects.map(project => project.key)} + elementsTotalCount={this.state.projectsTotalCount} + needToReload={ + this.state.needToReload && + this.state.lastSearchParams && + this.state.lastSearchParams.filter !== SelectListFilter.All + } + onSearch={this.fetchProjects} + onSelect={this.handleSelect} + onUnselect={this.handleUnselect} + renderElement={this.renderElement} + selectedElements={selectedProjects} + withPaging={true} + /> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/application-console/ApplicationProjectBranch.tsx b/server/sonar-web/src/main/js/apps/application-console/ApplicationProjectBranch.tsx new file mode 100644 index 00000000000..a8902cec729 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/application-console/ApplicationProjectBranch.tsx @@ -0,0 +1,58 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import * as React from 'react'; +import BranchIcon from 'sonar-ui-common/components/icons/BranchIcon'; +import { translate } from 'sonar-ui-common/helpers/l10n'; +import { Application } from '../../types/application'; +import BranchRowActions from './BranchRowActions'; +import { ApplicationBranch } from './utils'; + +export interface ApplicationProjectBranchProps { + application: Application; + branch: ApplicationBranch; + onUpdateBranches: (branches: Array<ApplicationBranch>) => void; +} + +export default function ApplicationProjectBranch(props: ApplicationProjectBranchProps) { + const { application, branch } = props; + return ( + <tr> + <td> + <BranchIcon className="little-spacer-right" /> + {branch.name} + {branch.isMain && ( + <span className="badge spacer-left"> + {translate('application_console.branches.main_branch')} + </span> + )} + </td> + <td className="thin nowrap"> + {!branch.isMain && ( + <BranchRowActions + application={application} + branch={branch} + onUpdateBranches={props.onUpdateBranches} + /> + )} + </td> + </tr> + ); +} diff --git a/server/sonar-web/src/main/js/apps/application-console/ApplicationView.tsx b/server/sonar-web/src/main/js/apps/application-console/ApplicationView.tsx new file mode 100644 index 00000000000..32b1be17275 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/application-console/ApplicationView.tsx @@ -0,0 +1,169 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import * as React from 'react'; +import { InjectedRouter } from 'react-router'; +import { getApplicationDetails } from '../../api/application'; +import { Application, ApplicationProject } from '../../types/application'; +import ApplicationDetails from './ApplicationDetails'; +import { ApplicationBranch } from './utils'; + +interface Props { + applicationKey: string; + canRecompute?: boolean; + onDelete: (key: string) => void; + onEdit: (key: string, name: string) => void; + pathname: string; + router: Pick<InjectedRouter, 'replace'>; + single?: boolean; +} + +interface State { + application?: Application; + loading: boolean; +} + +export default class ApplicationView extends React.PureComponent<Props, State> { + mounted = false; + + state: State = { + loading: true + }; + + componentDidMount() { + this.mounted = true; + this.fetchDetails(); + } + + componentDidUpdate(prevProps: Props) { + if (prevProps.applicationKey !== this.props.applicationKey) { + this.fetchDetails(); + } + } + + componentWillUnmount() { + this.mounted = false; + } + + fetchDetails = async () => { + try { + const application = await getApplicationDetails(this.props.applicationKey); + if (this.mounted) { + this.setState({ application, loading: false }); + } + } catch { + if (this.mounted) { + this.setState({ loading: false }); + } + } + }; + + handleDelete = (key: string) => { + if (this.mounted) { + this.props.onDelete(key); + this.props.router.replace(this.props.pathname); + } + }; + + handleEdit = (key: string, name: string, description: string) => { + if (this.mounted) { + this.props.onEdit(key, name); + this.setState(state => { + if (state.application) { + return { + application: { + ...state.application, + name, + description + } + }; + } else { + return null; + } + }); + } + }; + + handleAddProject = (project: ApplicationProject) => { + this.setState(state => { + if (state.application) { + return { + application: { + ...state.application, + projects: [...state.application.projects, project] + } + }; + } else { + return null; + } + }); + }; + + handleRemoveProject = (projectKey: string) => { + this.setState(state => { + if (state.application) { + return { + application: { + ...state.application, + projects: state.application.projects.filter(p => p.key !== projectKey) + } + }; + } else { + return null; + } + }); + }; + + handleUpdateBranches = (branches: ApplicationBranch[]) => { + this.setState(state => { + if (state.application) { + return { application: { ...state.application, branches } }; + } else { + return null; + } + }); + }; + + render() { + if (this.state.loading) { + return <i className="spinner spacer" />; + } + + const { application } = this.state; + if (!application) { + // when application is not found + return null; + } + + return ( + <ApplicationDetails + application={application} + canRecompute={this.props.canRecompute} + onAddProject={this.handleAddProject} + onDelete={this.handleDelete} + onEdit={this.handleEdit} + onRemoveProject={this.handleRemoveProject} + onUpdateBranches={this.handleUpdateBranches} + pathname={this.props.pathname} + single={this.props.single} + /> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/application-console/BranchRowActions.tsx b/server/sonar-web/src/main/js/apps/application-console/BranchRowActions.tsx new file mode 100644 index 00000000000..15538900ddb --- /dev/null +++ b/server/sonar-web/src/main/js/apps/application-console/BranchRowActions.tsx @@ -0,0 +1,111 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { without } from 'lodash'; +import * as React from 'react'; +import ActionsDropdown, { + ActionsDropdownItem +} from 'sonar-ui-common/components/controls/ActionsDropdown'; +import ConfirmButton from 'sonar-ui-common/components/controls/ConfirmButton'; +import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n'; +import { deleteApplicationBranch } from '../../api/application'; +import { Application } from '../../types/application'; +import CreateBranchForm from './CreateBranchForm'; +import { ApplicationBranch } from './utils'; + +interface Props { + application: Application; + branch: ApplicationBranch; + onUpdateBranches: (branches: Array<ApplicationBranch>) => void; +} + +interface State { + isUpdating: boolean; +} + +export default class BranchRowActions extends React.PureComponent<Props, State> { + state: State = { isUpdating: false }; + + handleDelete = () => { + const { application, branch } = this.props; + return deleteApplicationBranch(application.key, branch.name).then(() => { + this.props.onUpdateBranches(without(application.branches, branch)); + }); + }; + + handleUpdate = (newBranchName: string) => { + this.props.onUpdateBranches( + this.props.application.branches.map(branch => { + if (branch.name === this.props.branch.name) { + branch.name = newBranchName; + } + return branch; + }) + ); + }; + + handleCloseForm = () => { + this.setState({ isUpdating: false }); + }; + + handleUpdateClick = () => { + this.setState({ isUpdating: true }); + }; + + render() { + return ( + <> + <ConfirmButton + confirmButtonText={translate('delete')} + isDestructive={true} + modalBody={translateWithParameters( + 'application_console.branches.delete.warning_x', + this.props.branch.name + )} + modalHeader={translate('application_console.branches.delete')} + onConfirm={this.handleDelete}> + {({ onClick }) => ( + <ActionsDropdown> + <ActionsDropdownItem onClick={this.handleUpdateClick}> + {translate('edit')} + </ActionsDropdownItem> + <ActionsDropdownItem destructive={true} onClick={onClick}> + {translate('delete')} + </ActionsDropdownItem> + </ActionsDropdown> + )} + </ConfirmButton> + + {this.state.isUpdating && ( + <CreateBranchForm + application={this.props.application} + branch={this.props.branch} + enabledProjectsKey={this.props.application.projects + .filter(p => p.enabled) + .map(p => p.key)} + onClose={this.handleCloseForm} + onCreate={() => {}} + onUpdate={this.handleUpdate} + /> + )} + </> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/application-console/BranchSelectItem.tsx b/server/sonar-web/src/main/js/apps/application-console/BranchSelectItem.tsx new file mode 100644 index 00000000000..d71350776a4 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/application-console/BranchSelectItem.tsx @@ -0,0 +1,76 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import * as React from 'react'; +import Tooltip from 'sonar-ui-common/components/controls/Tooltip'; +import BranchIcon from 'sonar-ui-common/components/icons/BranchIcon'; + +export interface Option { + label: string; + type: string; + value: string; +} + +interface Props { + option: Option; + children?: React.ReactNode; + className?: string; + isFocused?: boolean; + onFocus: (option: Option, event: React.SyntheticEvent<HTMLElement>) => void; + onSelect: (option: Option, event: React.SyntheticEvent<HTMLElement>) => void; +} + +export default class BranchSelectItem extends React.PureComponent<Props> { + handleMouseDown = (event: React.MouseEvent<HTMLElement>) => { + event.preventDefault(); + event.stopPropagation(); + this.props.onSelect(this.props.option, event); + }; + + handleMouseEnter = (event: React.MouseEvent<HTMLElement>) => { + this.props.onFocus(this.props.option, event); + }; + + handleMouseMove = (event: React.MouseEvent<HTMLElement>) => { + if (this.props.isFocused) { + return; + } + this.props.onFocus(this.props.option, event); + }; + + render() { + const { option } = this.props; + return ( + <Tooltip overlay={option.label} placement="left"> + <div + className={this.props.className} + onMouseDown={this.handleMouseDown} + onMouseEnter={this.handleMouseEnter} + onMouseMove={this.handleMouseMove} + role="listitem"> + <div> + <BranchIcon className="little-spacer-right" /> + {option.label} + </div> + </div> + </Tooltip> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/application-console/ConsoleApplicationApp.tsx b/server/sonar-web/src/main/js/apps/application-console/ConsoleApplicationApp.tsx new file mode 100644 index 00000000000..6ed3851a3f2 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/application-console/ConsoleApplicationApp.tsx @@ -0,0 +1,51 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { Location } from 'history'; +import * as React from 'react'; +import { InjectedRouter } from 'react-router'; +import ApplicationView from './ApplicationView'; + +interface Props { + component: { key: string }; + location: Location; + router: InjectedRouter; +} + +export default class ConsoleApplicationApp extends React.PureComponent<Props> { + doNothing = () => {}; + + render() { + return ( + <div className="page page-limited"> + <div className="navigator-content"> + <ApplicationView + applicationKey={this.props.component.key} + canRecompute={true} + onDelete={this.doNothing} + onEdit={this.doNothing} + pathname={this.props.location.pathname} + router={this.props.router} + /> + </div> + </div> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/application-console/CreateBranchForm.tsx b/server/sonar-web/src/main/js/apps/application-console/CreateBranchForm.tsx new file mode 100644 index 00000000000..c8fe5f4f2c3 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/application-console/CreateBranchForm.tsx @@ -0,0 +1,294 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { some, without } from 'lodash'; +import * as React from 'react'; +import { ResetButtonLink, SubmitButton } from 'sonar-ui-common/components/controls/buttons'; +import SimpleModal from 'sonar-ui-common/components/controls/SimpleModal'; +import DeferredSpinner from 'sonar-ui-common/components/ui/DeferredSpinner'; +import { translate } from 'sonar-ui-common/helpers/l10n'; +import { + addApplicationBranch, + getApplicationDetails, + updateApplicationBranch +} from '../../api/application'; +import { Application, ApplicationProject } from '../../types/application'; +import ProjectBranchRow from './ProjectBranchRow'; +import { ApplicationBranch, SelectBranchOption } from './utils'; + +interface Props { + application: Application; + branch?: ApplicationBranch; + enabledProjectsKey: string[]; + onClose: () => void; + onCreate: (branch: ApplicationBranch) => void; + onUpdate: (name: string) => void; +} + +interface BranchesList { + [name: string]: SelectBranchOption | null; +} + +interface State { + loading: boolean; + name: string; + projects: ApplicationProject[]; + selected: string[]; + selectedBranches: BranchesList; +} + +export default class CreateBranchForm extends React.PureComponent<Props, State> { + mounted = false; + node?: HTMLElement | null = null; + currentSelect?: HTMLElement | null = null; + + state: State = { + loading: false, + name: '', + projects: [], + selected: [], + selectedBranches: {} + }; + + componentDidMount() { + this.mounted = true; + const { application } = this.props; + const branch = this.props.branch ? this.props.branch.name : undefined; + this.setState({ loading: true }); + getApplicationDetails(application.key, branch).then( + application => { + if (this.mounted) { + const projects = application.projects.filter(p => + this.props.enabledProjectsKey.includes(p.key) + ); + const selected = projects.filter(p => p.selected).map(p => p.key); + const selectedBranches: BranchesList = {}; + projects.forEach(p => { + if (!p.enabled) { + selectedBranches[p.key] = null; + } else { + selectedBranches[p.key] = { + value: p.branch || '', + label: p.branch || '', + isMain: p.isMain || false + }; + } + }); + this.setState({ + name: branch || '', + selected, + loading: false, + projects, + selectedBranches + }); + } + }, + () => { + this.props.onClose(); + } + ); + } + + componentWillUnmount() { + this.mounted = false; + } + + stopLoading = () => { + if (this.mounted) { + this.setState({ loading: false }); + } + }; + + canSubmit = () => { + const hasUnselectedBranches = some(this.state.selectedBranches, (branch, projectKey) => { + return !branch && this.state.selected.includes(projectKey); + }); + return ( + !this.state.loading && + this.state.name.length > 0 && + !hasUnselectedBranches && + this.state.selected.length > 0 + ); + }; + + handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => { + this.setState({ name: event.currentTarget.value }); + }; + + handleFormSubmit = async () => { + const projectKeys = this.state.selected; + + const projectBranches = projectKeys.map(p => { + const branch = this.state.selectedBranches[p]; + return !branch || branch.isMain ? '' : branch.value; + }); + + if (this.props.branch) { + await updateApplicationBranch({ + application: this.props.application.key, + branch: this.props.branch.name, + name: this.state.name, + project: projectKeys, + projectBranch: projectBranches + }); + this.props.onUpdate(this.state.name); + } else { + await addApplicationBranch({ + application: this.props.application.key, + branch: this.state.name, + project: projectKeys, + projectBranch: projectBranches + }); + this.props.onCreate({ name: this.state.name, isMain: false }); + } + this.props.onClose(); + }; + + handleProjectCheck = (checked: boolean, key: string) => { + this.setState(state => ({ + selected: checked ? [...state.selected, key] : without(state.selected, key) + })); + }; + + handleBranchChange = (projectKey: string, branch: SelectBranchOption) => { + this.setState(state => ({ + selectedBranches: { ...state.selectedBranches, [projectKey]: branch } + })); + }; + + handleSelectorClose = () => { + if (this.node) { + this.node.classList.add('selector-hidden'); + } + }; + + handleSelectorDirection = (selectNode: HTMLElement, elementCount: number) => { + if (this.node) { + const modalTop = this.node.getBoundingClientRect().top; + const modalHeight = this.node.offsetHeight; + const maxSelectHeight = Math.min(220, elementCount * 22 + 22); + const selectBottom = selectNode.getBoundingClientRect().top + maxSelectHeight; + if (selectBottom > modalTop + modalHeight) { + this.node.classList.add('inverted-direction'); + } else { + this.node.classList.remove('inverted-direction'); + } + this.node.classList.remove('selector-hidden'); + } + }; + + renderProjectsList = () => { + return ( + <> + <strong className="spacer-left spacer-top"> + {translate('application_console.branches.configuration')} + </strong> + <p className="spacer-top big-spacer-bottom spacer-left spacer-right"> + {translate('application_console.branches.create.help')} + </p> + <table className="data zebra"> + <thead> + <tr> + <th className="thin" /> + <th className="thin">{translate('project')}</th> + <th>{translate('branch')}</th> + </tr> + </thead> + <tbody> + {this.state.projects.map(project => ( + <ProjectBranchRow + checked={this.state.selected.includes(project.key)} + key={project.key} + onChange={this.handleBranchChange} + onCheck={this.handleProjectCheck} + onClose={this.handleSelectorClose} + onOpen={this.handleSelectorDirection} + project={project} + /> + ))} + </tbody> + </table> + </> + ); + }; + + render() { + const isUpdating = this.props.branch !== undefined; + const header = translate('application_console.branches', isUpdating ? 'update' : 'create'); + return ( + <SimpleModal + header={header} + onClose={this.props.onClose} + onSubmit={this.handleFormSubmit} + size="medium"> + {({ onCloseClick, onFormSubmit, submitting }) => ( + <form className="views-form" onSubmit={onFormSubmit}> + <div className="modal-head"> + <h2>{header}</h2> + </div> + + <div + className="modal-body modal-container selector-hidden" + ref={node => (this.node = node)}> + {this.state.loading ? ( + <div className="text-center big-spacer-top big-spacer-bottom"> + <i className="spinner spacer-right" /> + </div> + ) : ( + <> + <div className="modal-field"> + <label htmlFor="view-edit-name"> + {translate('name')} <em className="mandatory">*</em> + </label> + <input + autoFocus={true} + className="input-super-large" + maxLength={250} + name="name" + onChange={this.handleInputChange} + size={50} + type="text" + value={this.state.name} + /> + </div> + {this.renderProjectsList()} + </> + )} + </div> + + <div className="modal-foot"> + <DeferredSpinner className="spacer-right" loading={submitting} /> + <SubmitButton disabled={submitting || !this.canSubmit()}> + {translate( + 'application_console.branches', + isUpdating ? 'update' : 'create', + 'verb' + )} + </SubmitButton> + <ResetButtonLink onClick={onCloseClick}> + {translate('application_console.branches.cancel')} + </ResetButtonLink> + </div> + </form> + )} + </SimpleModal> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/application-console/EditForm.tsx b/server/sonar-web/src/main/js/apps/application-console/EditForm.tsx new file mode 100644 index 00000000000..24c7caa3de8 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/application-console/EditForm.tsx @@ -0,0 +1,123 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import * as React from 'react'; +import { ResetButtonLink, SubmitButton } from 'sonar-ui-common/components/controls/buttons'; +import SimpleModal from 'sonar-ui-common/components/controls/SimpleModal'; +import DeferredSpinner from 'sonar-ui-common/components/ui/DeferredSpinner'; +import { translate } from 'sonar-ui-common/helpers/l10n'; + +interface Commons { + desc?: string; + description?: string; + key: string; + name: string; +} + +interface Props<T extends Commons> { + header: string; + onChange: (key: string, name: string, description: string) => Promise<void>; + onClose: () => void; + onEdit: (key: string, name: string, description: string) => void; + application: T; +} + +interface State { + description: string; + name: string; +} + +export default class EditForm<T extends Commons> extends React.PureComponent<Props<T>, State> { + constructor(props: Props<T>) { + super(props); + this.state = { + description: props.application.desc || props.application.description || '', + name: props.application.name + }; + } + + handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => { + this.setState({ name: event.currentTarget.value }); + }; + + handleDescriptionChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => { + this.setState({ description: event.currentTarget.value }); + }; + + handleFormSubmit = () => { + return this.props + .onChange(this.props.application.key, this.state.name, this.state.description) + .then(() => { + this.props.onEdit(this.props.application.key, this.state.name, this.state.description); + this.props.onClose(); + }); + }; + + render() { + return ( + <SimpleModal + header={this.props.header} + onClose={this.props.onClose} + onSubmit={this.handleFormSubmit} + size="small"> + {({ onCloseClick, onFormSubmit, submitting }) => ( + <form onSubmit={onFormSubmit}> + <div className="modal-head"> + <h2>{this.props.header}</h2> + </div> + + <div className="modal-body"> + <div className="modal-field"> + <label htmlFor="view-edit-name">{translate('name')}</label> + <input + autoFocus={true} + id="view-edit-name" + maxLength={100} + name="name" + onChange={this.handleNameChange} + size={50} + type="text" + value={this.state.name} + /> + </div> + <div className="modal-field"> + <label htmlFor="view-edit-description">{translate('description')}</label> + <textarea + id="view-edit-description" + name="description" + onChange={this.handleDescriptionChange} + value={this.state.description} + /> + </div> + </div> + + <div className="modal-foot"> + <DeferredSpinner className="spacer-right" loading={submitting} /> + <SubmitButton disabled={submitting || !this.state.name.length}> + {translate('save')} + </SubmitButton> + <ResetButtonLink onClick={onCloseClick}>{translate('cancel')}</ResetButtonLink> + </div> + </form> + )} + </SimpleModal> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/application-console/ProjectBranchRow.tsx b/server/sonar-web/src/main/js/apps/application-console/ProjectBranchRow.tsx new file mode 100644 index 00000000000..8061640b298 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/application-console/ProjectBranchRow.tsx @@ -0,0 +1,140 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import * as React from 'react'; +import Checkbox from 'sonar-ui-common/components/controls/Checkbox'; +import Select from 'sonar-ui-common/components/controls/Select'; +import Tooltip from 'sonar-ui-common/components/controls/Tooltip'; +import QualifierIcon from 'sonar-ui-common/components/icons/QualifierIcon'; +import DeferredSpinner from 'sonar-ui-common/components/ui/DeferredSpinner'; +import { getBranches } from '../../api/branches'; +import { ApplicationProject } from '../../types/application'; +import BranchSelectItem from './BranchSelectItem'; +import { ApplicationBranch, SelectBranchOption } from './utils'; + +interface Props { + checked: boolean; + onChange: (projectKey: string, branch: SelectBranchOption) => void; + onCheck: (checked: boolean, id?: string) => void; + onClose: () => void; + onOpen: (selectNode: HTMLElement, elementCount: number) => void; + project: ApplicationProject; +} + +interface State { + branches?: SelectBranchOption[]; + loading: boolean; + selectedBranch?: SelectBranchOption; +} + +export default class ProjectBranchRow extends React.PureComponent<Props, State> { + node?: HTMLElement | null = null; + mounted = false; + state: State = { loading: false }; + + componentDidMount() { + this.mounted = true; + } + + componentWillUnmount() { + this.mounted = false; + } + + parseBranches = (branches: Array<ApplicationBranch>) => { + return branches + .sort((a, b) => (a.name < b.name ? -1 : 1)) + .map(branch => { + return { value: branch.name, label: branch.name, isMain: branch.isMain }; + }); + }; + + setCurrentTarget = (event: React.FocusEvent<HTMLInputElement>) => { + this.node = event.target; + }; + + handleChange = (value: SelectBranchOption) => { + this.props.onChange(this.props.project.key, value); + this.setState({ selectedBranch: value }); + }; + + handleOpen = () => { + if (this.state.branches && this.node) { + this.props.onOpen(this.node, this.state.branches.length); + return; + } + + const { project } = this.props; + this.setState({ loading: true }); + getBranches(project.key).then( + branchesResult => { + const branches = this.parseBranches(branchesResult); + if (this.node) { + this.props.onOpen(this.node, branches.length); + } + if (this.mounted) { + this.setState({ branches, loading: false }); + } + }, + () => { + /* Fail silently*/ + } + ); + }; + + render() { + const { checked, onCheck, onClose, project } = this.props; + const options = this.state.branches || [ + { value: project.branch, label: project.branch, isMain: project.isMain } + ]; + const value = project.enabled + ? this.state.selectedBranch || project.branch + : this.state.selectedBranch; + return ( + <tr key={project.key}> + <td className="text-center"> + <Checkbox checked={checked} id={project.key} onCheck={onCheck} /> + </td> + <td className="nowrap hide-overflow branch-name-row"> + <Tooltip overlay={project.name}> + <span> + <QualifierIcon qualifier="TRK" /> {project.name} + </span> + </Tooltip> + </td> + <td> + <Select + className="width100" + clearable={false} + disabled={!checked} + onChange={this.handleChange} + onClose={onClose} + onFocus={this.setCurrentTarget} + onOpen={this.handleOpen} + optionComponent={BranchSelectItem} + options={options} + searchable={false} + value={value} + /> + <DeferredSpinner className="project-branch-row-spinner" loading={this.state.loading} /> + </td> + </tr> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/application-console/__tests__/ApplicationBranches-test.tsx b/server/sonar-web/src/main/js/apps/application-console/__tests__/ApplicationBranches-test.tsx new file mode 100644 index 00000000000..a4801806aac --- /dev/null +++ b/server/sonar-web/src/main/js/apps/application-console/__tests__/ApplicationBranches-test.tsx @@ -0,0 +1,60 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { shallow } from 'enzyme'; +import * as React from 'react'; +import { mockApplication, mockApplicationProject } from '../../../helpers/mocks/application'; +import { mockBranch, mockMainBranch } from '../../../helpers/mocks/branch-like'; +import ApplicationBranches from '../ApplicationBranches'; + +it('should render correctly', () => { + expect(shallowRender()).toMatchSnapshot('default'); + expect( + shallowRender({ + application: mockApplication({ projects: [mockApplicationProject({ enabled: true })] }) + }) + ).toMatchSnapshot('can create branches'); + const wrapper = shallowRender(); + wrapper.setState({ creating: true }); + expect(wrapper).toMatchSnapshot('creating branch'); +}); + +it('correctly triggers the onUpdateBranches prop', () => { + const onUpdateBranches = jest.fn(); + const branch = mockBranch(); + const branches = [mockMainBranch()]; + const wrapper = shallowRender({ application: mockApplication({ branches }), onUpdateBranches }); + const instance = wrapper.instance(); + + instance.handleCreateClick(); + expect(wrapper.state().creating).toBe(true); + + instance.handleCreateFormClose(); + expect(wrapper.state().creating).toBe(false); + + instance.handleCreate(branch); + expect(onUpdateBranches).toBeCalledWith([...branches, branch]); +}); + +function shallowRender(props: Partial<ApplicationBranches['props']> = {}) { + return shallow<ApplicationBranches>( + <ApplicationBranches application={mockApplication()} onUpdateBranches={jest.fn()} {...props} /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/application-console/__tests__/ApplicationDetails-test.tsx b/server/sonar-web/src/main/js/apps/application-console/__tests__/ApplicationDetails-test.tsx new file mode 100644 index 00000000000..0da4e909654 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/application-console/__tests__/ApplicationDetails-test.tsx @@ -0,0 +1,91 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { shallow } from 'enzyme'; +import * as React from 'react'; +import { click, waitAndUpdate } from 'sonar-ui-common/helpers/testUtils'; +import { deleteApplication, refreshApplication } from '../../../api/application'; +import addGlobalSuccessMessage from '../../../app/utils/addGlobalSuccessMessage'; +import { mockApplication } from '../../../helpers/mocks/application'; +import ApplicationDetails from '../ApplicationDetails'; +import EditForm from '../EditForm'; + +jest.mock('../../../api/application', () => ({ + deleteApplication: jest.fn().mockResolvedValue({}), + editApplication: jest.fn(), + refreshApplication: jest.fn().mockResolvedValue({}) +})); + +jest.mock('../../../app/utils/addGlobalSuccessMessage', () => ({ + default: jest.fn() +})); + +it('should render correctly', () => { + expect(shallowRender()).toMatchSnapshot('default'); + expect( + shallowRender({ + application: mockApplication({ description: 'Foo bar', key: 'foo' }), + canRecompute: true, + single: false + }) + ).toMatchSnapshot('can delete and recompute'); +}); + +it('should handle editing', () => { + const wrapper = shallowRender(); + click(wrapper.find('#view-details-edit')); + expect(wrapper.find(EditForm)).toMatchSnapshot('edit form'); +}); + +it('should handle deleting', async () => { + const onDelete = jest.fn(); + const wrapper = shallowRender({ onDelete, single: false }); + + wrapper.instance().handleDelete(); + expect(deleteApplication).toBeCalledWith('foo'); + await waitAndUpdate(wrapper); + expect(onDelete).toBeCalledWith('foo'); +}); + +it('should handle refreshing', async () => { + const wrapper = shallowRender({ single: false }); + + wrapper.instance().handleRefreshClick(); + expect(refreshApplication).toBeCalledWith('foo'); + await waitAndUpdate(wrapper); + expect(addGlobalSuccessMessage).toBeCalled(); +}); + +function shallowRender(props: Partial<ApplicationDetails['props']> = {}) { + return shallow<ApplicationDetails>( + <ApplicationDetails + application={mockApplication({ key: 'foo' })} + canRecompute={false} + onAddProject={jest.fn()} + onDelete={jest.fn()} + onEdit={jest.fn()} + onRemoveProject={jest.fn()} + onUpdateBranches={jest.fn()} + pathname="path/name" + single={true} + {...props} + /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/application-console/__tests__/ApplicationDetailsProjects-test.tsx b/server/sonar-web/src/main/js/apps/application-console/__tests__/ApplicationDetailsProjects-test.tsx new file mode 100644 index 00000000000..a484ea41d4c --- /dev/null +++ b/server/sonar-web/src/main/js/apps/application-console/__tests__/ApplicationDetailsProjects-test.tsx @@ -0,0 +1,98 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { shallow } from 'enzyme'; +import * as React from 'react'; +import SelectList, { SelectListFilter } from 'sonar-ui-common/components/controls/SelectList'; +import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils'; +import { + addProjectToApplication, + getApplicationProjects, + removeProjectFromApplication +} from '../../../api/application'; +import { mockApplication } from '../../../helpers/mocks/application'; +import ApplicationDetailsProjects from '../ApplicationDetailsProjects'; + +jest.mock('../../../api/application', () => ({ + getApplicationProjects: jest.fn().mockResolvedValue({ + paging: { pageIndex: 1, pageSize: 3, total: 55 }, + projects: [ + { key: 'test1', name: 'test1', selected: false }, + { key: 'test2', name: 'test2', selected: false, disabled: true, includedIn: 'foo' }, + { key: 'test3', name: 'test3', selected: true } + ] + }), + addProjectToApplication: jest.fn().mockResolvedValue({}), + removeProjectFromApplication: jest.fn().mockResolvedValue({}) +})); + +beforeEach(jest.clearAllMocks); + +it('should render correctly in application mode', async () => { + const wrapper = shallowRender(); + wrapper + .find(SelectList) + .props() + .onSearch({ query: '', filter: SelectListFilter.Selected, page: 1, pageSize: 100 }); + await waitAndUpdate(wrapper); + + expect(wrapper).toMatchSnapshot(); + expect(wrapper.instance().renderElement('test1')).toMatchSnapshot(); + expect(wrapper.instance().renderElement('test2')).toMatchSnapshot(); + + expect(getApplicationProjects).toHaveBeenCalledWith( + expect.objectContaining({ + application: 'foo', + p: 1, + ps: 100, + q: undefined, + selected: SelectListFilter.Selected + }) + ); + + wrapper.instance().handleSelect('test1'); + await waitAndUpdate(wrapper); + expect(addProjectToApplication).toHaveBeenCalledWith('foo', 'test1'); + + wrapper.instance().fetchProjects({ query: 'bar', filter: SelectListFilter.Selected }); + await waitAndUpdate(wrapper); + expect(getApplicationProjects).toHaveBeenCalledWith( + expect.objectContaining({ application: 'foo', q: 'bar', selected: SelectListFilter.Selected }) + ); + + wrapper.instance().handleUnselect('test1'); + await waitAndUpdate(wrapper); + expect(removeProjectFromApplication).toHaveBeenCalledWith('foo', 'test1'); +}); + +it('should refresh properly if props changes', () => { + const wrapper = shallowRender(); + const spy = jest.spyOn(wrapper.instance(), 'fetchProjects'); + + wrapper.setProps({ application: { key: 'bar' } as any }); + expect(wrapper.state().lastSearchParams.applicationKey).toBe('bar'); + expect(spy).toHaveBeenCalled(); +}); + +function shallowRender(props: Partial<ApplicationDetailsProjects['props']> = {}) { + return shallow<ApplicationDetailsProjects>( + <ApplicationDetailsProjects application={mockApplication()} {...props} /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/application-console/__tests__/ApplicationProjectBranch-test.tsx b/server/sonar-web/src/main/js/apps/application-console/__tests__/ApplicationProjectBranch-test.tsx new file mode 100644 index 00000000000..cbdb2680533 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/application-console/__tests__/ApplicationProjectBranch-test.tsx @@ -0,0 +1,43 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { shallow } from 'enzyme'; +import * as React from 'react'; +import { mockApplication } from '../../../helpers/mocks/application'; +import { mockBranch, mockMainBranch } from '../../../helpers/mocks/branch-like'; +import ApplicationProjectBranch, { + ApplicationProjectBranchProps +} from '../ApplicationProjectBranch'; + +it('should render correctly', () => { + expect(shallowRender()).toMatchSnapshot('default'); + expect(shallowRender({ branch: mockMainBranch() })).toMatchSnapshot('main branch'); +}); + +function shallowRender(props: Partial<ApplicationProjectBranchProps> = {}) { + return shallow<ApplicationProjectBranchProps>( + <ApplicationProjectBranch + application={mockApplication()} + branch={mockBranch()} + onUpdateBranches={jest.fn()} + {...props} + /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/application-console/__tests__/ApplicationView-test.tsx b/server/sonar-web/src/main/js/apps/application-console/__tests__/ApplicationView-test.tsx new file mode 100644 index 00000000000..5e16858561c --- /dev/null +++ b/server/sonar-web/src/main/js/apps/application-console/__tests__/ApplicationView-test.tsx @@ -0,0 +1,110 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import { shallow } from 'enzyme'; +import * as React from 'react'; +import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils'; +import { getApplicationDetails } from '../../../api/application'; +import { mockApplication, mockApplicationProject } from '../../../helpers/mocks/application'; +import { mockRouter } from '../../../helpers/testMocks'; +import { Application } from '../../../types/application'; +import ApplicationView from '../ApplicationView'; + +jest.mock('../../../api/application', () => ({ + getApplicationDetails: jest.fn().mockResolvedValue({}) +})); + +it('Should render correctly', () => { + expect(shallowRender()).toMatchSnapshot(); +}); + +it('Should add project to application', async () => { + const app = mockApplication(); + const project = mockApplicationProject({ key: 'FOO' }); + (getApplicationDetails as jest.Mock<Promise<Application>>).mockRejectedValueOnce(app); + let wrapper = shallowRender({}); + wrapper.instance().handleAddProject(project); + expect(wrapper.state().application?.projects).toBeUndefined(); + + (getApplicationDetails as jest.Mock<Promise<Application>>).mockResolvedValueOnce(app); + wrapper = shallowRender({}); + await waitAndUpdate(wrapper); + wrapper.instance().handleAddProject(project); + expect(wrapper.state().application?.projects).toContain(project); +}); + +it('Should remove project from application', async () => { + const project = mockApplicationProject({ key: 'FOO' }); + const app = mockApplication({ projects: [project] }); + + (getApplicationDetails as jest.Mock<Promise<Application>>).mockRejectedValueOnce(app); + let wrapper = shallowRender({}); + wrapper.instance().handleRemoveProject('FOO'); + expect(wrapper.state().application?.projects).toBeUndefined(); + + (getApplicationDetails as jest.Mock<Promise<Application>>).mockResolvedValueOnce(app); + wrapper = shallowRender({}); + await waitAndUpdate(wrapper); + wrapper.instance().handleRemoveProject('FOO'); + expect(wrapper.state().application?.projects.length).toBe(0); +}); + +it('Should edit application correctly', async () => { + const app = mockApplication(); + + (getApplicationDetails as jest.Mock<Promise<Application>>).mockRejectedValueOnce(app); + let wrapper = shallowRender({}); + wrapper.instance().handleEdit(app.key, 'NEW_NAME', 'NEW_DESC'); + expect(wrapper.state().application).toBeUndefined(); + + (getApplicationDetails as jest.Mock<Promise<Application>>).mockResolvedValueOnce(app); + wrapper = shallowRender({}); + await waitAndUpdate(wrapper); + wrapper.instance().handleEdit(app.key, 'NEW_NAME', 'NEW_DESC'); + expect(wrapper.state().application?.name).toBe('NEW_NAME'); + expect(wrapper.state().application?.description).toBe('NEW_DESC'); +}); + +it('Should update branch correctly', async () => { + const app = mockApplication(); + + (getApplicationDetails as jest.Mock<Promise<Application>>).mockRejectedValueOnce(app); + let wrapper = shallowRender({}); + wrapper.instance().handleUpdateBranches([]); + expect(wrapper.state().application).toBeUndefined(); + + (getApplicationDetails as jest.Mock<Promise<Application>>).mockResolvedValueOnce(app); + wrapper = shallowRender({}); + await waitAndUpdate(wrapper); + wrapper.instance().handleUpdateBranches([]); + expect(wrapper.state().application?.branches.length).toBe(0); +}); + +function shallowRender(props: Partial<ApplicationView['props']> = {}) { + return shallow<ApplicationView>( + <ApplicationView + applicationKey={'1'} + onDelete={jest.fn()} + onEdit={jest.fn()} + pathname={'test'} + router={mockRouter()} + {...props} + /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/application-console/__tests__/BranchRowActions-test.tsx b/server/sonar-web/src/main/js/apps/application-console/__tests__/BranchRowActions-test.tsx new file mode 100644 index 00000000000..b51e41fe23b --- /dev/null +++ b/server/sonar-web/src/main/js/apps/application-console/__tests__/BranchRowActions-test.tsx @@ -0,0 +1,39 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import { shallow } from 'enzyme'; +import * as React from 'react'; +import { mockApplication } from '../../../helpers/mocks/application'; +import { mockBranch } from '../../../helpers/mocks/branch-like'; +import BranchRowActions from '../BranchRowActions'; + +it('Should render correctly', () => { + expect(shallowRender()).toMatchSnapshot(); +}); + +function shallowRender(props: Partial<BranchRowActions['props']> = {}) { + return shallow<BranchRowActions>( + <BranchRowActions + application={mockApplication()} + branch={mockBranch()} + onUpdateBranches={jest.fn()} + {...props} + /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/application-console/__tests__/BranchSelectItem-test.tsx b/server/sonar-web/src/main/js/apps/application-console/__tests__/BranchSelectItem-test.tsx new file mode 100644 index 00000000000..703c02b6ff3 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/application-console/__tests__/BranchSelectItem-test.tsx @@ -0,0 +1,37 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import { shallow } from 'enzyme'; +import * as React from 'react'; +import BranchSelectItem from '../BranchSelectItem'; + +it('Should render correctly', () => { + expect(shallowRender()).toMatchSnapshot(); +}); + +function shallowRender(props: Partial<BranchSelectItem['props']> = {}) { + return shallow<BranchSelectItem>( + <BranchSelectItem + option={{ label: 'test', type: 'type', value: 'value' }} + onFocus={jest.fn()} + onSelect={jest.fn()} + {...props} + /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/application-console/__tests__/ConsoleApplicationApp-test.tsx b/server/sonar-web/src/main/js/apps/application-console/__tests__/ConsoleApplicationApp-test.tsx new file mode 100644 index 00000000000..5328b5623a4 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/application-console/__tests__/ConsoleApplicationApp-test.tsx @@ -0,0 +1,38 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import { shallow } from 'enzyme'; +import * as React from 'react'; +import { mockComponent, mockLocation, mockRouter } from '../../../helpers/testMocks'; +import ConsoleApplicationApp from '../ConsoleApplicationApp'; + +it('Should render correctly', () => { + expect(shallowRender()).toMatchSnapshot(); +}); + +function shallowRender(props: Partial<ConsoleApplicationApp['props']> = {}) { + return shallow<ConsoleApplicationApp>( + <ConsoleApplicationApp + component={mockComponent()} + location={mockLocation()} + router={mockRouter()} + {...props} + /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/application-console/__tests__/CreateBranchForm-test.tsx b/server/sonar-web/src/main/js/apps/application-console/__tests__/CreateBranchForm-test.tsx new file mode 100644 index 00000000000..67f41feb5af --- /dev/null +++ b/server/sonar-web/src/main/js/apps/application-console/__tests__/CreateBranchForm-test.tsx @@ -0,0 +1,132 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import { shallow } from 'enzyme'; +import * as React from 'react'; +import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils'; +import { getApplicationDetails } from '../../../api/application'; +import { mockApplication, mockApplicationProject } from '../../../helpers/mocks/application'; +import { Application } from '../../../types/application'; +import CreateBranchForm from '../CreateBranchForm'; + +jest.mock('../../../api/application', () => ({ + getApplicationDetails: jest.fn().mockResolvedValue({}), + addApplicationBranch: jest.fn(), + updateApplicationBranch: jest.fn() +})); + +it('Should handle submit correctly', async () => { + const app = mockApplication(); + (getApplicationDetails as jest.Mock<Promise<Application>>).mockResolvedValueOnce(app); + const handleClose = jest.fn(); + const handleCeate = jest.fn(); + const handleUpdate = jest.fn(); + let wrapper = shallowRender({ application: app, onClose: handleClose, onCreate: handleCeate }); + wrapper.instance().handleFormSubmit(); + await waitAndUpdate(wrapper); + expect(handleClose).toHaveBeenCalled(); + expect(handleCeate).toHaveBeenCalled(); + + (getApplicationDetails as jest.Mock<Promise<Application>>).mockResolvedValueOnce(app); + wrapper = shallowRender({ + application: app, + branch: { isMain: false, name: 'foo' }, + onUpdate: handleUpdate + }); + wrapper.instance().handleFormSubmit(); + await waitAndUpdate(wrapper); + expect(handleUpdate).toHaveBeenCalled(); +}); + +it('Should render correctly', async () => { + const app = mockApplication({ + projects: [ + mockApplicationProject({ key: '1', enabled: true }), + mockApplicationProject({ key: '2', enabled: true }), + mockApplicationProject({ key: '3', enabled: false }), + mockApplicationProject({ enabled: false }) + ] + }); + (getApplicationDetails as jest.Mock<Promise<Application>>).mockResolvedValueOnce(app); + const wrapper = shallowRender({ application: app, enabledProjectsKey: ['1', '3'] }); + await waitAndUpdate(wrapper); + expect(wrapper).toMatchSnapshot(); +}); + +it('Should close when no response', async () => { + const app = mockApplication(); + (getApplicationDetails as jest.Mock<Promise<Application>>).mockRejectedValueOnce(app); + const handleClose = jest.fn(); + const wrapper = shallowRender({ application: app, onClose: handleClose }); + await waitAndUpdate(wrapper); + expect(handleClose).toHaveBeenCalled(); +}); + +it('Should update loading flag', () => { + const wrapper = shallowRender(); + wrapper.setState({ loading: true }); + wrapper.instance().stopLoading(); + expect(wrapper.state().loading).toBe(false); +}); + +it('Should update on input event', () => { + const wrapper = shallowRender(); + wrapper.setState({ name: '' }); + wrapper + .instance() + .handleInputChange(({ currentTarget: { value: 'bar' } } as any) as React.ChangeEvent< + HTMLInputElement + >); + expect(wrapper.state().name).toBe('bar'); +}); + +it('Should tell if it can submit correctly', () => { + const wrapper = shallowRender(); + wrapper.setState({ loading: true }); + expect(wrapper.instance().canSubmit()).toBe(false); + wrapper.setState({ loading: false, name: '' }); + expect(wrapper.instance().canSubmit()).toBe(false); + wrapper.setState({ + loading: false, + name: 'ok', + selectedBranches: { foo: null }, + selected: ['foo'] + }); + expect(wrapper.instance().canSubmit()).toBe(false); + wrapper.setState({ + loading: false, + name: 'ok', + selectedBranches: { foo: { label: 'foo', isMain: true, value: 'foo' } }, + selected: ['foo'] + }); + expect(wrapper.instance().canSubmit()).toBe(true); +}); + +function shallowRender(props: Partial<CreateBranchForm['props']> = {}) { + return shallow<CreateBranchForm>( + <CreateBranchForm + application={mockApplication()} + enabledProjectsKey={[]} + onClose={jest.fn()} + onCreate={jest.fn()} + onUpdate={jest.fn()} + {...props} + /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/application-console/__tests__/EditForm-test.tsx b/server/sonar-web/src/main/js/apps/application-console/__tests__/EditForm-test.tsx new file mode 100644 index 00000000000..08145c96f3f --- /dev/null +++ b/server/sonar-web/src/main/js/apps/application-console/__tests__/EditForm-test.tsx @@ -0,0 +1,66 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +/* eslint-disable sonarjs/no-duplicate-string */ +import { shallow } from 'enzyme'; +import * as React from 'react'; +import SimpleModal from 'sonar-ui-common/components/controls/SimpleModal'; +import { change, waitAndUpdate } from 'sonar-ui-common/helpers/testUtils'; +import { mockApplication } from '../../../helpers/mocks/application'; +import { Application } from '../../../types/application'; +import EditForm from '../EditForm'; + +it('should render correctly', () => { + expect( + shallowRender() + .find(SimpleModal) + .dive() + ).toMatchSnapshot(); +}); + +it('should correctly submit the new info', async () => { + const onChange = jest.fn().mockResolvedValue({}); + const onClose = jest.fn(); + const onEdit = jest.fn(); + const wrapper = shallowRender({ onChange, onClose, onEdit }); + const modal = wrapper.find(SimpleModal).dive(); + + change(modal.find('#view-edit-name'), 'New name'); + change(modal.find('#view-edit-description'), 'New description'); + + wrapper.instance().handleFormSubmit(); + expect(onChange).toBeCalledWith('foo', 'New name', 'New description'); + await waitAndUpdate(wrapper); + expect(onEdit).toBeCalledWith('foo', 'New name', 'New description'); + expect(onClose).toBeCalled(); +}); + +function shallowRender(props: Partial<EditForm<Application>['props']> = {}) { + return shallow<EditForm<Application>>( + <EditForm + header="Edit" + onChange={jest.fn()} + onClose={jest.fn()} + onEdit={jest.fn()} + application={mockApplication()} + {...props} + /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/application-console/__tests__/ProjectBranchRow-test.tsx b/server/sonar-web/src/main/js/apps/application-console/__tests__/ProjectBranchRow-test.tsx new file mode 100644 index 00000000000..378f91f5ab9 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/application-console/__tests__/ProjectBranchRow-test.tsx @@ -0,0 +1,41 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import { shallow } from 'enzyme'; +import * as React from 'react'; +import { mockApplicationProject } from '../../../helpers/mocks/application'; +import ProjectBranchRow from '../ProjectBranchRow'; + +it('Should render correctly', () => { + expect(shallowRender()).toMatchSnapshot(); +}); + +function shallowRender(props: Partial<ProjectBranchRow['props']> = {}) { + return shallow<ProjectBranchRow>( + <ProjectBranchRow + checked={true} + onChange={jest.fn()} + onCheck={jest.fn()} + onOpen={jest.fn()} + onClose={jest.fn()} + project={mockApplicationProject()} + {...props} + /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/ApplicationBranches-test.tsx.snap b/server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/ApplicationBranches-test.tsx.snap new file mode 100644 index 00000000000..d291936d7be --- /dev/null +++ b/server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/ApplicationBranches-test.tsx.snap @@ -0,0 +1,176 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly: can create branches 1`] = ` +<div + className="app-branches-console" +> + <div + className="boxed-group-actions" + > + <Button + disabled={false} + onClick={[Function]} + > + application_console.branches.create + </Button> + </div> + <h2 + className="text-limited big-spacer-top" + title="application_console.branches" + > + application_console.branches + </h2> + <p> + application_console.branches.help + </p> + <div + className="app-branches-list" + > + <table + className="data zebra" + > + <tbody> + <ApplicationProjectBranch + application={ + Object { + "branches": Array [ + Object { + "analysisDate": "2018-01-01", + "excludedFromPurge": true, + "isMain": false, + "name": "branch-6.7", + }, + ], + "key": "foo", + "name": "Foo", + "projects": Array [ + Object { + "branch": "master", + "enabled": true, + "isMain": true, + "key": "bar", + "name": "Bar", + }, + ], + "visibility": "private", + } + } + branch={ + Object { + "analysisDate": "2018-01-01", + "excludedFromPurge": true, + "isMain": false, + "name": "branch-6.7", + } + } + key="branch-6.7" + onUpdateBranches={[MockFunction]} + /> + </tbody> + </table> + </div> +</div> +`; + +exports[`should render correctly: creating branch 1`] = ` +<div + className="app-branches-console" +> + <div + className="boxed-group-actions" + > + <Button + disabled={true} + onClick={[Function]} + > + application_console.branches.create + </Button> + </div> + <h2 + className="text-limited big-spacer-top" + title="application_console.branches" + > + application_console.branches + </h2> + <p> + application_console.branches.help + </p> + <div + className="app-branches-list" + > + <p + className="text-center big-spacer-top" + > + application_console.branches.no_branches + </p> + </div> + <CreateBranchForm + application={ + Object { + "branches": Array [ + Object { + "analysisDate": "2018-01-01", + "excludedFromPurge": true, + "isMain": false, + "name": "branch-6.7", + }, + ], + "key": "foo", + "name": "Foo", + "projects": Array [ + Object { + "branch": "master", + "isMain": true, + "key": "bar", + "name": "Bar", + }, + ], + "visibility": "private", + } + } + enabledProjectsKey={ + Array [ + "bar", + ] + } + onClose={[Function]} + onCreate={[Function]} + onUpdate={[Function]} + /> +</div> +`; + +exports[`should render correctly: default 1`] = ` +<div + className="app-branches-console" +> + <div + className="boxed-group-actions" + > + <Button + disabled={true} + onClick={[Function]} + > + application_console.branches.create + </Button> + </div> + <h2 + className="text-limited big-spacer-top" + title="application_console.branches" + > + application_console.branches + </h2> + <p> + application_console.branches.help + </p> + <div + className="app-branches-list" + > + <p + className="text-center big-spacer-top" + > + application_console.branches.no_branches + </p> + </div> +</div> +`; diff --git a/server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/ApplicationDetails-test.tsx.snap b/server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/ApplicationDetails-test.tsx.snap new file mode 100644 index 00000000000..c9dd363ca4c --- /dev/null +++ b/server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/ApplicationDetails-test.tsx.snap @@ -0,0 +1,284 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should handle editing: edit form 1`] = ` +<EditForm + application={ + Object { + "branches": Array [ + Object { + "analysisDate": "2018-01-01", + "excludedFromPurge": true, + "isMain": false, + "name": "branch-6.7", + }, + ], + "key": "foo", + "name": "Foo", + "projects": Array [ + Object { + "branch": "master", + "isMain": true, + "key": "bar", + "name": "Bar", + }, + ], + "visibility": "private", + } + } + header="portfolios.edit_application" + onChange={[MockFunction]} + onClose={[Function]} + onEdit={[MockFunction]} +/> +`; + +exports[`should render correctly: can delete and recompute 1`] = ` +<div + className="boxed-group portfolios-console-details" + id="view-details" +> + <div + className="boxed-group-actions" + > + <Button + className="little-spacer-right" + id="view-details-edit" + onClick={[Function]} + > + edit + </Button> + <Button + className="little-spacer-right" + disabled={false} + onClick={[Function]} + > + application_console.recompute + </Button> + <ConfirmButton + confirmButtonText="delete" + isDestructive={true} + modalBody="application_console.do_you_want_to_delete.Foo" + modalHeader="application_console.delete_application" + onConfirm={[Function]} + > + <Component /> + </ConfirmButton> + </div> + <header + className="boxed-group-header" + id="view-details-header" + > + <h2 + className="text-limited" + title="Foo" + > + Foo + </h2> + </header> + <div + className="boxed-group-inner" + id="view-details-content" + > + <div + className="big-spacer-bottom" + > + <div + className="little-spacer-bottom" + > + Foo bar + </div> + <div + className="subtitle" + > + key + : + foo + <Link + className="spacer-left" + onlyActiveOnIndex={false} + style={Object {}} + to={ + Object { + "pathname": "/dashboard", + "query": Object { + "id": "foo", + }, + } + } + > + application_console.open_dashbard + </Link> + </div> + </div> + <ApplicationDetailsProjects + application={ + Object { + "branches": Array [ + Object { + "analysisDate": "2018-01-01", + "excludedFromPurge": true, + "isMain": false, + "name": "branch-6.7", + }, + ], + "description": "Foo bar", + "key": "foo", + "name": "Foo", + "projects": Array [ + Object { + "branch": "master", + "isMain": true, + "key": "bar", + "name": "Bar", + }, + ], + "visibility": "private", + } + } + onAddProject={[MockFunction]} + onRemoveProject={[MockFunction]} + /> + <ApplicationBranches + application={ + Object { + "branches": Array [ + Object { + "analysisDate": "2018-01-01", + "excludedFromPurge": true, + "isMain": false, + "name": "branch-6.7", + }, + ], + "description": "Foo bar", + "key": "foo", + "name": "Foo", + "projects": Array [ + Object { + "branch": "master", + "isMain": true, + "key": "bar", + "name": "Bar", + }, + ], + "visibility": "private", + } + } + onUpdateBranches={[MockFunction]} + /> + </div> +</div> +`; + +exports[`should render correctly: default 1`] = ` +<div + className="boxed-group portfolios-console-details" + id="view-details" +> + <div + className="boxed-group-actions" + > + <Button + className="little-spacer-right" + id="view-details-edit" + onClick={[Function]} + > + edit + </Button> + </div> + <header + className="boxed-group-header" + id="view-details-header" + > + <h2 + className="text-limited" + title="Foo" + > + Foo + </h2> + </header> + <div + className="boxed-group-inner" + id="view-details-content" + > + <div + className="big-spacer-bottom" + > + <div + className="subtitle" + > + key + : + foo + <Link + className="spacer-left" + onlyActiveOnIndex={false} + style={Object {}} + to={ + Object { + "pathname": "/dashboard", + "query": Object { + "id": "foo", + }, + } + } + > + application_console.open_dashbard + </Link> + </div> + </div> + <ApplicationDetailsProjects + application={ + Object { + "branches": Array [ + Object { + "analysisDate": "2018-01-01", + "excludedFromPurge": true, + "isMain": false, + "name": "branch-6.7", + }, + ], + "key": "foo", + "name": "Foo", + "projects": Array [ + Object { + "branch": "master", + "isMain": true, + "key": "bar", + "name": "Bar", + }, + ], + "visibility": "private", + } + } + onAddProject={[MockFunction]} + onRemoveProject={[MockFunction]} + /> + <ApplicationBranches + application={ + Object { + "branches": Array [ + Object { + "analysisDate": "2018-01-01", + "excludedFromPurge": true, + "isMain": false, + "name": "branch-6.7", + }, + ], + "key": "foo", + "name": "Foo", + "projects": Array [ + Object { + "branch": "master", + "isMain": true, + "key": "bar", + "name": "Bar", + }, + ], + "visibility": "private", + } + } + onUpdateBranches={[MockFunction]} + /> + </div> +</div> +`; diff --git a/server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/ApplicationDetailsProjects-test.tsx.snap b/server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/ApplicationDetailsProjects-test.tsx.snap new file mode 100644 index 00000000000..caedd4c5d95 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/ApplicationDetailsProjects-test.tsx.snap @@ -0,0 +1,72 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly in application mode 1`] = ` +<SelectList + disabledElements={Array []} + elements={ + Array [ + "test1", + "test2", + "test3", + ] + } + elementsTotalCount={55} + needToReload={false} + onSearch={[Function]} + onSelect={[Function]} + onUnselect={[Function]} + renderElement={[Function]} + selectedElements={ + Array [ + "test3", + ] + } + withPaging={true} +/> +`; + +exports[`should render correctly in application mode 2`] = ` +<div + className="views-project-item display-flex-center" +> + <QualifierIcon + className="spacer-right" + qualifier="TRK" + /> + <div> + <div + title="test1" + > + test1 + </div> + <div + className="note" + > + test1 + </div> + </div> +</div> +`; + +exports[`should render correctly in application mode 3`] = ` +<div + className="views-project-item display-flex-center" +> + <QualifierIcon + className="spacer-right" + qualifier="TRK" + /> + <div> + <div + title="test2" + > + test2 + </div> + <div + className="note" + > + test2 + </div> + </div> +</div> +`; diff --git a/server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/ApplicationProjectBranch-test.tsx.snap b/server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/ApplicationProjectBranch-test.tsx.snap new file mode 100644 index 00000000000..9fe73abb7c5 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/ApplicationProjectBranch-test.tsx.snap @@ -0,0 +1,69 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly: default 1`] = ` +<tr> + <td> + <BranchIcon + className="little-spacer-right" + /> + branch-6.7 + </td> + <td + className="thin nowrap" + > + <BranchRowActions + application={ + Object { + "branches": Array [ + Object { + "analysisDate": "2018-01-01", + "excludedFromPurge": true, + "isMain": false, + "name": "branch-6.7", + }, + ], + "key": "foo", + "name": "Foo", + "projects": Array [ + Object { + "branch": "master", + "isMain": true, + "key": "bar", + "name": "Bar", + }, + ], + "visibility": "private", + } + } + branch={ + Object { + "analysisDate": "2018-01-01", + "excludedFromPurge": true, + "isMain": false, + "name": "branch-6.7", + } + } + onUpdateBranches={[MockFunction]} + /> + </td> +</tr> +`; + +exports[`should render correctly: main branch 1`] = ` +<tr> + <td> + <BranchIcon + className="little-spacer-right" + /> + master + <span + className="badge spacer-left" + > + application_console.branches.main_branch + </span> + </td> + <td + className="thin nowrap" + /> +</tr> +`; diff --git a/server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/ApplicationView-test.tsx.snap b/server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/ApplicationView-test.tsx.snap new file mode 100644 index 00000000000..84227b75517 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/ApplicationView-test.tsx.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Should render correctly 1`] = ` +<i + className="spinner spacer" +/> +`; diff --git a/server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/BranchRowActions-test.tsx.snap b/server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/BranchRowActions-test.tsx.snap new file mode 100644 index 00000000000..7c1f10a8b4a --- /dev/null +++ b/server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/BranchRowActions-test.tsx.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Should render correctly 1`] = ` +<Fragment> + <ConfirmButton + confirmButtonText="delete" + isDestructive={true} + modalBody="application_console.branches.delete.warning_x.branch-6.7" + modalHeader="application_console.branches.delete" + onConfirm={[Function]} + > + <Component /> + </ConfirmButton> +</Fragment> +`; diff --git a/server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/BranchSelectItem-test.tsx.snap b/server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/BranchSelectItem-test.tsx.snap new file mode 100644 index 00000000000..a9878d576af --- /dev/null +++ b/server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/BranchSelectItem-test.tsx.snap @@ -0,0 +1,22 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Should render correctly 1`] = ` +<Tooltip + overlay="test" + placement="left" +> + <div + onMouseDown={[Function]} + onMouseEnter={[Function]} + onMouseMove={[Function]} + role="listitem" + > + <div> + <BranchIcon + className="little-spacer-right" + /> + test + </div> + </div> +</Tooltip> +`; diff --git a/server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/ConsoleApplicationApp-test.tsx.snap b/server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/ConsoleApplicationApp-test.tsx.snap new file mode 100644 index 00000000000..323f4996dcf --- /dev/null +++ b/server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/ConsoleApplicationApp-test.tsx.snap @@ -0,0 +1,32 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Should render correctly 1`] = ` +<div + className="page page-limited" +> + <div + className="navigator-content" + > + <ApplicationView + applicationKey="my-project" + canRecompute={true} + onDelete={[Function]} + onEdit={[Function]} + pathname="/path" + router={ + Object { + "createHref": [MockFunction], + "createPath": [MockFunction], + "go": [MockFunction], + "goBack": [MockFunction], + "goForward": [MockFunction], + "isActive": [MockFunction], + "push": [MockFunction], + "replace": [MockFunction], + "setRouteLeaveHook": [MockFunction], + } + } + /> + </div> +</div> +`; diff --git a/server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/CreateBranchForm-test.tsx.snap b/server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/CreateBranchForm-test.tsx.snap new file mode 100644 index 00000000000..72322d18438 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/CreateBranchForm-test.tsx.snap @@ -0,0 +1,12 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Should render correctly 1`] = ` +<SimpleModal + header="application_console.branches.create" + onClose={[MockFunction]} + onSubmit={[Function]} + size="medium" +> + <Component /> +</SimpleModal> +`; diff --git a/server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/EditForm-test.tsx.snap b/server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/EditForm-test.tsx.snap new file mode 100644 index 00000000000..46f70be1267 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/EditForm-test.tsx.snap @@ -0,0 +1,77 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly 1`] = ` +<Modal + contentLabel="Edit" + onRequestClose={[MockFunction]} + size="small" +> + <form + onSubmit={[Function]} + > + <div + className="modal-head" + > + <h2> + Edit + </h2> + </div> + <div + className="modal-body" + > + <div + className="modal-field" + > + <label + htmlFor="view-edit-name" + > + name + </label> + <input + autoFocus={true} + id="view-edit-name" + maxLength={100} + name="name" + onChange={[Function]} + size={50} + type="text" + value="Foo" + /> + </div> + <div + className="modal-field" + > + <label + htmlFor="view-edit-description" + > + description + </label> + <textarea + id="view-edit-description" + name="description" + onChange={[Function]} + value="" + /> + </div> + </div> + <div + className="modal-foot" + > + <DeferredSpinner + className="spacer-right" + loading={false} + /> + <SubmitButton + disabled={false} + > + save + </SubmitButton> + <ResetButtonLink + onClick={[Function]} + > + cancel + </ResetButtonLink> + </div> + </form> +</Modal> +`; diff --git a/server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/ProjectBranchRow-test.tsx.snap b/server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/ProjectBranchRow-test.tsx.snap new file mode 100644 index 00000000000..b2a8af392e0 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/ProjectBranchRow-test.tsx.snap @@ -0,0 +1,59 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Should render correctly 1`] = ` +<tr + key="bar" +> + <td + className="text-center" + > + <Checkbox + checked={true} + id="bar" + onCheck={[MockFunction]} + thirdState={false} + /> + </td> + <td + className="nowrap hide-overflow branch-name-row" + > + <Tooltip + overlay="Bar" + > + <span> + <QualifierIcon + qualifier="TRK" + /> + + Bar + </span> + </Tooltip> + </td> + <td> + <Select + className="width100" + clearable={false} + disabled={false} + onChange={[Function]} + onClose={[MockFunction]} + onFocus={[Function]} + onOpen={[Function]} + optionComponent={[Function]} + options={ + Array [ + Object { + "isMain": true, + "label": "master", + "value": "master", + }, + ] + } + searchable={false} + /> + <DeferredSpinner + className="project-branch-row-spinner" + loading={false} + /> + </td> +</tr> +`; diff --git a/server/sonar-web/src/main/js/apps/application-console/routes.ts b/server/sonar-web/src/main/js/apps/application-console/routes.ts new file mode 100644 index 00000000000..6ddddead00d --- /dev/null +++ b/server/sonar-web/src/main/js/apps/application-console/routes.ts @@ -0,0 +1,28 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import { lazyLoadComponent } from 'sonar-ui-common/components/lazyLoadComponent'; + +const routes = [ + { + indexRoute: { component: lazyLoadComponent(() => import('./ConsoleApplicationApp')) } + } +]; + +export default routes; diff --git a/server/sonar-web/src/main/js/apps/application-console/utils.ts b/server/sonar-web/src/main/js/apps/application-console/utils.ts new file mode 100644 index 00000000000..08eeb1561ef --- /dev/null +++ b/server/sonar-web/src/main/js/apps/application-console/utils.ts @@ -0,0 +1,29 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { Branch } from '../../types/branch-like'; + +export interface SelectBranchOption { + value: string; + label: string; + isMain: boolean; +} + +export type ApplicationBranch = Pick<Branch, 'isMain' | 'name'>; diff --git a/server/sonar-web/src/main/js/apps/permissions/global/components/AllHoldersList.tsx b/server/sonar-web/src/main/js/apps/permissions/global/components/AllHoldersList.tsx index 08c6ee95695..ac153dcd69f 100644 --- a/server/sonar-web/src/main/js/apps/permissions/global/components/AllHoldersList.tsx +++ b/server/sonar-web/src/main/js/apps/permissions/global/components/AllHoldersList.tsx @@ -21,12 +21,13 @@ import * as React from 'react'; import { connect } from 'react-redux'; import ListFooter from 'sonar-ui-common/components/controls/ListFooter'; import { getAppState, Store } from '../../../../store/rootReducer'; +import { ComponentQualifier } from '../../../../types/component'; import HoldersList from '../../shared/components/HoldersList'; import SearchForm from '../../shared/components/SearchForm'; import { convertToPermissionDefinitions, - PERMISSIONS_ORDER_GLOBAL, - PERMISSIONS_ORDER_GLOBAL_GOV + filterPermissions, + PERMISSIONS_ORDER_GLOBAL } from '../../utils'; interface StateProps { @@ -75,11 +76,13 @@ export class AllHoldersList extends React.PureComponent<Props> { }; render() { - const { filter, groups, groupsPaging, users, usersPaging } = this.props; + const { appState, filter, groups, groupsPaging, users, usersPaging } = this.props; const l10nPrefix = this.props.organization ? 'organizations_permissions' : 'global_permissions'; - const governanceInstalled = this.props.appState.qualifiers.includes('VW'); + + const hasPortfoliosEnabled = appState.qualifiers.includes(ComponentQualifier.Portfolio); + const hasApplicationsEnabled = appState.qualifiers.includes(ComponentQualifier.Application); const permissions = convertToPermissionDefinitions( - governanceInstalled ? PERMISSIONS_ORDER_GLOBAL_GOV : PERMISSIONS_ORDER_GLOBAL, + filterPermissions(PERMISSIONS_ORDER_GLOBAL, hasApplicationsEnabled, hasPortfoliosEnabled), l10nPrefix ); diff --git a/server/sonar-web/src/main/js/apps/permissions/global/components/__tests__/AllHoldersList-test.tsx b/server/sonar-web/src/main/js/apps/permissions/global/components/__tests__/AllHoldersList-test.tsx new file mode 100644 index 00000000000..a2d2a6aa5b0 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/permissions/global/components/__tests__/AllHoldersList-test.tsx @@ -0,0 +1,94 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { shallow } from 'enzyme'; +import * as React from 'react'; +import { mockPermissionGroup, mockPermissionUser } from '../../../../../helpers/mocks/permissions'; +import { ComponentQualifier } from '../../../../../types/component'; +import { AllHoldersList } from '../AllHoldersList'; + +it('should render correctly', () => { + expect(shallowRender()).toMatchSnapshot('default'); + expect(shallowRender({ filter: 'users' })).toMatchSnapshot('filter users'); + expect(shallowRender({ filter: 'groups' })).toMatchSnapshot('filter groups'); + expect( + shallowRender({ + appState: { qualifiers: [ComponentQualifier.Project, ComponentQualifier.Application] } + }) + ).toMatchSnapshot('applications available'); + expect( + shallowRender({ + appState: { qualifiers: [ComponentQualifier.Project, ComponentQualifier.Portfolio] } + }) + ).toMatchSnapshot('portfolios available'); +}); + +it('should correctly toggle user permissions', () => { + const grantPermissionToUser = jest.fn(); + const revokePermissionFromUser = jest.fn(); + const grantPermission = 'applicationcreator'; + const revokePermission = 'provisioning'; + const user = mockPermissionUser(); + const wrapper = shallowRender({ grantPermissionToUser, revokePermissionFromUser }); + const instance = wrapper.instance(); + + instance.handleToggleUser(user, grantPermission); + expect(grantPermissionToUser).toBeCalledWith(user.login, grantPermission); + + instance.handleToggleUser(user, revokePermission); + expect(revokePermissionFromUser).toBeCalledWith(user.login, revokePermission); +}); + +it('should correctly toggle group permissions', () => { + const grantPermissionToGroup = jest.fn(); + const revokePermissionFromGroup = jest.fn(); + const grantPermission = 'applicationcreator'; + const revokePermission = 'provisioning'; + const group = mockPermissionGroup(); + const wrapper = shallowRender({ grantPermissionToGroup, revokePermissionFromGroup }); + const instance = wrapper.instance(); + + instance.handleToggleGroup(group, grantPermission); + expect(grantPermissionToGroup).toBeCalledWith(group.name, grantPermission); + + instance.handleToggleGroup(group, revokePermission); + expect(revokePermissionFromGroup).toBeCalledWith(group.name, revokePermission); +}); + +function shallowRender(props: Partial<AllHoldersList['props']> = {}) { + return shallow<AllHoldersList>( + <AllHoldersList + appState={{ qualifiers: [ComponentQualifier.Project] }} + filter="" + grantPermissionToGroup={jest.fn()} + grantPermissionToUser={jest.fn()} + groups={[mockPermissionGroup()]} + loadHolders={jest.fn()} + onLoadMore={jest.fn()} + onFilter={jest.fn()} + onSearch={jest.fn()} + query="" + revokePermissionFromGroup={jest.fn()} + revokePermissionFromUser={jest.fn()} + users={[mockPermissionUser()]} + {...props} + /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/permissions/global/components/__tests__/__snapshots__/AllHoldersList-test.tsx.snap b/server/sonar-web/src/main/js/apps/permissions/global/components/__tests__/__snapshots__/AllHoldersList-test.tsx.snap new file mode 100644 index 00000000000..62855ead53b --- /dev/null +++ b/server/sonar-web/src/main/js/apps/permissions/global/components/__tests__/__snapshots__/AllHoldersList-test.tsx.snap @@ -0,0 +1,436 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly: applications available 1`] = ` +<Fragment> + <HoldersList + filter="" + groups={ + Array [ + Object { + "name": "sonar-admins", + "permissions": Array [ + "provisioning", + ], + }, + ] + } + onToggleGroup={[Function]} + onToggleUser={[Function]} + permissions={ + Array [ + Object { + "description": "global_permissions.admin.desc", + "key": "admin", + "name": "global_permissions.admin", + }, + Object { + "category": "administer", + "permissions": Array [ + Object { + "description": "global_permissions.gateadmin.desc", + "key": "gateadmin", + "name": "global_permissions.gateadmin", + }, + Object { + "description": "global_permissions.profileadmin.desc", + "key": "profileadmin", + "name": "global_permissions.profileadmin", + }, + ], + }, + Object { + "description": "global_permissions.scan.desc", + "key": "scan", + "name": "global_permissions.scan", + }, + Object { + "category": "creator", + "permissions": Array [ + Object { + "description": "global_permissions.provisioning.desc", + "key": "provisioning", + "name": "global_permissions.provisioning", + }, + Object { + "description": "global_permissions.applicationcreator.desc", + "key": "applicationcreator", + "name": "global_permissions.applicationcreator", + }, + ], + }, + ] + } + query="" + users={ + Array [ + Object { + "active": true, + "local": true, + "login": "john.doe", + "name": "johndoe", + "permissions": Array [ + "provisioning", + ], + }, + ] + } + > + <SearchForm + filter="" + onFilter={[MockFunction]} + onSearch={[MockFunction]} + query="" + /> + </HoldersList> + <ListFooter + count={2} + loadMore={[MockFunction]} + total={2} + /> +</Fragment> +`; + +exports[`should render correctly: default 1`] = ` +<Fragment> + <HoldersList + filter="" + groups={ + Array [ + Object { + "name": "sonar-admins", + "permissions": Array [ + "provisioning", + ], + }, + ] + } + onToggleGroup={[Function]} + onToggleUser={[Function]} + permissions={ + Array [ + Object { + "description": "global_permissions.admin.desc", + "key": "admin", + "name": "global_permissions.admin", + }, + Object { + "category": "administer", + "permissions": Array [ + Object { + "description": "global_permissions.gateadmin.desc", + "key": "gateadmin", + "name": "global_permissions.gateadmin", + }, + Object { + "description": "global_permissions.profileadmin.desc", + "key": "profileadmin", + "name": "global_permissions.profileadmin", + }, + ], + }, + Object { + "description": "global_permissions.scan.desc", + "key": "scan", + "name": "global_permissions.scan", + }, + Object { + "category": "creator", + "permissions": Array [ + Object { + "description": "global_permissions.provisioning.desc", + "key": "provisioning", + "name": "global_permissions.provisioning", + }, + ], + }, + ] + } + query="" + users={ + Array [ + Object { + "active": true, + "local": true, + "login": "john.doe", + "name": "johndoe", + "permissions": Array [ + "provisioning", + ], + }, + ] + } + > + <SearchForm + filter="" + onFilter={[MockFunction]} + onSearch={[MockFunction]} + query="" + /> + </HoldersList> + <ListFooter + count={2} + loadMore={[MockFunction]} + total={2} + /> +</Fragment> +`; + +exports[`should render correctly: filter groups 1`] = ` +<Fragment> + <HoldersList + filter="groups" + groups={ + Array [ + Object { + "name": "sonar-admins", + "permissions": Array [ + "provisioning", + ], + }, + ] + } + onToggleGroup={[Function]} + onToggleUser={[Function]} + permissions={ + Array [ + Object { + "description": "global_permissions.admin.desc", + "key": "admin", + "name": "global_permissions.admin", + }, + Object { + "category": "administer", + "permissions": Array [ + Object { + "description": "global_permissions.gateadmin.desc", + "key": "gateadmin", + "name": "global_permissions.gateadmin", + }, + Object { + "description": "global_permissions.profileadmin.desc", + "key": "profileadmin", + "name": "global_permissions.profileadmin", + }, + ], + }, + Object { + "description": "global_permissions.scan.desc", + "key": "scan", + "name": "global_permissions.scan", + }, + Object { + "category": "creator", + "permissions": Array [ + Object { + "description": "global_permissions.provisioning.desc", + "key": "provisioning", + "name": "global_permissions.provisioning", + }, + ], + }, + ] + } + query="" + users={ + Array [ + Object { + "active": true, + "local": true, + "login": "john.doe", + "name": "johndoe", + "permissions": Array [ + "provisioning", + ], + }, + ] + } + > + <SearchForm + filter="groups" + onFilter={[MockFunction]} + onSearch={[MockFunction]} + query="" + /> + </HoldersList> + <ListFooter + count={1} + loadMore={[MockFunction]} + total={1} + /> +</Fragment> +`; + +exports[`should render correctly: filter users 1`] = ` +<Fragment> + <HoldersList + filter="users" + groups={ + Array [ + Object { + "name": "sonar-admins", + "permissions": Array [ + "provisioning", + ], + }, + ] + } + onToggleGroup={[Function]} + onToggleUser={[Function]} + permissions={ + Array [ + Object { + "description": "global_permissions.admin.desc", + "key": "admin", + "name": "global_permissions.admin", + }, + Object { + "category": "administer", + "permissions": Array [ + Object { + "description": "global_permissions.gateadmin.desc", + "key": "gateadmin", + "name": "global_permissions.gateadmin", + }, + Object { + "description": "global_permissions.profileadmin.desc", + "key": "profileadmin", + "name": "global_permissions.profileadmin", + }, + ], + }, + Object { + "description": "global_permissions.scan.desc", + "key": "scan", + "name": "global_permissions.scan", + }, + Object { + "category": "creator", + "permissions": Array [ + Object { + "description": "global_permissions.provisioning.desc", + "key": "provisioning", + "name": "global_permissions.provisioning", + }, + ], + }, + ] + } + query="" + users={ + Array [ + Object { + "active": true, + "local": true, + "login": "john.doe", + "name": "johndoe", + "permissions": Array [ + "provisioning", + ], + }, + ] + } + > + <SearchForm + filter="users" + onFilter={[MockFunction]} + onSearch={[MockFunction]} + query="" + /> + </HoldersList> + <ListFooter + count={1} + loadMore={[MockFunction]} + total={1} + /> +</Fragment> +`; + +exports[`should render correctly: portfolios available 1`] = ` +<Fragment> + <HoldersList + filter="" + groups={ + Array [ + Object { + "name": "sonar-admins", + "permissions": Array [ + "provisioning", + ], + }, + ] + } + onToggleGroup={[Function]} + onToggleUser={[Function]} + permissions={ + Array [ + Object { + "description": "global_permissions.admin.desc", + "key": "admin", + "name": "global_permissions.admin", + }, + Object { + "category": "administer", + "permissions": Array [ + Object { + "description": "global_permissions.gateadmin.desc", + "key": "gateadmin", + "name": "global_permissions.gateadmin", + }, + Object { + "description": "global_permissions.profileadmin.desc", + "key": "profileadmin", + "name": "global_permissions.profileadmin", + }, + ], + }, + Object { + "description": "global_permissions.scan.desc", + "key": "scan", + "name": "global_permissions.scan", + }, + Object { + "category": "creator", + "permissions": Array [ + Object { + "description": "global_permissions.provisioning.desc", + "key": "provisioning", + "name": "global_permissions.provisioning", + }, + Object { + "description": "global_permissions.portfoliocreator.desc", + "key": "portfoliocreator", + "name": "global_permissions.portfoliocreator", + }, + ], + }, + ] + } + query="" + users={ + Array [ + Object { + "active": true, + "local": true, + "login": "john.doe", + "name": "johndoe", + "permissions": Array [ + "provisioning", + ], + }, + ] + } + > + <SearchForm + filter="" + onFilter={[MockFunction]} + onSearch={[MockFunction]} + query="" + /> + </HoldersList> + <ListFooter + count={2} + loadMore={[MockFunction]} + total={2} + /> +</Fragment> +`; diff --git a/server/sonar-web/src/main/js/apps/permissions/project/components/PageHeader.tsx b/server/sonar-web/src/main/js/apps/permissions/project/components/PageHeader.tsx index eae9cb4b87e..64c1e76cd4b 100644 --- a/server/sonar-web/src/main/js/apps/permissions/project/components/PageHeader.tsx +++ b/server/sonar-web/src/main/js/apps/permissions/project/components/PageHeader.tsx @@ -20,6 +20,7 @@ import * as React from 'react'; import { Button } from 'sonar-ui-common/components/controls/buttons'; import { translate } from 'sonar-ui-common/helpers/l10n'; +import { isApplication, isPortfolioLike } from '../../../../types/component'; import ApplyTemplate from './ApplyTemplate'; interface Props { @@ -60,9 +61,12 @@ export default class PageHeader extends React.PureComponent<Props, State> { const canApplyPermissionTemplate = configuration != null && configuration.canApplyPermissionTemplate; - const description = ['VW', 'SVW', 'APP'].includes(component.qualifier) - ? translate('roles.page.description_portfolio') - : translate('roles.page.description2'); + let description = translate('roles.page.description2'); + if (isPortfolioLike(component.qualifier)) { + description = translate('roles.page.description_portfolio'); + } else if (isApplication(component.qualifier)) { + description = translate('roles.page.description_application'); + } const visibilityDescription = component.qualifier === 'TRK' && component.visibility diff --git a/server/sonar-web/src/main/js/apps/permissions/utils.ts b/server/sonar-web/src/main/js/apps/permissions/utils.ts index a5cf29932e8..65b8734371f 100644 --- a/server/sonar-web/src/main/js/apps/permissions/utils.ts +++ b/server/sonar-web/src/main/js/apps/permissions/utils.ts @@ -33,13 +33,6 @@ export const PERMISSIONS_ORDER_GLOBAL = [ 'admin', { category: 'administer', permissions: ['gateadmin', 'profileadmin'] }, 'scan', - { category: 'creator', permissions: ['provisioning'] } -]; - -export const PERMISSIONS_ORDER_GLOBAL_GOV = [ - 'admin', - { category: 'administer', permissions: ['gateadmin', 'profileadmin'] }, - 'scan', { category: 'creator', permissions: ['provisioning', 'applicationcreator', 'portfoliocreator'] } ]; @@ -73,6 +66,28 @@ function convertToPermissionDefinition(permission: string, l10nPrefix: string) { }; } +export function filterPermissions( + permissions: Array<string | { category: string; permissions: string[] }>, + hasApplicationsEnabled: boolean, + hasPortfoliosEnabled: boolean +) { + return permissions.map(permission => { + if (typeof permission === 'object' && permission.category === 'creator') { + return { + ...permission, + permissions: permission.permissions.filter(p => { + return ( + p === 'provisioning' || + (p === 'portfoliocreator' && hasPortfoliosEnabled) || + (p === 'applicationcreator' && hasApplicationsEnabled) + ); + }) + }; + } + return permission; + }); +} + export function convertToPermissionDefinitions( permissions: Array<string | { category: string; permissions: string[] }>, l10nPrefix: string diff --git a/server/sonar-web/src/main/js/apps/projectDeletion/Form.tsx b/server/sonar-web/src/main/js/apps/projectDeletion/Form.tsx index f179ec2fed2..d5d1a4fda5b 100644 --- a/server/sonar-web/src/main/js/apps/projectDeletion/Form.tsx +++ b/server/sonar-web/src/main/js/apps/projectDeletion/Form.tsx @@ -21,10 +21,11 @@ import * as React from 'react'; import { Button } from 'sonar-ui-common/components/controls/buttons'; import ConfirmButton from 'sonar-ui-common/components/controls/ConfirmButton'; import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n'; +import { deleteApplication } from '../../api/application'; import { deletePortfolio, deleteProject } from '../../api/components'; import addGlobalSuccessMessage from '../../app/utils/addGlobalSuccessMessage'; import { Router, withRouter } from '../../components/hoc/withRouter'; -import { ComponentQualifier, isPortfolioLike } from '../../types/component'; +import { isApplication, isPortfolioLike } from '../../types/component'; interface Props { component: Pick<T.Component, 'key' | 'name' | 'qualifier'>; @@ -34,9 +35,14 @@ interface Props { export class Form extends React.PureComponent<Props> { handleDelete = async () => { const { component } = this.props; - const deleteMethod = - component.qualifier === ComponentQualifier.Project ? deleteProject : deletePortfolio; - const redirectTo = isPortfolioLike(component.qualifier) ? '/portfolios' : '/'; + let deleteMethod = deleteProject; + let redirectTo = '/'; + if (isPortfolioLike(component.qualifier)) { + deleteMethod = deletePortfolio; + redirectTo = '/portfolios'; + } else if (isApplication(component.qualifier)) { + deleteMethod = deleteApplication; + } await deleteMethod(component.key); diff --git a/server/sonar-web/src/main/js/apps/projectDeletion/__tests__/Form-test.tsx b/server/sonar-web/src/main/js/apps/projectDeletion/__tests__/Form-test.tsx index b279c6ce22f..ba0ca0b2a8c 100644 --- a/server/sonar-web/src/main/js/apps/projectDeletion/__tests__/Form-test.tsx +++ b/server/sonar-web/src/main/js/apps/projectDeletion/__tests__/Form-test.tsx @@ -19,6 +19,7 @@ */ import { shallow } from 'enzyme'; import * as React from 'react'; +import { deleteApplication } from '../../../api/application'; import { deletePortfolio, deleteProject } from '../../../api/components'; import { mockRouter } from '../../../helpers/testMocks'; import { Form } from '../Form'; @@ -28,6 +29,10 @@ jest.mock('../../../api/components', () => ({ deletePortfolio: jest.fn().mockResolvedValue(undefined) })); +jest.mock('../../../api/application', () => ({ + deleteApplication: jest.fn().mockResolvedValue(undefined) +})); + beforeEach(() => { jest.clearAllMocks(); }); @@ -56,6 +61,19 @@ it('should delete portfolio', async () => { form.prop<Function>('onConfirm')(); expect(deletePortfolio).toBeCalledWith('foo'); expect(deleteProject).not.toBeCalled(); + expect(deleteApplication).not.toBeCalled(); await new Promise(setImmediate); expect(router.replace).toBeCalledWith('/portfolios'); }); + +it('should delete application', async () => { + const component = { key: 'foo', name: 'Foo', qualifier: 'APP' }; + const router = mockRouter(); + const form = shallow(<Form component={component} router={router} />); + form.prop<Function>('onConfirm')(); + expect(deleteApplication).toBeCalledWith('foo'); + expect(deleteProject).not.toBeCalled(); + expect(deletePortfolio).not.toBeCalled(); + await new Promise(setImmediate); + expect(router.replace).toBeCalledWith('/'); +}); diff --git a/server/sonar-web/src/main/js/helpers/mocks/application.ts b/server/sonar-web/src/main/js/helpers/mocks/application.ts index 8f81272cea9..d4b7b5b45ed 100644 --- a/server/sonar-web/src/main/js/helpers/mocks/application.ts +++ b/server/sonar-web/src/main/js/helpers/mocks/application.ts @@ -17,7 +17,20 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { ApplicationPeriod } from '../../types/application'; +import { Application, ApplicationPeriod, ApplicationProject } from '../../types/application'; +import { Visibility } from '../../types/component'; +import { mockBranch } from './branch-like'; + +export function mockApplication(overrides: Partial<Application> = {}): Application { + return { + branches: [mockBranch()], + key: 'foo', + name: 'Foo', + projects: [mockApplicationProject()], + visibility: Visibility.Private, + ...overrides + }; +} export function mockApplicationPeriod( overrides: Partial<ApplicationPeriod> = {} @@ -29,3 +42,15 @@ export function mockApplicationPeriod( ...overrides }; } + +export function mockApplicationProject( + overrides: Partial<ApplicationProject> = {} +): ApplicationProject { + return { + branch: 'master', + isMain: true, + key: 'bar', + name: 'Bar', + ...overrides + }; +} diff --git a/server/sonar-web/src/main/js/helpers/mocks/permissions.ts b/server/sonar-web/src/main/js/helpers/mocks/permissions.ts new file mode 100644 index 00000000000..a72310e2701 --- /dev/null +++ b/server/sonar-web/src/main/js/helpers/mocks/permissions.ts @@ -0,0 +1,38 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import { mockUser } from '../testMocks'; + +export function mockPermissionGroup(overrides: Partial<T.PermissionGroup> = {}): T.PermissionGroup { + return { + name: 'sonar-admins', + permissions: ['provisioning'], + ...overrides + }; +} + +export function mockPermissionUser(overrides: Partial<T.PermissionUser> = {}): T.PermissionUser { + return { + ...mockUser(), + active: true, + name: 'johndoe', + permissions: ['provisioning'], + ...overrides + }; +} diff --git a/server/sonar-web/src/main/js/helpers/urls.ts b/server/sonar-web/src/main/js/helpers/urls.ts index b6e55ea8111..1e45ca3cf47 100644 --- a/server/sonar-web/src/main/js/helpers/urls.ts +++ b/server/sonar-web/src/main/js/helpers/urls.ts @@ -21,7 +21,7 @@ import { pick } from 'lodash'; import { getBaseUrl, Location } from 'sonar-ui-common/helpers/urls'; import { getProfilePath } from '../apps/quality-profiles/utils'; import { BranchLike, BranchParameters } from '../types/branch-like'; -import { ComponentQualifier, isPortfolioLike } from '../types/component'; +import { ComponentQualifier, isApplication, isPortfolioLike } from '../types/component'; import { GraphType } from '../types/project-activity'; import { SecurityStandard } from '../types/security'; import { getBranchLikeQuery, isBranch, isMainBranch, isPullRequest } from './branch-like'; @@ -38,6 +38,19 @@ export function getComponentOverviewUrl( : getProjectQueryUrl(componentKey, branchParameters); } +export function getComponentAdminUrl( + componentKey: string, + componentQualifier: ComponentQualifier | string +) { + if (isPortfolioLike(componentQualifier)) { + return getPortfolioAdminUrl(componentKey); + } else if (isApplication(componentQualifier)) { + return getApplicationAdminUrl(componentKey); + } else { + return getProjectUrl(componentKey); + } +} + export function getProjectUrl(project: string, branch?: string): Location { return { pathname: '/dashboard', query: { id: project, branch } }; } @@ -50,8 +63,15 @@ export function getPortfolioUrl(key: string): Location { return { pathname: '/portfolio', query: { id: key } }; } -export function getPortfolioAdminUrl(key: string, qualifier: string) { - return { pathname: '/project/admin/extension/governance/console', query: { id: key, qualifier } }; +export function getPortfolioAdminUrl(key: string) { + return { + pathname: '/project/admin/extension/governance/console', + query: { id: key, qualifier: ComponentQualifier.Portfolio } + }; +} + +export function getApplicationAdminUrl(key: string) { + return { pathname: '/application/console', query: { id: key } }; } export function getComponentBackgroundTaskUrl(componentKey: string, status?: string): Location { diff --git a/server/sonar-web/src/main/js/types/component.ts b/server/sonar-web/src/main/js/types/component.ts index 031f30db859..f7896de4f18 100644 --- a/server/sonar-web/src/main/js/types/component.ts +++ b/server/sonar-web/src/main/js/types/component.ts @@ -56,7 +56,9 @@ export interface TreeComponentWithPath extends TreeComponent { path: string; } -export function isPortfolioLike(componentQualifier?: string | ComponentQualifier) { +export function isPortfolioLike( + componentQualifier?: string | ComponentQualifier +): componentQualifier is ComponentQualifier.Portfolio | ComponentQualifier.SubPortfolio { return Boolean( componentQualifier && [ @@ -65,3 +67,9 @@ export function isPortfolioLike(componentQualifier?: string | ComponentQualifier ].includes(componentQualifier) ); } + +export function isApplication( + componentQualifier?: string | ComponentQualifier +): componentQualifier is ComponentQualifier.Application { + return componentQualifier === ComponentQualifier.Application; +} diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index 26c7b1861aa..e4d3e0bccda 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -525,12 +525,32 @@ visibility.private.description.long=Only members of the organization will be abl # #------------------------------------------------------------------------------ +application_console.branches=Application Branches +application_console.branches.cancel=Cancel +application_console.branches.configuration=Branch configuration +application_console.branches.create=Create branch +application_console.branches.create.verb=Create +application_console.branches.create.help=For each project of your Application, choose a project branch that will be displayed inside the Application’s branch. +application_console.branches.delete=Delete branch +application_console.branches.delete.warning_x=Are you sure you want to delete "{0}" ? +application_console.branches.help=Track branches other than the main branch of this application's projects. +application_console.branches.main_branch=Main Branch +application_console.branches.update=Update branch +application_console.branches.update.verb=Update +application_console.branches.no_branches=No branches yet. You can create branches once projects are selected for this Application. +application_console.page=Edit Definition +application_console.open_dashbard=Open Dashboard +application_console.delete_application=Delete Application +application_console.recompute=Recompute +application_console.refresh_started=Your application will be recomputed soon +application_console.do_you_want_to_delete=Are you sure that you want to delete "{0}"? coding_rules.page=Rules global_permissions.page=Global Permissions global_permissions.page.description=Grant and revoke permissions to make changes at the global level. These permissions include editing Quality Profiles, executing analysis, and performing global system administration. roles.page=Project Permissions roles.page.description2=Grant and revoke project-level permissions. Permissions can be granted to groups or individual users. roles.page.description_portfolio=Grant and revoke portfolio-level permissions. Permissions can be granted to groups or individual users. +roles.page.description_application=Grant and revoke application-level permissions. Permissions can be granted to groups or individual users. project_settings.page=General Settings project_settings.page.description=Edit project settings. project_links.page=Links @@ -2724,7 +2744,7 @@ background_task.type.REPORT=Project Analysis background_task.type.DEV_REFRESH=Developer Analysis background_task.type.DEV_PURGE=Developer Cleaning background_task.type.ISSUE_SYNC=Project Data Reload -background_task.type.VIEW_REFRESH=Portfolio Calculation +background_task.type.APP_REFRESH=Recomputation background_task.type.PROJECT_EXPORT=Project Export background_task.type.PROJECT_IMPORT=Project Import @@ -3216,6 +3236,8 @@ onboarding.create_project.select_repositories=Select repositories onboarding.create_project.select_all_repositories=Select all available repositories onboarding.create_project.from_bbs=Create a project from Bitbucket Server +onboarding.create_application.key.description=If specified, this value is used as the key instead of generating it from the name of the Application. Only letters, digits, dashes and underscores can be used. + onboarding.create_project.pat_form.title.bitbucket=Grant access to your repositories onboarding.create_project.pat_form.title.gitlab=Grant access to your projects onboarding.create_project.pat_form.help.bitbucket=SonarQube needs a personal access token to access and list your repositories from Bitbucket Server. |