diff options
author | Jeremy Davis <jeremy.davis@sonarsource.com> | 2021-08-10 17:50:25 +0200 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2021-08-12 20:07:59 +0000 |
commit | 94ff5648e0af09146d3db4c9a2fdba1852911022 (patch) | |
tree | 3c93d4fd3143c8ed4f0c6e0d49baac8b2c71bc43 /server/sonar-web/src/main | |
parent | 5589d0e0da0168d6d4af8ac4611f2dfc63c1687f (diff) | |
download | sonarqube-94ff5648e0af09146d3db4c9a2fdba1852911022.tar.gz sonarqube-94ff5648e0af09146d3db4c9a2fdba1852911022.zip |
SONAR-15258 Add App Report Settings
Diffstat (limited to 'server/sonar-web/src/main')
11 files changed, 603 insertions, 2 deletions
diff --git a/server/sonar-web/src/main/js/app/components/nav/component/Menu.tsx b/server/sonar-web/src/main/js/app/components/nav/component/Menu.tsx index c56114349b5..eee1d068e57 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/Menu.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/component/Menu.tsx @@ -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; diff --git a/server/sonar-web/src/main/js/app/utils/startReactApp.tsx b/server/sonar-web/src/main/js/app/utils/startReactApp.tsx index f3d5ed8709b..9f1187c0720 100644 --- a/server/sonar-web/src/main/js/app/utils/startReactApp.tsx +++ b/server/sonar-web/src/main/js/app/utils/startReactApp.tsx @@ -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" diff --git a/server/sonar-web/src/main/js/apps/application-settings/ApplicationSettingsApp.tsx b/server/sonar-web/src/main/js/apps/application-settings/ApplicationSettingsApp.tsx new file mode 100644 index 00000000000..1bef269e394 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/application-settings/ApplicationSettingsApp.tsx @@ -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> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/application-settings/ReportFrequencyForm.tsx b/server/sonar-web/src/main/js/apps/application-settings/ReportFrequencyForm.tsx new file mode 100644 index 00000000000..2d8790133a9 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/application-settings/ReportFrequencyForm.tsx @@ -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> + ); +} diff --git a/server/sonar-web/src/main/js/apps/application-settings/__tests__/ApplicationSettingsApp-test.tsx b/server/sonar-web/src/main/js/apps/application-settings/__tests__/ApplicationSettingsApp-test.tsx new file mode 100644 index 00000000000..a2e8c06391c --- /dev/null +++ b/server/sonar-web/src/main/js/apps/application-settings/__tests__/ApplicationSettingsApp-test.tsx @@ -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} /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/application-settings/__tests__/ReportFrequencyForm-test.tsx b/server/sonar-web/src/main/js/apps/application-settings/__tests__/ReportFrequencyForm-test.tsx new file mode 100644 index 00000000000..b7a8b0cb9d5 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/application-settings/__tests__/ReportFrequencyForm-test.tsx @@ -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} + /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/application-settings/__tests__/__snapshots__/ApplicationSettingsApp-test.tsx.snap b/server/sonar-web/src/main/js/apps/application-settings/__tests__/__snapshots__/ApplicationSettingsApp-test.tsx.snap new file mode 100644 index 00000000000..60191d42174 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/application-settings/__tests__/__snapshots__/ApplicationSettingsApp-test.tsx.snap @@ -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> +`; diff --git a/server/sonar-web/src/main/js/apps/application-settings/__tests__/__snapshots__/ReportFrequencyForm-test.tsx.snap b/server/sonar-web/src/main/js/apps/application-settings/__tests__/__snapshots__/ReportFrequencyForm-test.tsx.snap new file mode 100644 index 00000000000..dc8322658c5 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/application-settings/__tests__/__snapshots__/ReportFrequencyForm-test.tsx.snap @@ -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> +`; diff --git a/server/sonar-web/src/main/js/apps/application-settings/routes.ts b/server/sonar-web/src/main/js/apps/application-settings/routes.ts new file mode 100644 index 00000000000..18822c1779c --- /dev/null +++ b/server/sonar-web/src/main/js/apps/application-settings/routes.ts @@ -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; diff --git a/server/sonar-web/src/main/js/helpers/mocks/settings.ts b/server/sonar-web/src/main/js/helpers/mocks/settings.ts index 59fe8ecb25c..14065d55238 100644 --- a/server/sonar-web/src/main/js/helpers/mocks/settings.ts +++ b/server/sonar-web/src/main/js/helpers/mocks/settings.ts @@ -17,7 +17,20 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { 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 { diff --git a/server/sonar-web/src/main/js/types/settings.ts b/server/sonar-web/src/main/js/types/settings.ts index 6432a176068..b272b5b9943 100644 --- a/server/sonar-web/src/main/js/types/settings.ts +++ b/server/sonar-web/src/main/js/types/settings.ts @@ -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 }; |