@@ -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 | |||
); | |||
} |
@@ -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> | |||
); | |||
} | |||
} |
@@ -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} /> | |||
); | |||
} |
@@ -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> | |||
`; |
@@ -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) => { |
@@ -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> |
@@ -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> | |||
</> |
@@ -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", | |||
}, | |||
} | |||
} |
@@ -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} |
@@ -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()} |
@@ -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 []} |
@@ -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" |
@@ -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> | |||
); | |||
} | |||
} |
@@ -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> | |||
); | |||
} | |||
} |
@@ -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} | |||
/> | |||
); | |||
} | |||
} |
@@ -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> | |||
); | |||
} |
@@ -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} | |||
/> | |||
); | |||
} | |||
} |
@@ -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} | |||
/> | |||
)} | |||
</> | |||
); | |||
} | |||
} |
@@ -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> | |||
); | |||
} | |||
} |
@@ -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> | |||
); | |||
} | |||
} |
@@ -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> | |||
); | |||
} | |||
} |
@@ -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> | |||
); | |||
} | |||
} |
@@ -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> | |||
); | |||
} | |||
} |
@@ -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} /> | |||
); | |||
} |
@@ -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} | |||
/> | |||
); | |||
} |
@@ -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} /> | |||
); | |||
} |
@@ -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} | |||
/> | |||
); | |||
} |
@@ -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} | |||
/> | |||
); | |||
} |
@@ -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} | |||
/> | |||
); | |||
} |
@@ -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} | |||
/> | |||
); | |||
} |
@@ -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} | |||
/> | |||
); | |||
} |
@@ -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} | |||
/> | |||
); | |||
} |
@@ -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} | |||
/> | |||
); | |||
} |
@@ -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} | |||
/> | |||
); | |||
} |
@@ -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> | |||
`; |
@@ -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> | |||
`; |
@@ -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> | |||
`; |
@@ -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> | |||
`; |
@@ -0,0 +1,7 @@ | |||
// Jest Snapshot v1, https://goo.gl/fbAQLP | |||
exports[`Should render correctly 1`] = ` | |||
<i | |||
className="spinner spacer" | |||
/> | |||
`; |
@@ -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> | |||
`; |
@@ -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> | |||
`; |
@@ -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> | |||
`; |
@@ -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> | |||
`; |
@@ -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> | |||
`; |
@@ -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> | |||
`; |
@@ -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; |
@@ -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'>; |
@@ -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 | |||
); | |||
@@ -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} | |||
/> | |||
); | |||
} |
@@ -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> | |||
`; |
@@ -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 |
@@ -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 |
@@ -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); | |||
@@ -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('/'); | |||
}); |
@@ -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 | |||
}; | |||
} |
@@ -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 | |||
}; | |||
} |
@@ -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 { |
@@ -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; | |||
} |
@@ -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. |