Browse Source

SONAR-13950 Move Application UI logic to DE

tags/8.6.0.39681
Wouter Admiraal 3 years ago
parent
commit
51488b107e
59 changed files with 4481 additions and 71 deletions
  1. 76
    2
      server/sonar-web/src/main/js/api/application.ts
  2. 185
    0
      server/sonar-web/src/main/js/app/components/extensions/CreateApplicationForm.tsx
  3. 80
    0
      server/sonar-web/src/main/js/app/components/extensions/__tests__/CreateApplicationForm-test.tsx
  4. 150
    0
      server/sonar-web/src/main/js/app/components/extensions/__tests__/__snapshots__/CreateApplicationForm-test.tsx.snap
  5. 20
    3
      server/sonar-web/src/main/js/app/components/nav/component/Menu.tsx
  6. 29
    12
      server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/Menu-test.tsx.snap
  7. 2
    2
      server/sonar-web/src/main/js/app/components/nav/component/branch-like/CurrentBranchLike.tsx
  8. 1
    2
      server/sonar-web/src/main/js/app/components/nav/component/branch-like/__tests__/__snapshots__/CurrentBranchLike-test.tsx.snap
  9. 20
    16
      server/sonar-web/src/main/js/app/components/nav/global/GlobalNavPlus.tsx
  10. 21
    8
      server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavPlus-test.tsx
  11. 1
    1
      server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavPlus-test.tsx.snap
  12. 2
    0
      server/sonar-web/src/main/js/app/utils/startReactApp.tsx
  13. 120
    0
      server/sonar-web/src/main/js/apps/application-console/ApplicationBranches.tsx
  14. 180
    0
      server/sonar-web/src/main/js/apps/application-console/ApplicationDetails.tsx
  15. 221
    0
      server/sonar-web/src/main/js/apps/application-console/ApplicationDetailsProjects.tsx
  16. 58
    0
      server/sonar-web/src/main/js/apps/application-console/ApplicationProjectBranch.tsx
  17. 169
    0
      server/sonar-web/src/main/js/apps/application-console/ApplicationView.tsx
  18. 111
    0
      server/sonar-web/src/main/js/apps/application-console/BranchRowActions.tsx
  19. 76
    0
      server/sonar-web/src/main/js/apps/application-console/BranchSelectItem.tsx
  20. 51
    0
      server/sonar-web/src/main/js/apps/application-console/ConsoleApplicationApp.tsx
  21. 294
    0
      server/sonar-web/src/main/js/apps/application-console/CreateBranchForm.tsx
  22. 123
    0
      server/sonar-web/src/main/js/apps/application-console/EditForm.tsx
  23. 140
    0
      server/sonar-web/src/main/js/apps/application-console/ProjectBranchRow.tsx
  24. 60
    0
      server/sonar-web/src/main/js/apps/application-console/__tests__/ApplicationBranches-test.tsx
  25. 91
    0
      server/sonar-web/src/main/js/apps/application-console/__tests__/ApplicationDetails-test.tsx
  26. 98
    0
      server/sonar-web/src/main/js/apps/application-console/__tests__/ApplicationDetailsProjects-test.tsx
  27. 43
    0
      server/sonar-web/src/main/js/apps/application-console/__tests__/ApplicationProjectBranch-test.tsx
  28. 110
    0
      server/sonar-web/src/main/js/apps/application-console/__tests__/ApplicationView-test.tsx
  29. 39
    0
      server/sonar-web/src/main/js/apps/application-console/__tests__/BranchRowActions-test.tsx
  30. 37
    0
      server/sonar-web/src/main/js/apps/application-console/__tests__/BranchSelectItem-test.tsx
  31. 38
    0
      server/sonar-web/src/main/js/apps/application-console/__tests__/ConsoleApplicationApp-test.tsx
  32. 132
    0
      server/sonar-web/src/main/js/apps/application-console/__tests__/CreateBranchForm-test.tsx
  33. 66
    0
      server/sonar-web/src/main/js/apps/application-console/__tests__/EditForm-test.tsx
  34. 41
    0
      server/sonar-web/src/main/js/apps/application-console/__tests__/ProjectBranchRow-test.tsx
  35. 176
    0
      server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/ApplicationBranches-test.tsx.snap
  36. 284
    0
      server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/ApplicationDetails-test.tsx.snap
  37. 72
    0
      server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/ApplicationDetailsProjects-test.tsx.snap
  38. 69
    0
      server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/ApplicationProjectBranch-test.tsx.snap
  39. 7
    0
      server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/ApplicationView-test.tsx.snap
  40. 15
    0
      server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/BranchRowActions-test.tsx.snap
  41. 22
    0
      server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/BranchSelectItem-test.tsx.snap
  42. 32
    0
      server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/ConsoleApplicationApp-test.tsx.snap
  43. 12
    0
      server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/CreateBranchForm-test.tsx.snap
  44. 77
    0
      server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/EditForm-test.tsx.snap
  45. 59
    0
      server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/ProjectBranchRow-test.tsx.snap
  46. 28
    0
      server/sonar-web/src/main/js/apps/application-console/routes.ts
  47. 29
    0
      server/sonar-web/src/main/js/apps/application-console/utils.ts
  48. 8
    5
      server/sonar-web/src/main/js/apps/permissions/global/components/AllHoldersList.tsx
  49. 94
    0
      server/sonar-web/src/main/js/apps/permissions/global/components/__tests__/AllHoldersList-test.tsx
  50. 436
    0
      server/sonar-web/src/main/js/apps/permissions/global/components/__tests__/__snapshots__/AllHoldersList-test.tsx.snap
  51. 7
    3
      server/sonar-web/src/main/js/apps/permissions/project/components/PageHeader.tsx
  52. 22
    7
      server/sonar-web/src/main/js/apps/permissions/utils.ts
  53. 10
    4
      server/sonar-web/src/main/js/apps/projectDeletion/Form.tsx
  54. 18
    0
      server/sonar-web/src/main/js/apps/projectDeletion/__tests__/Form-test.tsx
  55. 26
    1
      server/sonar-web/src/main/js/helpers/mocks/application.ts
  56. 38
    0
      server/sonar-web/src/main/js/helpers/mocks/permissions.ts
  57. 23
    3
      server/sonar-web/src/main/js/helpers/urls.ts
  58. 9
    1
      server/sonar-web/src/main/js/types/component.ts
  59. 23
    1
      sonar-core/src/main/resources/org/sonar/l10n/core.properties

+ 76
- 2
server/sonar-web/src/main/js/api/application.ts View File

@@ -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
);
}

+ 185
- 0
server/sonar-web/src/main/js/app/components/extensions/CreateApplicationForm.tsx View File

@@ -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>
);
}
}

+ 80
- 0
server/sonar-web/src/main/js/app/components/extensions/__tests__/CreateApplicationForm-test.tsx View File

@@ -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} />
);
}

+ 150
- 0
server/sonar-web/src/main/js/app/components/extensions/__tests__/__snapshots__/CreateApplicationForm-test.tsx.snap View File

@@ -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>
`;

+ 20
- 3
server/sonar-web/src/main/js/app/components/nav/component/Menu.tsx View File

@@ -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) => {

+ 29
- 12
server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/Menu-test.tsx.snap View File

@@ -1088,6 +1088,23 @@ exports[`should work for all qualifiers 4`] = `
<ul
className="menu"
>
<li>
<Link
activeClassName="active"
onlyActiveOnIndex={false}
style={Object {}}
to={
Object {
"pathname": "/application/console",
"query": Object {
"id": "foo",
},
}
}
>
application_console.page
</Link>
</li>
<li>
<Link
activeClassName="active"
@@ -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>

+ 2
- 2
server/sonar-web/src/main/js/app/components/nav/component/branch-like/CurrentBranchLike.tsx View File

@@ -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>
</>

+ 1
- 2
server/sonar-web/src/main/js/app/components/nav/component/branch-like/__tests__/__snapshots__/CurrentBranchLike-test.tsx.snap View File

@@ -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",
},
}
}

+ 20
- 16
server/sonar-web/src/main/js/app/components/nav/global/GlobalNavPlus.tsx View File

@@ -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}

+ 21
- 8
server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavPlus-test.tsx View File

@@ -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()}

+ 1
- 1
server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavPlus-test.tsx.snap View File

@@ -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 []}

+ 2
- 0
server/sonar-web/src/main/js/app/utils/startReactApp.tsx View File

@@ -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"

+ 120
- 0
server/sonar-web/src/main/js/apps/application-console/ApplicationBranches.tsx View File

@@ -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>
);
}
}

+ 180
- 0
server/sonar-web/src/main/js/apps/application-console/ApplicationDetails.tsx View File

@@ -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>
);
}
}

+ 221
- 0
server/sonar-web/src/main/js/apps/application-console/ApplicationDetailsProjects.tsx View File

@@ -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}
/>
);
}
}

+ 58
- 0
server/sonar-web/src/main/js/apps/application-console/ApplicationProjectBranch.tsx View File

@@ -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>
);
}

+ 169
- 0
server/sonar-web/src/main/js/apps/application-console/ApplicationView.tsx View File

@@ -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}
/>
);
}
}

+ 111
- 0
server/sonar-web/src/main/js/apps/application-console/BranchRowActions.tsx View File

@@ -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}
/>
)}
</>
);
}
}

+ 76
- 0
server/sonar-web/src/main/js/apps/application-console/BranchSelectItem.tsx View File

@@ -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>
);
}
}

+ 51
- 0
server/sonar-web/src/main/js/apps/application-console/ConsoleApplicationApp.tsx View File

@@ -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>
);
}
}

+ 294
- 0
server/sonar-web/src/main/js/apps/application-console/CreateBranchForm.tsx View File

@@ -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>
);
}
}

+ 123
- 0
server/sonar-web/src/main/js/apps/application-console/EditForm.tsx View File

@@ -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>
);
}
}

+ 140
- 0
server/sonar-web/src/main/js/apps/application-console/ProjectBranchRow.tsx View File

@@ -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>
);
}
}

+ 60
- 0
server/sonar-web/src/main/js/apps/application-console/__tests__/ApplicationBranches-test.tsx View File

@@ -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} />
);
}

+ 91
- 0
server/sonar-web/src/main/js/apps/application-console/__tests__/ApplicationDetails-test.tsx View File

@@ -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}
/>
);
}

+ 98
- 0
server/sonar-web/src/main/js/apps/application-console/__tests__/ApplicationDetailsProjects-test.tsx View File

@@ -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} />
);
}

+ 43
- 0
server/sonar-web/src/main/js/apps/application-console/__tests__/ApplicationProjectBranch-test.tsx View File

@@ -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}
/>
);
}

+ 110
- 0
server/sonar-web/src/main/js/apps/application-console/__tests__/ApplicationView-test.tsx View File

@@ -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}
/>
);
}

+ 39
- 0
server/sonar-web/src/main/js/apps/application-console/__tests__/BranchRowActions-test.tsx View File

@@ -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}
/>
);
}

+ 37
- 0
server/sonar-web/src/main/js/apps/application-console/__tests__/BranchSelectItem-test.tsx View File

@@ -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}
/>
);
}

+ 38
- 0
server/sonar-web/src/main/js/apps/application-console/__tests__/ConsoleApplicationApp-test.tsx View File

@@ -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}
/>
);
}

+ 132
- 0
server/sonar-web/src/main/js/apps/application-console/__tests__/CreateBranchForm-test.tsx View File

@@ -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}
/>
);
}

+ 66
- 0
server/sonar-web/src/main/js/apps/application-console/__tests__/EditForm-test.tsx View File

@@ -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}
/>
);
}

+ 41
- 0
server/sonar-web/src/main/js/apps/application-console/__tests__/ProjectBranchRow-test.tsx View File

@@ -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}
/>
);
}

+ 176
- 0
server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/ApplicationBranches-test.tsx.snap View File

@@ -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>
`;

+ 284
- 0
server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/ApplicationDetails-test.tsx.snap View File

@@ -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>
`;

+ 72
- 0
server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/ApplicationDetailsProjects-test.tsx.snap View File

@@ -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>
`;

+ 69
- 0
server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/ApplicationProjectBranch-test.tsx.snap View File

@@ -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>
`;

+ 7
- 0
server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/ApplicationView-test.tsx.snap View File

@@ -0,0 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Should render correctly 1`] = `
<i
className="spinner spacer"
/>
`;

+ 15
- 0
server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/BranchRowActions-test.tsx.snap View File

@@ -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>
`;

+ 22
- 0
server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/BranchSelectItem-test.tsx.snap View File

@@ -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>
`;

+ 32
- 0
server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/ConsoleApplicationApp-test.tsx.snap View File

@@ -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>
`;

+ 12
- 0
server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/CreateBranchForm-test.tsx.snap View File

@@ -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>
`;

+ 77
- 0
server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/EditForm-test.tsx.snap View File

@@ -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>
`;

+ 59
- 0
server/sonar-web/src/main/js/apps/application-console/__tests__/__snapshots__/ProjectBranchRow-test.tsx.snap View File

@@ -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>
`;

+ 28
- 0
server/sonar-web/src/main/js/apps/application-console/routes.ts View File

@@ -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;

+ 29
- 0
server/sonar-web/src/main/js/apps/application-console/utils.ts View File

@@ -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'>;

+ 8
- 5
server/sonar-web/src/main/js/apps/permissions/global/components/AllHoldersList.tsx View File

@@ -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
);


+ 94
- 0
server/sonar-web/src/main/js/apps/permissions/global/components/__tests__/AllHoldersList-test.tsx View File

@@ -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}
/>
);
}

+ 436
- 0
server/sonar-web/src/main/js/apps/permissions/global/components/__tests__/__snapshots__/AllHoldersList-test.tsx.snap View File

@@ -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>
`;

+ 7
- 3
server/sonar-web/src/main/js/apps/permissions/project/components/PageHeader.tsx View File

@@ -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

+ 22
- 7
server/sonar-web/src/main/js/apps/permissions/utils.ts View File

@@ -30,13 +30,6 @@ export const PERMISSIONS_ORDER_FOR_PROJECT_TEMPLATE = [
];

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',
@@ -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

+ 10
- 4
server/sonar-web/src/main/js/apps/projectDeletion/Form.tsx View File

@@ -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);


+ 18
- 0
server/sonar-web/src/main/js/apps/projectDeletion/__tests__/Form-test.tsx View File

@@ -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('/');
});

+ 26
- 1
server/sonar-web/src/main/js/helpers/mocks/application.ts View File

@@ -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
};
}

+ 38
- 0
server/sonar-web/src/main/js/helpers/mocks/permissions.ts View File

@@ -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
};
}

+ 23
- 3
server/sonar-web/src/main/js/helpers/urls.ts View File

@@ -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 {

+ 9
- 1
server/sonar-web/src/main/js/types/component.ts View File

@@ -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;
}

+ 23
- 1
sonar-core/src/main/resources/org/sonar/l10n/core.properties View File

@@ -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.

Loading…
Cancel
Save