@@ -291,6 +291,7 @@ export class Menu extends React.PureComponent<Props> { | |||
this.renderBranchesLink(query, isProject), | |||
this.renderBaselineLink(query, isApplication, isPortfolio), | |||
this.renderConsoleAppLink(query, isApplication), | |||
this.renderReportSettingsLink(query, isApplication), | |||
...this.renderAdminExtensions(query, isApplication), | |||
this.renderProfilesLink(query), | |||
this.renderQualityGateLink(query), | |||
@@ -385,6 +386,23 @@ export class Menu extends React.PureComponent<Props> { | |||
); | |||
}; | |||
renderReportSettingsLink = (query: Query, isApplication: boolean) => { | |||
const extensions = this.getConfiguration().extensions || []; | |||
const hasGovernance = extensions.find(e => e.key === 'governance/console'); | |||
if (!isApplication || !hasGovernance) { | |||
return null; | |||
} | |||
return ( | |||
<li key="report-settings"> | |||
<Link activeClassName="active" to={{ pathname: '/application/settings', query }}> | |||
{translate('application_settings.report')} | |||
</Link> | |||
</li> | |||
); | |||
}; | |||
renderProfilesLink = (query: Query) => { | |||
if (!this.getConfiguration().showQualityProfiles) { | |||
return null; |
@@ -32,6 +32,7 @@ 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 applicationSettingsRoutes from '../../apps/application-settings/routes'; | |||
import auditLogsRoutes from '../../apps/audit-logs/routes'; | |||
import backgroundTasksRoutes from '../../apps/background-tasks/routes'; | |||
import codeRoutes from '../../apps/code/routes'; | |||
@@ -203,6 +204,7 @@ function renderComponentRoutes() { | |||
<RouteWithChildRoutes path="project/settings" childRoutes={settingsRoutes} /> | |||
<RouteWithChildRoutes path="project_roles" childRoutes={projectPermissionsRoutes} /> | |||
<RouteWithChildRoutes path="application/console" childRoutes={applicationConsoleRoutes} /> | |||
<RouteWithChildRoutes path="application/settings" childRoutes={applicationSettingsRoutes} /> | |||
<RouteWithChildRoutes path="project/webhooks" childRoutes={webhooksRoutes} /> | |||
<Route | |||
path="project/deletion" |
@@ -0,0 +1,136 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2021 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 DeferredSpinner from 'sonar-ui-common/components/ui/DeferredSpinner'; | |||
import { translate } from 'sonar-ui-common/helpers/l10n'; | |||
import { getDefinitions, getValues, setSimpleSettingValue } from '../../api/settings'; | |||
import { SettingCategoryDefinition, SettingsKey } from '../../types/settings'; | |||
import ReportFrequencyForm from './ReportFrequencyForm'; | |||
interface Props { | |||
component: T.Component; | |||
} | |||
interface State { | |||
definition?: SettingCategoryDefinition; | |||
frequency?: string; | |||
loading: boolean; | |||
} | |||
export default class ApplicationSettingsApp extends React.PureComponent<Props, State> { | |||
mounted = false; | |||
state: State = { | |||
loading: true | |||
}; | |||
componentDidMount() { | |||
this.mounted = true; | |||
this.fetchInitialData(); | |||
} | |||
async componentDidUpdate(prevProps: Props) { | |||
if (prevProps.component.key !== this.props.component.key) { | |||
this.setState({ loading: true }); | |||
await this.fetchSetting(); | |||
this.setState({ loading: false }); | |||
} | |||
} | |||
componentWillUnmount() { | |||
this.mounted = false; | |||
} | |||
fetchInitialData = async () => { | |||
this.setState({ loading: true }); | |||
await Promise.all([this.fetchDefinition(), this.fetchSetting()]); | |||
this.setState({ loading: false }); | |||
}; | |||
fetchDefinition = async () => { | |||
const { component } = this.props; | |||
try { | |||
const definitions = await getDefinitions(component.key); | |||
const definition = definitions.find(d => d.key === SettingsKey.ProjectReportFrequency); | |||
if (this.mounted) { | |||
this.setState({ definition }); | |||
} | |||
} catch (_) { | |||
/* do nothing */ | |||
} | |||
}; | |||
fetchSetting = async () => { | |||
const { component } = this.props; | |||
try { | |||
const setting = ( | |||
await getValues({ component: component.key, keys: SettingsKey.ProjectReportFrequency }) | |||
).pop(); | |||
if (this.mounted) { | |||
this.setState({ | |||
frequency: setting ? setting.value : undefined | |||
}); | |||
} | |||
} catch (_) { | |||
/* do nothing */ | |||
} | |||
}; | |||
handleSubmit = async (frequency: string) => { | |||
const { component } = this.props; | |||
try { | |||
await setSimpleSettingValue({ | |||
component: component.key, | |||
key: SettingsKey.ProjectReportFrequency, | |||
value: frequency | |||
}); | |||
this.setState({ frequency }); | |||
} catch (_) { | |||
/* Do nothing */ | |||
} | |||
}; | |||
render() { | |||
const { definition, frequency, loading } = this.state; | |||
return ( | |||
<div className="page page-limited application-settings"> | |||
<h1>{translate('application_settings.page')}</h1> | |||
<div className="boxed-group big-padded big-spacer-top"> | |||
<DeferredSpinner loading={loading}> | |||
<div> | |||
{definition && frequency && ( | |||
<ReportFrequencyForm | |||
definition={definition} | |||
frequency={frequency} | |||
onSave={this.handleSubmit} | |||
/> | |||
)} | |||
</div> | |||
</DeferredSpinner> | |||
</div> | |||
</div> | |||
); | |||
} | |||
} |
@@ -0,0 +1,96 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2021 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, ResetButtonLink } from 'sonar-ui-common/components/controls/buttons'; | |||
import Select from 'sonar-ui-common/components/controls/Select'; | |||
import { translate } from 'sonar-ui-common/helpers/l10n'; | |||
import { sanitizeStringRestricted } from '../../helpers/sanitize'; | |||
import { SettingCategoryDefinition } from '../../types/settings'; | |||
export interface ReportFrequencyFormProps { | |||
definition: SettingCategoryDefinition; | |||
frequency: string; | |||
onSave: (value: string) => Promise<void>; | |||
} | |||
export default function ReportFrequencyForm(props: ReportFrequencyFormProps) { | |||
const { definition, frequency } = props; | |||
const { defaultValue } = definition; | |||
const [currentSelection, setCurrentSelection] = React.useState(frequency); | |||
const options = props.definition.options.map(option => ({ | |||
label: option, | |||
value: option | |||
})); | |||
const handleReset = () => { | |||
if (defaultValue) { | |||
setCurrentSelection(defaultValue); | |||
props.onSave(defaultValue); | |||
} | |||
}; | |||
return ( | |||
<div> | |||
<h2>{translate('application_settings.report.frequency')}</h2> | |||
{definition.description && ( | |||
<div | |||
className="markdown" | |||
// eslint-disable-next-line react/no-danger | |||
dangerouslySetInnerHTML={{ | |||
__html: sanitizeStringRestricted(definition.description) | |||
}} | |||
/> | |||
)} | |||
<Select | |||
className="input-medium" | |||
clearable={false} | |||
name={definition.name} | |||
onChange={({ value }: { value: string }) => setCurrentSelection(value)} | |||
options={options} | |||
value={currentSelection} | |||
/> | |||
<div className="display-flex-center big-spacer-top"> | |||
{frequency !== currentSelection && ( | |||
<Button | |||
className="spacer-right button-success" | |||
onClick={() => props.onSave(currentSelection)}> | |||
{translate('save')} | |||
</Button> | |||
)} | |||
{defaultValue !== undefined && frequency !== defaultValue && ( | |||
<Button className="spacer-right" onClick={handleReset}> | |||
{translate('reset_verb')} | |||
</Button> | |||
)} | |||
{frequency !== currentSelection && ( | |||
<ResetButtonLink className="spacer-right" onClick={() => setCurrentSelection(frequency)}> | |||
{translate('cancel')} | |||
</ResetButtonLink> | |||
)} | |||
</div> | |||
</div> | |||
); | |||
} |
@@ -0,0 +1,70 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2021 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 { setSimpleSettingValue } from '../../../api/settings'; | |||
import { mockComponent } from '../../../helpers/testMocks'; | |||
import { SettingsKey } from '../../../types/settings'; | |||
import ApplicationSettingsApp from '../ApplicationSettingsApp'; | |||
jest.mock('../../../api/settings', () => { | |||
const { mockDefinition } = jest.requireActual('../../../helpers/mocks/settings'); | |||
const definition = mockDefinition({ | |||
key: 'sonar.governance.report.project.branch.frequency', // SettingsKey.ProjectReportFrequency | |||
defaultValue: 'Monthly', | |||
description: 'description', | |||
options: ['Daily', 'Weekly', 'Monthly'] | |||
}); | |||
return { | |||
getDefinitions: jest.fn().mockResolvedValue([definition]), | |||
getValues: jest.fn().mockResolvedValue([{ value: 'Monthly' }]), | |||
setSimpleSettingValue: jest.fn().mockResolvedValue(undefined) | |||
}; | |||
}); | |||
it('should render correctly', async () => { | |||
const wrapper = shallowRender(); | |||
expect(wrapper).toMatchSnapshot('loading'); | |||
await waitAndUpdate(wrapper); | |||
expect(wrapper).toMatchSnapshot('default'); | |||
}); | |||
it('should handle submission', async () => { | |||
const component = mockComponent({ key: 'app-key' }); | |||
const wrapper = shallowRender({ component }); | |||
await waitAndUpdate(wrapper); | |||
wrapper.instance().handleSubmit('Daily'); | |||
expect(setSimpleSettingValue).toBeCalledWith({ | |||
component: component.key, | |||
key: SettingsKey.ProjectReportFrequency, | |||
value: 'Daily' | |||
}); | |||
}); | |||
function shallowRender(props: Partial<ApplicationSettingsApp['props']> = {}) { | |||
return shallow<ApplicationSettingsApp>( | |||
<ApplicationSettingsApp component={mockComponent()} {...props} /> | |||
); | |||
} |
@@ -0,0 +1,70 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2021 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 { Button, ResetButtonLink } from 'sonar-ui-common/components/controls/buttons'; | |||
import Select from 'sonar-ui-common/components/controls/Select'; | |||
import { mockDefinition } from '../../../helpers/mocks/settings'; | |||
import ReportFrequencyForm, { ReportFrequencyFormProps } from '../ReportFrequencyForm'; | |||
it('should render correctly', () => { | |||
expect(shallowRender()).toMatchSnapshot('default'); | |||
expect(shallowRender({ frequency: 'Weekly' })).toMatchSnapshot('changed'); | |||
expect(shallowRender({ definition: mockDefinition() })).toMatchSnapshot('no description'); | |||
}); | |||
it('should handle changes', () => { | |||
const onSave = jest.fn(); | |||
const wrapper = shallowRender({ onSave }); | |||
wrapper.find(Select).simulate('change', { value: 'Daily' }); | |||
expect(wrapper.find('.button-success').exists()).toBe(true); | |||
expect(wrapper.find(ResetButtonLink).exists()).toBe(true); | |||
wrapper.find(Button).simulate('click'); | |||
expect(onSave).toBeCalledWith('Daily'); | |||
}); | |||
it('should handle reset', () => { | |||
const onSave = jest.fn(); | |||
const wrapper = shallowRender({ frequency: 'Weekly', onSave }); | |||
expect(wrapper.find('.button-success').exists()).toBe(false); | |||
wrapper.find(Button).simulate('click'); | |||
expect(onSave).toBeCalledWith('Monthly'); | |||
}); | |||
function shallowRender(props: Partial<ReportFrequencyFormProps> = {}) { | |||
return shallow<ReportFrequencyFormProps>( | |||
<ReportFrequencyForm | |||
definition={mockDefinition({ | |||
defaultValue: 'Monthly', | |||
description: 'description', | |||
options: ['Daily', 'Weekly', 'Monthly'] | |||
})} | |||
frequency="Monthly" | |||
onSave={jest.fn()} | |||
{...props} | |||
/> | |||
); | |||
} |
@@ -0,0 +1,59 @@ | |||
// Jest Snapshot v1, https://goo.gl/fbAQLP | |||
exports[`should render correctly: default 1`] = ` | |||
<div | |||
className="page page-limited application-settings" | |||
> | |||
<h1> | |||
application_settings.page | |||
</h1> | |||
<div | |||
className="boxed-group big-padded big-spacer-top" | |||
> | |||
<DeferredSpinner | |||
loading={false} | |||
> | |||
<div> | |||
<ReportFrequencyForm | |||
definition={ | |||
Object { | |||
"category": "foo category", | |||
"defaultValue": "Monthly", | |||
"description": "description", | |||
"fields": Array [], | |||
"key": "sonar.governance.report.project.branch.frequency", | |||
"options": Array [ | |||
"Daily", | |||
"Weekly", | |||
"Monthly", | |||
], | |||
"subCategory": "foo subCat", | |||
} | |||
} | |||
frequency="Monthly" | |||
onSave={[Function]} | |||
/> | |||
</div> | |||
</DeferredSpinner> | |||
</div> | |||
</div> | |||
`; | |||
exports[`should render correctly: loading 1`] = ` | |||
<div | |||
className="page page-limited application-settings" | |||
> | |||
<h1> | |||
application_settings.page | |||
</h1> | |||
<div | |||
className="boxed-group big-padded big-spacer-top" | |||
> | |||
<DeferredSpinner | |||
loading={true} | |||
> | |||
<div /> | |||
</DeferredSpinner> | |||
</div> | |||
</div> | |||
`; |
@@ -0,0 +1,108 @@ | |||
// Jest Snapshot v1, https://goo.gl/fbAQLP | |||
exports[`should render correctly: changed 1`] = ` | |||
<div> | |||
<h2> | |||
application_settings.report.frequency | |||
</h2> | |||
<div | |||
className="markdown" | |||
dangerouslySetInnerHTML={ | |||
Object { | |||
"__html": "description", | |||
} | |||
} | |||
/> | |||
<Select | |||
className="input-medium" | |||
clearable={false} | |||
onChange={[Function]} | |||
options={ | |||
Array [ | |||
Object { | |||
"label": "Daily", | |||
"value": "Daily", | |||
}, | |||
Object { | |||
"label": "Weekly", | |||
"value": "Weekly", | |||
}, | |||
Object { | |||
"label": "Monthly", | |||
"value": "Monthly", | |||
}, | |||
] | |||
} | |||
value="Weekly" | |||
/> | |||
<div | |||
className="display-flex-center big-spacer-top" | |||
> | |||
<Button | |||
className="spacer-right" | |||
onClick={[Function]} | |||
> | |||
reset_verb | |||
</Button> | |||
</div> | |||
</div> | |||
`; | |||
exports[`should render correctly: default 1`] = ` | |||
<div> | |||
<h2> | |||
application_settings.report.frequency | |||
</h2> | |||
<div | |||
className="markdown" | |||
dangerouslySetInnerHTML={ | |||
Object { | |||
"__html": "description", | |||
} | |||
} | |||
/> | |||
<Select | |||
className="input-medium" | |||
clearable={false} | |||
onChange={[Function]} | |||
options={ | |||
Array [ | |||
Object { | |||
"label": "Daily", | |||
"value": "Daily", | |||
}, | |||
Object { | |||
"label": "Weekly", | |||
"value": "Weekly", | |||
}, | |||
Object { | |||
"label": "Monthly", | |||
"value": "Monthly", | |||
}, | |||
] | |||
} | |||
value="Monthly" | |||
/> | |||
<div | |||
className="display-flex-center big-spacer-top" | |||
/> | |||
</div> | |||
`; | |||
exports[`should render correctly: no description 1`] = ` | |||
<div> | |||
<h2> | |||
application_settings.report.frequency | |||
</h2> | |||
<Select | |||
className="input-medium" | |||
clearable={false} | |||
onChange={[Function]} | |||
options={Array []} | |||
value="Monthly" | |||
/> | |||
<div | |||
className="display-flex-center big-spacer-top" | |||
/> | |||
</div> | |||
`; |
@@ -0,0 +1,28 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2021 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('./ApplicationSettingsApp')) } | |||
} | |||
]; | |||
export default routes; |
@@ -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 { Setting, SettingWithCategory } from '../../types/settings'; | |||
import { Setting, SettingCategoryDefinition, SettingWithCategory } from '../../types/settings'; | |||
export function mockDefinition( | |||
overrides: Partial<SettingCategoryDefinition> = {} | |||
): SettingCategoryDefinition { | |||
return { | |||
key: 'foo', | |||
category: 'foo category', | |||
fields: [], | |||
options: [], | |||
subCategory: 'foo subCat', | |||
...overrides | |||
}; | |||
} | |||
export function mockSetting(overrides: Partial<Setting> = {}): Setting { | |||
return { |
@@ -21,7 +21,8 @@ export const enum SettingsKey { | |||
DaysBeforeDeletingInactiveBranchesAndPRs = 'sonar.dbcleaner.daysBeforeDeletingInactiveBranchesAndPRs', | |||
DefaultProjectVisibility = 'projects.default.visibility', | |||
ServerBaseUrl = 'sonar.core.serverBaseURL', | |||
PluginRiskConsent = 'sonar.plugins.risk.consent' | |||
PluginRiskConsent = 'sonar.plugins.risk.consent', | |||
ProjectReportFrequency = 'sonar.governance.report.project.branch.frequency' | |||
} | |||
export type Setting = SettingValue & { definition: SettingDefinition }; |
@@ -562,6 +562,9 @@ 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}"? | |||
application_settings.page=Application Settings | |||
application_settings.report=Application Report Settings | |||
application_settings.report.frequency=Application Reports Frequency | |||
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. |