aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--server/sonar-web/src/main/js/api/application.ts78
-rw-r--r--server/sonar-web/src/main/js/app/components/extensions/CreateApplicationForm.tsx185
-rw-r--r--server/sonar-web/src/main/js/app/components/extensions/__tests__/CreateApplicationForm-test.tsx80
-rw-r--r--server/sonar-web/src/main/js/app/components/extensions/__tests__/__snapshots__/CreateApplicationForm-test.tsx.snap150
-rw-r--r--server/sonar-web/src/main/js/app/components/nav/component/Menu.tsx23
-rw-r--r--server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/Menu-test.tsx.snap41
-rw-r--r--server/sonar-web/src/main/js/app/components/nav/component/branch-like/CurrentBranchLike.tsx4
-rw-r--r--server/sonar-web/src/main/js/app/components/nav/component/branch-like/__tests__/__snapshots__/CurrentBranchLike-test.tsx.snap3
-rw-r--r--server/sonar-web/src/main/js/app/components/nav/global/GlobalNavPlus.tsx36
-rw-r--r--server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavPlus-test.tsx29
-rw-r--r--server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavPlus-test.tsx.snap2
-rw-r--r--server/sonar-web/src/main/js/app/utils/startReactApp.tsx2
-rw-r--r--server/sonar-web/src/main/js/apps/application-console/ApplicationBranches.tsx120
-rw-r--r--server/sonar-web/src/main/js/apps/application-console/ApplicationDetails.tsx180
-rw-r--r--server/sonar-web/src/main/js/apps/application-console/ApplicationDetailsProjects.tsx221
-rw-r--r--server/sonar-web/src/main/js/apps/application-console/ApplicationProjectBranch.tsx58
-rw-r--r--server/sonar-web/src/main/js/apps/application-console/ApplicationView.tsx169
-rw-r--r--server/sonar-web/src/main/js/apps/application-console/BranchRowActions.tsx111
-rw-r--r--server/sonar-web/src/main/js/apps/application-console/BranchSelectItem.tsx76
-rw-r--r--server/sonar-web/src/main/js/apps/application-console/ConsoleApplicationApp.tsx51
-rw-r--r--server/sonar-web/src/main/js/apps/application-console/CreateBranchForm.tsx294
-rw-r--r--server/sonar-web/src/main/js/apps/application-console/EditForm.tsx123
-rw-r--r--server/sonar-web/src/main/js/apps/application-console/ProjectBranchRow.tsx140
-rw-r--r--server/sonar-web/src/main/js/apps/application-console/__tests__/ApplicationBranches-test.tsx60
-rw-r--r--server/sonar-web/src/main/js/apps/application-console/__tests__/ApplicationDetails-test.tsx91
-rw-r--r--server/sonar-web/src/main/js/apps/application-console/__tests__/ApplicationDetailsProjects-test.tsx98
-rw-r--r--server/sonar-web/src/main/js/apps/application-console/__tests__/ApplicationProjectBranch-test.tsx43
-rw-r--r--server/sonar-web/src/main/js/apps/application-console/__tests__/ApplicationView-test.tsx110
-rw-r--r--server/sonar-web/src/main/js/apps/application-console/__tests__/BranchRowActions-test.tsx39
-rw-r--r--server/sonar-web/src/main/js/apps/application-console/__tests__/BranchSelectItem-test.tsx37
-rw-r--r--server/sonar-web/src/main/js/apps/application-console/__tests__/ConsoleApplicationApp-test.tsx38
-rw-r--r--server/sonar-web/src/main/js/apps/application-console/__tests__/CreateBranchForm-test.tsx132
-rw-r--r--server/sonar-web/src/main/js/apps/application-console/__tests__/EditForm-test.tsx66
-rw-r--r--server/sonar-web/src/main/js/apps/application-console/__tests__/ProjectBranchRow-test.tsx41
-rw-r--r--server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/ApplicationBranches-test.tsx.snap176
-rw-r--r--server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/ApplicationDetails-test.tsx.snap284
-rw-r--r--server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/ApplicationDetailsProjects-test.tsx.snap72
-rw-r--r--server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/ApplicationProjectBranch-test.tsx.snap69
-rw-r--r--server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/ApplicationView-test.tsx.snap7
-rw-r--r--server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/BranchRowActions-test.tsx.snap15
-rw-r--r--server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/BranchSelectItem-test.tsx.snap22
-rw-r--r--server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/ConsoleApplicationApp-test.tsx.snap32
-rw-r--r--server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/CreateBranchForm-test.tsx.snap12
-rw-r--r--server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/EditForm-test.tsx.snap77
-rw-r--r--server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/ProjectBranchRow-test.tsx.snap59
-rw-r--r--server/sonar-web/src/main/js/apps/application-console/routes.ts28
-rw-r--r--server/sonar-web/src/main/js/apps/application-console/utils.ts29
-rw-r--r--server/sonar-web/src/main/js/apps/permissions/global/components/AllHoldersList.tsx13
-rw-r--r--server/sonar-web/src/main/js/apps/permissions/global/components/__tests__/AllHoldersList-test.tsx94
-rw-r--r--server/sonar-web/src/main/js/apps/permissions/global/components/__tests__/__snapshots__/AllHoldersList-test.tsx.snap436
-rw-r--r--server/sonar-web/src/main/js/apps/permissions/project/components/PageHeader.tsx10
-rw-r--r--server/sonar-web/src/main/js/apps/permissions/utils.ts29
-rw-r--r--server/sonar-web/src/main/js/apps/projectDeletion/Form.tsx14
-rw-r--r--server/sonar-web/src/main/js/apps/projectDeletion/__tests__/Form-test.tsx18
-rw-r--r--server/sonar-web/src/main/js/helpers/mocks/application.ts27
-rw-r--r--server/sonar-web/src/main/js/helpers/mocks/permissions.ts38
-rw-r--r--server/sonar-web/src/main/js/helpers/urls.ts26
-rw-r--r--server/sonar-web/src/main/js/types/component.ts10
-rw-r--r--sonar-core/src/main/resources/org/sonar/l10n/core.properties24
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.