From: stanislavh Date: Mon, 13 Feb 2023 10:44:11 +0000 (+0100) Subject: SONAR-18128 SONAR-18358 SONAR-18368 Page titles do not identify purpose of pages X-Git-Tag: 10.0.0.68432~230 X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=982ba2999674768245cc20150fc37c6259204b39;p=sonarqube.git SONAR-18128 SONAR-18358 SONAR-18368 Page titles do not identify purpose of pages --- diff --git a/server/sonar-web/src/main/js/app/components/AdminContainer.tsx b/server/sonar-web/src/main/js/app/components/AdminContainer.tsx index bcc5dc09267..82ab8d97641 100644 --- a/server/sonar-web/src/main/js/app/components/AdminContainer.tsx +++ b/server/sonar-web/src/main/js/app/components/AdminContainer.tsx @@ -24,7 +24,7 @@ import { getSettingsNavigation } from '../../api/navigation'; import { getPendingPlugins } from '../../api/plugins'; import { getSystemStatus, waitSystemUPStatus } from '../../api/system'; import handleRequiredAuthorization from '../../app/utils/handleRequiredAuthorization'; -import { translate } from '../../helpers/l10n'; +import { translate, translateWithParameters } from '../../helpers/l10n'; import { AdminPagesContext } from '../../types/admin'; import { AppState } from '../../types/appstate'; import { PendingPluginResult } from '../../types/plugins'; @@ -119,13 +119,17 @@ export class AdminContainer extends React.PureComponent - + + {renderRedirects()} diff --git a/server/sonar-web/src/main/js/apps/coding-rules/__tests__/CodingRules-it.ts b/server/sonar-web/src/main/js/apps/coding-rules/__tests__/CodingRules-it.ts index 00fe9b0ff43..9750dfc6762 100644 --- a/server/sonar-web/src/main/js/apps/coding-rules/__tests__/CodingRules-it.ts +++ b/server/sonar-web/src/main/js/apps/coding-rules/__tests__/CodingRules-it.ts @@ -75,7 +75,7 @@ it('should show open rule with default description section', async () => { expect( await screen.findByRole('heading', { level: 1, name: 'Awsome java rule' }) ).toBeInTheDocument(); - expect(document.title).toEqual('coding_rule.page.Java.Awsome java rule'); + expect(document.title).toEqual('page_title.template.with_category.coding_rules.page'); expect(screen.getByText('Why')).toBeInTheDocument(); expect(screen.getByText('Because')).toBeInTheDocument(); }); diff --git a/server/sonar-web/src/main/js/apps/coding-rules/components/App.tsx b/server/sonar-web/src/main/js/apps/coding-rules/components/App.tsx deleted file mode 100644 index c69234ee29c..00000000000 --- a/server/sonar-web/src/main/js/apps/coding-rules/components/App.tsx +++ /dev/null @@ -1,718 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2023 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 { keyBy } from 'lodash'; -import * as React from 'react'; -import { Helmet } from 'react-helmet-async'; -import { Profile, searchQualityProfiles } from '../../../api/quality-profiles'; -import { getRulesApp, searchRules } from '../../../api/rules'; -import withCurrentUserContext from '../../../app/components/current-user/withCurrentUserContext'; -import A11ySkipTarget from '../../../components/a11y/A11ySkipTarget'; -import FiltersHeader from '../../../components/common/FiltersHeader'; -import ScreenPositionHelper from '../../../components/common/ScreenPositionHelper'; -import ListFooter from '../../../components/controls/ListFooter'; -import SearchBox from '../../../components/controls/SearchBox'; -import Suggestions from '../../../components/embed-docs-modal/Suggestions'; -import { Location, Router, withRouter } from '../../../components/hoc/withRouter'; -import BackIcon from '../../../components/icons/BackIcon'; -import '../../../components/search-navigator.css'; -import { isInput, isShortcut } from '../../../helpers/keyboardEventHelpers'; -import { KeyboardKeys } from '../../../helpers/keycodes'; -import { translate, translateWithParameters } from '../../../helpers/l10n'; -import { - addSideBarClass, - addWhitePageClass, - removeSideBarClass, - removeWhitePageClass, -} from '../../../helpers/pages'; -import { SecurityStandard } from '../../../types/security'; -import { Dict, Paging, RawQuery, Rule, RuleActivation } from '../../../types/types'; -import { CurrentUser, isLoggedIn } from '../../../types/users'; -import { - shouldOpenSonarSourceSecurityFacet, - shouldOpenStandardsChildFacet, - shouldOpenStandardsFacet, - STANDARDS, -} from '../../issues/utils'; -import { - Activation, - Actives, - areQueriesEqual, - FacetKey, - Facets, - getAppFacet, - getOpen, - getSelected, - getServerFacet, - hasRuleKey, - OpenFacets, - parseQuery, - Query, - serializeQuery, - shouldRequestFacet, -} from '../query'; -import '../styles.css'; -import BulkChange from './BulkChange'; -import FacetsList from './FacetsList'; -import PageActions from './PageActions'; -import RuleDetails from './RuleDetails'; -import RuleListItem from './RuleListItem'; - -const PAGE_SIZE = 100; -const MAX_SEARCH_LENGTH = 200; -const LIMIT_BEFORE_LOAD_MORE = 5; - -interface Props { - currentUser: CurrentUser; - location: Location; - router: Router; -} - -interface State { - actives?: Actives; - canWrite?: boolean; - facets?: Facets; - loading: boolean; - openFacets: OpenFacets; - paging?: Paging; - referencedProfiles: Dict; - referencedRepositories: Dict<{ key: string; language: string; name: string }>; - rules: Rule[]; -} - -export class App extends React.PureComponent { - mounted = false; - - constructor(props: Props) { - super(props); - const query = parseQuery(props.location.query); - this.state = { - loading: true, - openFacets: { - languages: true, - owaspTop10: shouldOpenStandardsChildFacet({}, query, SecurityStandard.OWASP_TOP10), - 'owaspTop10-2021': shouldOpenStandardsChildFacet( - {}, - query, - SecurityStandard.OWASP_TOP10_2021 - ), - sansTop25: shouldOpenStandardsChildFacet({}, query, SecurityStandard.SANS_TOP25), - sonarsourceSecurity: shouldOpenSonarSourceSecurityFacet({}, query), - standards: shouldOpenStandardsFacet({}, query), - types: true, - }, - referencedProfiles: {}, - referencedRepositories: {}, - rules: [], - }; - } - - componentDidMount() { - this.mounted = true; - addWhitePageClass(); - addSideBarClass(); - this.attachShortcuts(); - this.fetchInitialData(); - } - - componentDidUpdate(prevProps: Props) { - if (!areQueriesEqual(prevProps.location.query, this.props.location.query)) { - this.fetchFirstRules(); - } - if (this.getSelectedRuleKey(prevProps) !== this.getSelectedRuleKey(this.props)) { - // if user simply selected another issue - // or if user went from the source code back to the list of issues - this.scrollToSelectedRule(); - } - } - - componentWillUnmount() { - this.mounted = false; - removeWhitePageClass(); - removeSideBarClass(); - this.detachShortcuts(); - } - - attachShortcuts = () => { - document.addEventListener('keydown', this.handleKeyPress); - }; - - handleKeyPress = (event: KeyboardEvent) => { - if (isInput(event) || isShortcut(event)) { - return true; - } - switch (event.key) { - case KeyboardKeys.LeftArrow: - event.preventDefault(); - this.handleBack(); - break; - case KeyboardKeys.RightArrow: - event.preventDefault(); - this.openSelectedRule(); - break; - case KeyboardKeys.DownArrow: - event.preventDefault(); - this.selectNextRule(); - break; - case KeyboardKeys.UpArrow: - event.preventDefault(); - this.selectPreviousRule(); - break; - } - }; - - detachShortcuts = () => { - document.removeEventListener('keydown', this.handleKeyPress); - }; - - getOpenRule = (rules: Rule[]) => { - const open = getOpen(this.props.location.query); - return open && rules.find((rule) => rule.key === open); - }; - - getSelectedRuleKey = (props: Props) => { - return getSelected(props.location.query); - }; - - getFacetsToFetch = () => { - const { openFacets } = this.state; - return Object.keys(openFacets) - .filter((facet: FacetKey) => openFacets[facet]) - .filter((facet: FacetKey) => shouldRequestFacet(facet)) - .map((facet: FacetKey) => getServerFacet(facet)); - }; - - getFieldsToFetch = () => { - const fields = [ - 'isTemplate', - 'name', - 'lang', - 'langName', - 'severity', - 'status', - 'sysTags', - 'tags', - 'templateKey', - ]; - if (parseQuery(this.props.location.query).profile) { - fields.push('actives', 'params'); - } - return fields; - }; - - getSearchParameters = () => ({ - f: this.getFieldsToFetch().join(), - facets: this.getFacetsToFetch().join(), - ps: PAGE_SIZE, - s: 'name', - ...this.props.location.query, - }); - - stopLoading = () => { - if (this.mounted) { - this.setState({ loading: false }); - } - }; - - fetchInitialData = () => { - this.setState({ loading: true }); - Promise.all([getRulesApp(), searchQualityProfiles()]).then( - ([{ canWrite, repositories }, { profiles }]) => { - this.setState({ - canWrite, - referencedProfiles: keyBy(profiles, 'key'), - referencedRepositories: keyBy(repositories, 'key'), - }); - this.fetchFirstRules(); - }, - this.stopLoading - ); - }; - - makeFetchRequest = (query?: RawQuery) => - searchRules({ ...this.getSearchParameters(), ...query }).then( - ({ actives: rawActives, facets: rawFacets, p, ps, rules, total }) => { - const actives = rawActives && parseActives(rawActives); - const facets = rawFacets && parseFacets(rawFacets); - const paging = { pageIndex: p, pageSize: ps, total }; - return { actives, facets, paging, rules }; - } - ); - - fetchFirstRules = (query?: RawQuery) => { - this.setState({ loading: true }); - this.makeFetchRequest(query).then(({ actives, facets, paging, rules }) => { - if (this.mounted) { - const openRule = this.getOpenRule(rules); - const selected = rules.length > 0 && !openRule ? rules[0].key : undefined; - this.routeSelectedRulePath(selected); - this.setState({ - actives, - facets, - loading: false, - paging, - rules, - }); - } - }, this.stopLoading); - }; - - fetchMoreRules = () => { - const { paging } = this.state; - if (paging) { - this.setState({ loading: true }); - const nextPage = paging.pageIndex + 1; - this.makeFetchRequest({ p: nextPage, facets: undefined }).then( - ({ actives, paging, rules }) => { - if (this.mounted) { - this.setState((state: State) => ({ - actives: { ...state.actives, ...actives }, - loading: false, - paging, - rules: [...state.rules, ...rules], - })); - } - }, - this.stopLoading - ); - } - }; - - fetchFacet = (facet: FacetKey) => { - this.makeFetchRequest({ ps: 1, facets: getServerFacet(facet) }).then(({ facets }) => { - if (this.mounted) { - this.setState((state) => ({ facets: { ...state.facets, ...facets }, loading: false })); - } - }, this.stopLoading); - }; - - getSelectedIndex = ({ rules } = this.state) => { - const selected = this.getSelectedRuleKey(this.props) || getOpen(this.props.location.query); - const index = rules.findIndex((rule) => rule.key === selected); - return index !== -1 ? index : undefined; - }; - - selectNextRule = () => { - const { rules, loading, paging } = this.state; - const selectedIndex = this.getSelectedIndex(); - if (selectedIndex !== undefined) { - if ( - selectedIndex > rules.length - LIMIT_BEFORE_LOAD_MORE && - !loading && - paging && - rules.length < paging.total - ) { - this.fetchMoreRules(); - } - if (rules && selectedIndex < rules.length - 1) { - if (this.getOpenRule(this.state.rules)) { - this.openRule(rules[selectedIndex + 1].key); - } else { - this.routeSelectedRulePath(rules[selectedIndex + 1].key); - } - } - } - }; - - selectPreviousRule = () => { - const { rules } = this.state; - const selectedIndex = this.getSelectedIndex(); - if (rules && selectedIndex !== undefined && selectedIndex > 0) { - if (this.getOpenRule(this.state.rules)) { - this.openRule(rules[selectedIndex - 1].key); - } else { - this.routeSelectedRulePath(rules[selectedIndex - 1].key); - } - } - }; - - getRulePath = (rule: string) => ({ - pathname: this.props.location.pathname, - query: { - ...serializeQuery(parseQuery(this.props.location.query)), - open: rule, - }, - }); - - routeSelectedRulePath = (rule?: string) => { - if (rule) { - this.props.router.replace({ - pathname: this.props.location.pathname, - query: { ...serializeQuery(parseQuery(this.props.location.query)), selected: rule }, - }); - } - }; - - openRule = (rule: string) => { - const path = this.getRulePath(rule); - if (this.getOpenRule(this.state.rules)) { - this.props.router.replace(path); - } else { - this.props.router.push(path); - } - }; - - openSelectedRule = () => { - const selected = this.getSelectedRuleKey(this.props); - if (selected) { - this.openRule(selected); - } - }; - - closeRule = () => { - this.props.router.push({ - pathname: this.props.location.pathname, - query: { - ...serializeQuery(parseQuery(this.props.location.query)), - selected: this.getOpenRule(this.state.rules)?.key || this.getSelectedRuleKey(this.props), - open: undefined, - }, - }); - this.scrollToSelectedRule(); - }; - - scrollToSelectedRule = () => { - const selected = this.getSelectedRuleKey(this.props); - if (selected) { - const element = document.querySelector(`[data-rule="${selected}"]`); - if (element) { - element.scrollIntoView({ behavior: 'auto', block: 'center' }); - } - } - }; - - getRuleActivation = (rule: string) => { - const { actives } = this.state; - const query = parseQuery(this.props.location.query); - if (actives && actives[rule] && query.profile) { - return actives[rule][query.profile]; - } - }; - - getSelectedProfile = () => { - const { referencedProfiles } = this.state; - const query = parseQuery(this.props.location.query); - if (query.profile) { - return referencedProfiles[query.profile]; - } - }; - - closeFacet = (facet: string) => - this.setState((state) => ({ - openFacets: { ...state.openFacets, [facet]: false }, - })); - - handleRuleOpen = (ruleKey: string) => { - this.props.router.push(this.getRulePath(ruleKey)); - }; - - handleBack = (event?: React.SyntheticEvent) => { - const usingPermalink = hasRuleKey(this.props.location.query); - - if (event) { - event.preventDefault(); - event.currentTarget.blur(); - } - - if (usingPermalink) { - this.handleReset(); - } else { - this.closeRule(); - } - }; - - handleFilterChange = (changes: Partial) => { - this.props.router.push({ - pathname: this.props.location.pathname, - query: serializeQuery({ ...parseQuery(this.props.location.query), ...changes }), - }); - - this.setState(({ openFacets }) => ({ - openFacets: { - ...openFacets, - sonarsourceSecurity: shouldOpenSonarSourceSecurityFacet(openFacets, changes), - standards: shouldOpenStandardsFacet(openFacets, changes), - }, - })); - }; - - handleFacetToggle = (property: string) => { - this.setState((state) => { - const willOpenProperty = !state.openFacets[property]; - const newState = { - loading: state.loading, - openFacets: { ...state.openFacets, [property]: willOpenProperty }, - }; - - // Try to open sonarsource security "subfacet" by default if the standard facet is open - if (willOpenProperty && property === STANDARDS) { - newState.openFacets.sonarsourceSecurity = shouldOpenSonarSourceSecurityFacet( - newState.openFacets, - parseQuery(this.props.location.query) - ); - // Force loading of sonarsource security facet data - property = newState.openFacets.sonarsourceSecurity ? 'sonarsourceSecurity' : property; - } - - if (shouldRequestFacet(property) && (!state.facets || !state.facets[property])) { - newState.loading = true; - this.fetchFacet(property); - } - - return newState; - }); - }; - - handleReload = () => this.fetchFirstRules(); - - handleReset = () => this.props.router.push({ pathname: this.props.location.pathname }); - - /** Tries to take rule by index, or takes the last one */ - pickRuleAround = (rules: Rule[], selectedIndex: number | undefined) => { - if (selectedIndex === undefined || rules.length === 0) { - return undefined; - } - if (selectedIndex >= 0 && selectedIndex < rules.length) { - return rules[selectedIndex].key; - } - return rules[rules.length - 1].key; - }; - - handleRuleDelete = (ruleKey: string) => { - if (parseQuery(this.props.location.query).ruleKey === ruleKey) { - this.handleReset(); - } else { - this.setState((state) => { - const rules = state.rules.filter((rule) => rule.key !== ruleKey); - const selectedIndex = this.getSelectedIndex(state); - const selected = this.pickRuleAround(rules, selectedIndex); - this.routeSelectedRulePath(selected); - return { rules }; - }); - this.closeRule(); - } - }; - - handleRuleActivate = (profile: string, rule: string, activation: Activation) => - this.setState((state: State) => { - const { actives = {} } = state; - if (!actives[rule]) { - return { actives: { ...actives, [rule]: { [profile]: activation } } }; - } - - return { actives: { ...actives, [rule]: { ...actives[rule], [profile]: activation } } }; - }); - - handleRuleDeactivate = (profile: string, rule: string) => - this.setState((state) => { - const { actives } = state; - if (actives && actives[rule]) { - const newRule = { ...actives[rule] }; - delete newRule[profile]; - return { actives: { ...actives, [rule]: newRule } }; - } - return null; - }); - - handleSearch = (searchQuery: string) => this.handleFilterChange({ searchQuery }); - - isFiltered = () => Object.keys(serializeQuery(parseQuery(this.props.location.query))).length > 0; - - renderBulkButton = () => { - const { currentUser } = this.props; - const { canWrite, paging, referencedProfiles } = this.state; - const query = parseQuery(this.props.location.query); - const canUpdate = canWrite || Object.values(referencedProfiles).some((p) => p.actions?.edit); - - if (!isLoggedIn(currentUser) || !canUpdate) { - return
; - } - - return ( - paging && ( - - ) - ); - }; - - render() { - const { paging, rules } = this.state; - const selectedIndex = this.getSelectedIndex(); - const query = parseQuery(this.props.location.query); - const openRule = this.getOpenRule(this.state.rules); - const usingPermalink = hasRuleKey(this.props.location.query); - const selected = this.getSelectedRuleKey(this.props); - - return ( - <> - - - - -
- - {({ top }) => ( -
-
-
- - - - -
-
-
- )} -
- -
-
-
-
- -
- {openRule ? ( - - - {usingPermalink - ? translate('coding_rules.see_all') - : translate('coding_rules.return_to_list')} - - ) : ( - this.renderBulkButton() - )} - {!usingPermalink && ( - - )} -
-
-
-
- -
- {openRule ? ( - - ) : ( - <> -

{translate('list_of_rules')}

-
    - {rules.map((rule) => ( - - ))} -
- {paging !== undefined && ( - - )} - - )} -
-
-
- - ); - } -} - -function parseActives(rawActives: Dict) { - const actives: Actives = {}; - for (const [rule, activations] of Object.entries(rawActives)) { - actives[rule] = {}; - for (const { inherit, qProfile, severity } of activations) { - actives[rule][qProfile] = { inherit, severity }; - } - } - return actives; -} - -function parseFacets(rawFacets: { property: string; values: { count: number; val: string }[] }[]) { - const facets: Facets = {}; - for (const rawFacet of rawFacets) { - const values: Dict = {}; - for (const rawValue of rawFacet.values) { - values[rawValue.val] = rawValue.count; - } - facets[getAppFacet(rawFacet.property)] = values; - } - return facets; -} - -export default withRouter(withCurrentUserContext(App)); diff --git a/server/sonar-web/src/main/js/apps/coding-rules/components/CodingRulesApp.tsx b/server/sonar-web/src/main/js/apps/coding-rules/components/CodingRulesApp.tsx new file mode 100644 index 00000000000..79dcc48d547 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/coding-rules/components/CodingRulesApp.tsx @@ -0,0 +1,722 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { keyBy } from 'lodash'; +import * as React from 'react'; +import { Helmet } from 'react-helmet-async'; +import { Profile, searchQualityProfiles } from '../../../api/quality-profiles'; +import { getRulesApp, searchRules } from '../../../api/rules'; +import withCurrentUserContext from '../../../app/components/current-user/withCurrentUserContext'; +import A11ySkipTarget from '../../../components/a11y/A11ySkipTarget'; +import FiltersHeader from '../../../components/common/FiltersHeader'; +import ScreenPositionHelper from '../../../components/common/ScreenPositionHelper'; +import ListFooter from '../../../components/controls/ListFooter'; +import SearchBox from '../../../components/controls/SearchBox'; +import Suggestions from '../../../components/embed-docs-modal/Suggestions'; +import { Location, Router, withRouter } from '../../../components/hoc/withRouter'; +import BackIcon from '../../../components/icons/BackIcon'; +import '../../../components/search-navigator.css'; +import { isInput, isShortcut } from '../../../helpers/keyboardEventHelpers'; +import { KeyboardKeys } from '../../../helpers/keycodes'; +import { translate, translateWithParameters } from '../../../helpers/l10n'; +import { + addSideBarClass, + addWhitePageClass, + removeSideBarClass, + removeWhitePageClass, +} from '../../../helpers/pages'; +import { SecurityStandard } from '../../../types/security'; +import { Dict, Paging, RawQuery, Rule, RuleActivation } from '../../../types/types'; +import { CurrentUser, isLoggedIn } from '../../../types/users'; +import { + shouldOpenSonarSourceSecurityFacet, + shouldOpenStandardsChildFacet, + shouldOpenStandardsFacet, + STANDARDS, +} from '../../issues/utils'; +import { + Activation, + Actives, + areQueriesEqual, + FacetKey, + Facets, + getAppFacet, + getOpen, + getSelected, + getServerFacet, + hasRuleKey, + OpenFacets, + parseQuery, + Query, + serializeQuery, + shouldRequestFacet, +} from '../query'; +import '../styles.css'; +import BulkChange from './BulkChange'; +import FacetsList from './FacetsList'; +import PageActions from './PageActions'; +import RuleDetails from './RuleDetails'; +import RuleListItem from './RuleListItem'; + +const PAGE_SIZE = 100; +const MAX_SEARCH_LENGTH = 200; +const LIMIT_BEFORE_LOAD_MORE = 5; + +interface Props { + currentUser: CurrentUser; + location: Location; + router: Router; +} + +interface State { + actives?: Actives; + canWrite?: boolean; + facets?: Facets; + loading: boolean; + openFacets: OpenFacets; + paging?: Paging; + referencedProfiles: Dict; + referencedRepositories: Dict<{ key: string; language: string; name: string }>; + rules: Rule[]; +} + +export class CodingRulesApp extends React.PureComponent { + mounted = false; + + constructor(props: Props) { + super(props); + const query = parseQuery(props.location.query); + this.state = { + loading: true, + openFacets: { + languages: true, + owaspTop10: shouldOpenStandardsChildFacet({}, query, SecurityStandard.OWASP_TOP10), + 'owaspTop10-2021': shouldOpenStandardsChildFacet( + {}, + query, + SecurityStandard.OWASP_TOP10_2021 + ), + sansTop25: shouldOpenStandardsChildFacet({}, query, SecurityStandard.SANS_TOP25), + sonarsourceSecurity: shouldOpenSonarSourceSecurityFacet({}, query), + standards: shouldOpenStandardsFacet({}, query), + types: true, + }, + referencedProfiles: {}, + referencedRepositories: {}, + rules: [], + }; + } + + componentDidMount() { + this.mounted = true; + addWhitePageClass(); + addSideBarClass(); + this.attachShortcuts(); + this.fetchInitialData(); + } + + componentDidUpdate(prevProps: Props) { + if (!areQueriesEqual(prevProps.location.query, this.props.location.query)) { + this.fetchFirstRules(); + } + if (this.getSelectedRuleKey(prevProps) !== this.getSelectedRuleKey(this.props)) { + // if user simply selected another issue + // or if user went from the source code back to the list of issues + this.scrollToSelectedRule(); + } + } + + componentWillUnmount() { + this.mounted = false; + removeWhitePageClass(); + removeSideBarClass(); + this.detachShortcuts(); + } + + attachShortcuts = () => { + document.addEventListener('keydown', this.handleKeyPress); + }; + + handleKeyPress = (event: KeyboardEvent) => { + if (isInput(event) || isShortcut(event)) { + return true; + } + switch (event.key) { + case KeyboardKeys.LeftArrow: + event.preventDefault(); + this.handleBack(); + break; + case KeyboardKeys.RightArrow: + event.preventDefault(); + this.openSelectedRule(); + break; + case KeyboardKeys.DownArrow: + event.preventDefault(); + this.selectNextRule(); + break; + case KeyboardKeys.UpArrow: + event.preventDefault(); + this.selectPreviousRule(); + break; + } + }; + + detachShortcuts = () => { + document.removeEventListener('keydown', this.handleKeyPress); + }; + + getOpenRule = (rules: Rule[]) => { + const open = getOpen(this.props.location.query); + return open && rules.find((rule) => rule.key === open); + }; + + getSelectedRuleKey = (props: Props) => { + return getSelected(props.location.query); + }; + + getFacetsToFetch = () => { + const { openFacets } = this.state; + return Object.keys(openFacets) + .filter((facet: FacetKey) => openFacets[facet]) + .filter((facet: FacetKey) => shouldRequestFacet(facet)) + .map((facet: FacetKey) => getServerFacet(facet)); + }; + + getFieldsToFetch = () => { + const fields = [ + 'isTemplate', + 'name', + 'lang', + 'langName', + 'severity', + 'status', + 'sysTags', + 'tags', + 'templateKey', + ]; + if (parseQuery(this.props.location.query).profile) { + fields.push('actives', 'params'); + } + return fields; + }; + + getSearchParameters = () => ({ + f: this.getFieldsToFetch().join(), + facets: this.getFacetsToFetch().join(), + ps: PAGE_SIZE, + s: 'name', + ...this.props.location.query, + }); + + stopLoading = () => { + if (this.mounted) { + this.setState({ loading: false }); + } + }; + + fetchInitialData = () => { + this.setState({ loading: true }); + Promise.all([getRulesApp(), searchQualityProfiles()]).then( + ([{ canWrite, repositories }, { profiles }]) => { + this.setState({ + canWrite, + referencedProfiles: keyBy(profiles, 'key'), + referencedRepositories: keyBy(repositories, 'key'), + }); + this.fetchFirstRules(); + }, + this.stopLoading + ); + }; + + makeFetchRequest = (query?: RawQuery) => + searchRules({ ...this.getSearchParameters(), ...query }).then( + ({ actives: rawActives, facets: rawFacets, p, ps, rules, total }) => { + const actives = rawActives && parseActives(rawActives); + const facets = rawFacets && parseFacets(rawFacets); + const paging = { pageIndex: p, pageSize: ps, total }; + return { actives, facets, paging, rules }; + } + ); + + fetchFirstRules = (query?: RawQuery) => { + this.setState({ loading: true }); + this.makeFetchRequest(query).then(({ actives, facets, paging, rules }) => { + if (this.mounted) { + const openRule = this.getOpenRule(rules); + const selected = rules.length > 0 && !openRule ? rules[0].key : undefined; + this.routeSelectedRulePath(selected); + this.setState({ + actives, + facets, + loading: false, + paging, + rules, + }); + } + }, this.stopLoading); + }; + + fetchMoreRules = () => { + const { paging } = this.state; + if (paging) { + this.setState({ loading: true }); + const nextPage = paging.pageIndex + 1; + this.makeFetchRequest({ p: nextPage, facets: undefined }).then( + ({ actives, paging, rules }) => { + if (this.mounted) { + this.setState((state: State) => ({ + actives: { ...state.actives, ...actives }, + loading: false, + paging, + rules: [...state.rules, ...rules], + })); + } + }, + this.stopLoading + ); + } + }; + + fetchFacet = (facet: FacetKey) => { + this.makeFetchRequest({ ps: 1, facets: getServerFacet(facet) }).then(({ facets }) => { + if (this.mounted) { + this.setState((state) => ({ facets: { ...state.facets, ...facets }, loading: false })); + } + }, this.stopLoading); + }; + + getSelectedIndex = ({ rules } = this.state) => { + const selected = this.getSelectedRuleKey(this.props) || getOpen(this.props.location.query); + const index = rules.findIndex((rule) => rule.key === selected); + return index !== -1 ? index : undefined; + }; + + selectNextRule = () => { + const { rules, loading, paging } = this.state; + const selectedIndex = this.getSelectedIndex(); + if (selectedIndex !== undefined) { + if ( + selectedIndex > rules.length - LIMIT_BEFORE_LOAD_MORE && + !loading && + paging && + rules.length < paging.total + ) { + this.fetchMoreRules(); + } + if (rules && selectedIndex < rules.length - 1) { + if (this.getOpenRule(this.state.rules)) { + this.openRule(rules[selectedIndex + 1].key); + } else { + this.routeSelectedRulePath(rules[selectedIndex + 1].key); + } + } + } + }; + + selectPreviousRule = () => { + const { rules } = this.state; + const selectedIndex = this.getSelectedIndex(); + if (rules && selectedIndex !== undefined && selectedIndex > 0) { + if (this.getOpenRule(this.state.rules)) { + this.openRule(rules[selectedIndex - 1].key); + } else { + this.routeSelectedRulePath(rules[selectedIndex - 1].key); + } + } + }; + + getRulePath = (rule: string) => ({ + pathname: this.props.location.pathname, + query: { + ...serializeQuery(parseQuery(this.props.location.query)), + open: rule, + }, + }); + + routeSelectedRulePath = (rule?: string) => { + if (rule) { + this.props.router.replace({ + pathname: this.props.location.pathname, + query: { ...serializeQuery(parseQuery(this.props.location.query)), selected: rule }, + }); + } + }; + + openRule = (rule: string) => { + const path = this.getRulePath(rule); + if (this.getOpenRule(this.state.rules)) { + this.props.router.replace(path); + } else { + this.props.router.push(path); + } + }; + + openSelectedRule = () => { + const selected = this.getSelectedRuleKey(this.props); + if (selected) { + this.openRule(selected); + } + }; + + closeRule = () => { + this.props.router.push({ + pathname: this.props.location.pathname, + query: { + ...serializeQuery(parseQuery(this.props.location.query)), + selected: this.getOpenRule(this.state.rules)?.key || this.getSelectedRuleKey(this.props), + open: undefined, + }, + }); + this.scrollToSelectedRule(); + }; + + scrollToSelectedRule = () => { + const selected = this.getSelectedRuleKey(this.props); + if (selected) { + const element = document.querySelector(`[data-rule="${selected}"]`); + if (element) { + element.scrollIntoView({ behavior: 'auto', block: 'center' }); + } + } + }; + + getRuleActivation = (rule: string) => { + const { actives } = this.state; + const query = parseQuery(this.props.location.query); + if (actives && actives[rule] && query.profile) { + return actives[rule][query.profile]; + } + }; + + getSelectedProfile = () => { + const { referencedProfiles } = this.state; + const query = parseQuery(this.props.location.query); + if (query.profile) { + return referencedProfiles[query.profile]; + } + }; + + closeFacet = (facet: string) => + this.setState((state) => ({ + openFacets: { ...state.openFacets, [facet]: false }, + })); + + handleRuleOpen = (ruleKey: string) => { + this.props.router.push(this.getRulePath(ruleKey)); + }; + + handleBack = (event?: React.SyntheticEvent) => { + const usingPermalink = hasRuleKey(this.props.location.query); + + if (event) { + event.preventDefault(); + event.currentTarget.blur(); + } + + if (usingPermalink) { + this.handleReset(); + } else { + this.closeRule(); + } + }; + + handleFilterChange = (changes: Partial) => { + this.props.router.push({ + pathname: this.props.location.pathname, + query: serializeQuery({ ...parseQuery(this.props.location.query), ...changes }), + }); + + this.setState(({ openFacets }) => ({ + openFacets: { + ...openFacets, + sonarsourceSecurity: shouldOpenSonarSourceSecurityFacet(openFacets, changes), + standards: shouldOpenStandardsFacet(openFacets, changes), + }, + })); + }; + + handleFacetToggle = (property: string) => { + this.setState((state) => { + const willOpenProperty = !state.openFacets[property]; + const newState = { + loading: state.loading, + openFacets: { ...state.openFacets, [property]: willOpenProperty }, + }; + + // Try to open sonarsource security "subfacet" by default if the standard facet is open + if (willOpenProperty && property === STANDARDS) { + newState.openFacets.sonarsourceSecurity = shouldOpenSonarSourceSecurityFacet( + newState.openFacets, + parseQuery(this.props.location.query) + ); + // Force loading of sonarsource security facet data + property = newState.openFacets.sonarsourceSecurity ? 'sonarsourceSecurity' : property; + } + + if (shouldRequestFacet(property) && (!state.facets || !state.facets[property])) { + newState.loading = true; + this.fetchFacet(property); + } + + return newState; + }); + }; + + handleReload = () => this.fetchFirstRules(); + + handleReset = () => this.props.router.push({ pathname: this.props.location.pathname }); + + /** Tries to take rule by index, or takes the last one */ + pickRuleAround = (rules: Rule[], selectedIndex: number | undefined) => { + if (selectedIndex === undefined || rules.length === 0) { + return undefined; + } + if (selectedIndex >= 0 && selectedIndex < rules.length) { + return rules[selectedIndex].key; + } + return rules[rules.length - 1].key; + }; + + handleRuleDelete = (ruleKey: string) => { + if (parseQuery(this.props.location.query).ruleKey === ruleKey) { + this.handleReset(); + } else { + this.setState((state) => { + const rules = state.rules.filter((rule) => rule.key !== ruleKey); + const selectedIndex = this.getSelectedIndex(state); + const selected = this.pickRuleAround(rules, selectedIndex); + this.routeSelectedRulePath(selected); + return { rules }; + }); + this.closeRule(); + } + }; + + handleRuleActivate = (profile: string, rule: string, activation: Activation) => + this.setState((state: State) => { + const { actives = {} } = state; + if (!actives[rule]) { + return { actives: { ...actives, [rule]: { [profile]: activation } } }; + } + + return { actives: { ...actives, [rule]: { ...actives[rule], [profile]: activation } } }; + }); + + handleRuleDeactivate = (profile: string, rule: string) => + this.setState((state) => { + const { actives } = state; + if (actives && actives[rule]) { + const newRule = { ...actives[rule] }; + delete newRule[profile]; + return { actives: { ...actives, [rule]: newRule } }; + } + return null; + }); + + handleSearch = (searchQuery: string) => this.handleFilterChange({ searchQuery }); + + isFiltered = () => Object.keys(serializeQuery(parseQuery(this.props.location.query))).length > 0; + + renderBulkButton = () => { + const { currentUser } = this.props; + const { canWrite, paging, referencedProfiles } = this.state; + const query = parseQuery(this.props.location.query); + const canUpdate = canWrite || Object.values(referencedProfiles).some((p) => p.actions?.edit); + + if (!isLoggedIn(currentUser) || !canUpdate) { + return
; + } + + return ( + paging && ( + + ) + ); + }; + + render() { + const { paging, rules } = this.state; + const selectedIndex = this.getSelectedIndex(); + const query = parseQuery(this.props.location.query); + const openRule = this.getOpenRule(this.state.rules); + const usingPermalink = hasRuleKey(this.props.location.query); + const selected = this.getSelectedRuleKey(this.props); + + return ( + <> + + {openRule ? ( + + ) : ( + + + + )} +
+ + {({ top }) => ( +
+
+
+ + + + +
+
+
+ )} +
+ +
+
+
+
+ +
+ {openRule ? ( + + + {usingPermalink + ? translate('coding_rules.see_all') + : translate('coding_rules.return_to_list')} + + ) : ( + this.renderBulkButton() + )} + {!usingPermalink && ( + + )} +
+
+
+
+ +
+ {openRule ? ( + + ) : ( + <> +

{translate('list_of_rules')}

+
    + {rules.map((rule) => ( + + ))} +
+ {paging !== undefined && ( + + )} + + )} +
+
+
+ + ); + } +} + +function parseActives(rawActives: Dict) { + const actives: Actives = {}; + for (const [rule, activations] of Object.entries(rawActives)) { + actives[rule] = {}; + for (const { inherit, qProfile, severity } of activations) { + actives[rule][qProfile] = { inherit, severity }; + } + } + return actives; +} + +function parseFacets(rawFacets: { property: string; values: { count: number; val: string }[] }[]) { + const facets: Facets = {}; + for (const rawFacet of rawFacets) { + const values: Dict = {}; + for (const rawValue of rawFacet.values) { + values[rawValue.val] = rawValue.count; + } + facets[getAppFacet(rawFacet.property)] = values; + } + return facets; +} + +export default withRouter(withCurrentUserContext(CodingRulesApp)); diff --git a/server/sonar-web/src/main/js/apps/coding-rules/components/__tests__/App-test.tsx b/server/sonar-web/src/main/js/apps/coding-rules/components/__tests__/App-test.tsx deleted file mode 100644 index aa18b5cba52..00000000000 --- a/server/sonar-web/src/main/js/apps/coding-rules/components/__tests__/App-test.tsx +++ /dev/null @@ -1,119 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2023 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 { searchQualityProfiles } from '../../../../api/quality-profiles'; -import { getRulesApp } from '../../../../api/rules'; -import ScreenPositionHelper from '../../../../components/common/ScreenPositionHelper'; -import { - mockCurrentUser, - mockLocation, - mockQualityProfile, - mockRouter, -} from '../../../../helpers/testMocks'; -import { waitAndUpdate } from '../../../../helpers/testUtils'; -import { App } from '../App'; - -jest.mock('../../../../components/common/ScreenPositionHelper'); - -jest.mock('../../../../api/rules', () => { - const { mockRule } = jest.requireActual('../../../../helpers/testMocks'); - return { - getRulesApp: jest.fn().mockResolvedValue({ canWrite: true, repositories: [] }), - searchRules: jest.fn().mockResolvedValue({ - actives: [], - rawActives: [], - facets: [], - rawFacets: [], - p: 0, - ps: 100, - rules: [mockRule(), mockRule()], - total: 0, - }), - }; -}); - -jest.mock('../../../../api/quality-profiles', () => ({ - searchQualityProfiles: jest.fn().mockResolvedValue({ profiles: [] }), -})); - -jest.mock('../../../../helpers/system', () => ({ - getReactDomContainerSelector: () => '#content', -})); - -it('should render correctly', async () => { - const wrapper = shallowRender(); - expect(wrapper).toMatchSnapshot('loading'); - - await waitAndUpdate(wrapper); - expect(wrapper).toMatchSnapshot('loaded'); - expect(wrapper.find(ScreenPositionHelper).dive()).toMatchSnapshot( - 'loaded (ScreenPositionHelper)' - ); -}); - -describe('renderBulkButton', () => { - it('should be null when the user is not logged in', () => { - const wrapper = shallowRender({ - currentUser: mockCurrentUser(), - }); - expect(wrapper.instance().renderBulkButton()).toMatchSnapshot(); - }); - - it('should be null when the user does not have the sufficient permission', () => { - (getRulesApp as jest.Mock).mockReturnValueOnce({ canWrite: false, repositories: [] }); - - const wrapper = shallowRender(); - expect(wrapper.instance().renderBulkButton()).toMatchSnapshot(); - }); - - it('should show bulk change button when user has global admin rights on quality profiles', async () => { - (getRulesApp as jest.Mock).mockReturnValueOnce({ canWrite: true, repositories: [] }); - const wrapper = shallowRender(); - await waitAndUpdate(wrapper); - - expect(wrapper.instance().renderBulkButton()).toMatchSnapshot(); - }); - - it('should show bulk change button when user has edit rights on specific quality profile', async () => { - (getRulesApp as jest.Mock).mockReturnValueOnce({ canWrite: false, repositories: [] }); - (searchQualityProfiles as jest.Mock).mockReturnValueOnce({ - profiles: [mockQualityProfile({ key: 'foo', actions: { edit: true } }), mockQualityProfile()], - }); - - const wrapper = shallowRender(); - await waitAndUpdate(wrapper); - - expect(wrapper.instance().renderBulkButton()).toMatchSnapshot(); - }); -}); - -function shallowRender(props: Partial = {}) { - return shallow( - - ); -} diff --git a/server/sonar-web/src/main/js/apps/coding-rules/components/__tests__/CodingRulesApp-test.tsx b/server/sonar-web/src/main/js/apps/coding-rules/components/__tests__/CodingRulesApp-test.tsx new file mode 100644 index 00000000000..09ccc3496a5 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/coding-rules/components/__tests__/CodingRulesApp-test.tsx @@ -0,0 +1,119 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { searchQualityProfiles } from '../../../../api/quality-profiles'; +import { getRulesApp } from '../../../../api/rules'; +import ScreenPositionHelper from '../../../../components/common/ScreenPositionHelper'; +import { + mockCurrentUser, + mockLocation, + mockQualityProfile, + mockRouter, +} from '../../../../helpers/testMocks'; +import { waitAndUpdate } from '../../../../helpers/testUtils'; +import { CodingRulesApp } from '../CodingRulesApp'; + +jest.mock('../../../../components/common/ScreenPositionHelper'); + +jest.mock('../../../../api/rules', () => { + const { mockRule } = jest.requireActual('../../../../helpers/testMocks'); + return { + getRulesApp: jest.fn().mockResolvedValue({ canWrite: true, repositories: [] }), + searchRules: jest.fn().mockResolvedValue({ + actives: [], + rawActives: [], + facets: [], + rawFacets: [], + p: 0, + ps: 100, + rules: [mockRule(), mockRule()], + total: 0, + }), + }; +}); + +jest.mock('../../../../api/quality-profiles', () => ({ + searchQualityProfiles: jest.fn().mockResolvedValue({ profiles: [] }), +})); + +jest.mock('../../../../helpers/system', () => ({ + getReactDomContainerSelector: () => '#content', +})); + +it('should render correctly', async () => { + const wrapper = shallowRender(); + expect(wrapper).toMatchSnapshot('loading'); + + await waitAndUpdate(wrapper); + expect(wrapper).toMatchSnapshot('loaded'); + expect(wrapper.find(ScreenPositionHelper).dive()).toMatchSnapshot( + 'loaded (ScreenPositionHelper)' + ); +}); + +describe('renderBulkButton', () => { + it('should be null when the user is not logged in', () => { + const wrapper = shallowRender({ + currentUser: mockCurrentUser(), + }); + expect(wrapper.instance().renderBulkButton()).toMatchSnapshot(); + }); + + it('should be null when the user does not have the sufficient permission', () => { + (getRulesApp as jest.Mock).mockReturnValueOnce({ canWrite: false, repositories: [] }); + + const wrapper = shallowRender(); + expect(wrapper.instance().renderBulkButton()).toMatchSnapshot(); + }); + + it('should show bulk change button when user has global admin rights on quality profiles', async () => { + (getRulesApp as jest.Mock).mockReturnValueOnce({ canWrite: true, repositories: [] }); + const wrapper = shallowRender(); + await waitAndUpdate(wrapper); + + expect(wrapper.instance().renderBulkButton()).toMatchSnapshot(); + }); + + it('should show bulk change button when user has edit rights on specific quality profile', async () => { + (getRulesApp as jest.Mock).mockReturnValueOnce({ canWrite: false, repositories: [] }); + (searchQualityProfiles as jest.Mock).mockReturnValueOnce({ + profiles: [mockQualityProfile({ key: 'foo', actions: { edit: true } }), mockQualityProfile()], + }); + + const wrapper = shallowRender(); + await waitAndUpdate(wrapper); + + expect(wrapper.instance().renderBulkButton()).toMatchSnapshot(); + }); +}); + +function shallowRender(props: Partial = {}) { + return shallow( + + ); +} diff --git a/server/sonar-web/src/main/js/apps/coding-rules/components/__tests__/__snapshots__/App-test.tsx.snap b/server/sonar-web/src/main/js/apps/coding-rules/components/__tests__/__snapshots__/App-test.tsx.snap deleted file mode 100644 index 08da9918375..00000000000 --- a/server/sonar-web/src/main/js/apps/coding-rules/components/__tests__/__snapshots__/App-test.tsx.snap +++ /dev/null @@ -1,406 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`renderBulkButton should be null when the user does not have the sufficient permission 1`] = `
`; - -exports[`renderBulkButton should be null when the user is not logged in 1`] = `
`; - -exports[`renderBulkButton should show bulk change button when user has edit rights on specific quality profile 1`] = ` - -`; - -exports[`renderBulkButton should show bulk change button when user has global admin rights on quality profiles 1`] = ` - -`; - -exports[`should render correctly: loaded (ScreenPositionHelper) 1`] = ` -
-
-
- - - - -
-
-
-`; - -exports[`should render correctly: loaded 1`] = ` - - - - - -
- - - -
-
-
-
- -
- - -
-
-
-
-
-

- list_of_rules -

-
    - - -
- -
-
-
-
-`; - -exports[`should render correctly: loading 1`] = ` - - - - - -
- - - -
-
-
-
- -
-
- -
-
-
-
-
-

- list_of_rules -

-
    -
-
-
-
-`; diff --git a/server/sonar-web/src/main/js/apps/coding-rules/components/__tests__/__snapshots__/CodingRulesApp-test.tsx.snap b/server/sonar-web/src/main/js/apps/coding-rules/components/__tests__/__snapshots__/CodingRulesApp-test.tsx.snap new file mode 100644 index 00000000000..08da9918375 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/coding-rules/components/__tests__/__snapshots__/CodingRulesApp-test.tsx.snap @@ -0,0 +1,406 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renderBulkButton should be null when the user does not have the sufficient permission 1`] = `
`; + +exports[`renderBulkButton should be null when the user is not logged in 1`] = `
`; + +exports[`renderBulkButton should show bulk change button when user has edit rights on specific quality profile 1`] = ` + +`; + +exports[`renderBulkButton should show bulk change button when user has global admin rights on quality profiles 1`] = ` + +`; + +exports[`should render correctly: loaded (ScreenPositionHelper) 1`] = ` +
+
+
+ + + + +
+
+
+`; + +exports[`should render correctly: loaded 1`] = ` + + + + + +
+ + + +
+
+
+
+ +
+ + +
+
+
+
+
+

+ list_of_rules +

+
    + + +
+ +
+
+
+
+`; + +exports[`should render correctly: loading 1`] = ` + + + + + +
+ + + +
+
+
+
+ +
+
+ +
+
+
+
+
+

+ list_of_rules +

+
    +
+
+
+
+`; diff --git a/server/sonar-web/src/main/js/apps/coding-rules/routes.tsx b/server/sonar-web/src/main/js/apps/coding-rules/routes.tsx index 2e9bb7a9355..5ba7f224b13 100644 --- a/server/sonar-web/src/main/js/apps/coding-rules/routes.tsx +++ b/server/sonar-web/src/main/js/apps/coding-rules/routes.tsx @@ -20,7 +20,7 @@ import React, { useEffect } from 'react'; import { Route, useLocation, useNavigate } from 'react-router-dom'; import { RawQuery } from '../../types/types'; -import App from './components/App'; +import CodingRulesApp from './components/CodingRulesApp'; import { parseQuery, serializeQuery } from './query'; const EXPECTED_SPLIT_PARTS = 2; @@ -56,7 +56,7 @@ function HashEditWrapper() { } }, [location, navigate]); - return ; + return ; } const routes = () => } />; diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/App.tsx b/server/sonar-web/src/main/js/apps/component-measures/components/App.tsx deleted file mode 100644 index 922fa2d0906..00000000000 --- a/server/sonar-web/src/main/js/apps/component-measures/components/App.tsx +++ /dev/null @@ -1,379 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2023 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 styled from '@emotion/styled'; -import { debounce, keyBy } from 'lodash'; -import * as React from 'react'; -import { Helmet } from 'react-helmet-async'; -import { getMeasuresWithPeriod } from '../../../api/measures'; -import { getAllMetrics } from '../../../api/metrics'; -import withBranchStatusActions from '../../../app/components/branch-status/withBranchStatusActions'; -import { ComponentContext } from '../../../app/components/componentContext/ComponentContext'; -import ScreenPositionHelper from '../../../components/common/ScreenPositionHelper'; -import HelpTooltip from '../../../components/controls/HelpTooltip'; -import Suggestions from '../../../components/embed-docs-modal/Suggestions'; -import { Location, Router, withRouter } from '../../../components/hoc/withRouter'; -import { enhanceMeasure } from '../../../components/measure/utils'; -import '../../../components/search-navigator.css'; -import { Alert } from '../../../components/ui/Alert'; -import { getBranchLikeQuery, isPullRequest, isSameBranchLike } from '../../../helpers/branch-like'; -import { - getLocalizedMetricDomain, - translate, - translateWithParameters, -} from '../../../helpers/l10n'; -import { - addSideBarClass, - addWhitePageClass, - removeSideBarClass, - removeWhitePageClass, -} from '../../../helpers/pages'; -import { BranchLike } from '../../../types/branch-like'; -import { ComponentQualifier, isPortfolioLike } from '../../../types/component'; -import { - ComponentMeasure, - Dict, - Issue, - MeasureEnhanced, - Metric, - Period, -} from '../../../types/types'; -import Sidebar from '../sidebar/Sidebar'; -import '../style.css'; -import { - banQualityGateMeasure, - getMeasuresPageMetricKeys, - groupByDomains, - hasBubbleChart, - hasFullMeasures, - hasTree, - hasTreemap, - isProjectOverview, - parseQuery, - Query, - serializeQuery, - sortMeasures, -} from '../utils'; -import MeasureContent from './MeasureContent'; -import MeasureOverviewContainer from './MeasureOverviewContainer'; -import MeasuresEmpty from './MeasuresEmpty'; - -interface Props { - branchLike?: BranchLike; - component: ComponentMeasure; - fetchBranchStatus: (branchLike: BranchLike, projectKey: string) => Promise; - location: Location; - router: Router; -} - -interface State { - leakPeriod?: Period; - loading: boolean; - measures: MeasureEnhanced[]; - metrics: Dict; -} - -export class App extends React.PureComponent { - mounted = false; - state: State; - - constructor(props: Props) { - super(props); - this.state = { - loading: true, - measures: [], - metrics: {}, - }; - this.refreshBranchStatus = debounce(this.refreshBranchStatus, 1000); - } - - componentDidMount() { - this.mounted = true; - - getAllMetrics().then( - (metrics) => { - const byKey = keyBy(metrics, 'key'); - this.setState({ metrics: byKey }); - this.fetchMeasures(byKey); - }, - () => {} - ); - } - - componentDidUpdate(prevProps: Props, prevState: State) { - const prevQuery = parseQuery(prevProps.location.query); - const query = parseQuery(this.props.location.query); - - if ( - !isSameBranchLike(prevProps.branchLike, this.props.branchLike) || - prevProps.component.key !== this.props.component.key || - prevQuery.selected !== query.selected - ) { - this.fetchMeasures(this.state.metrics); - } - - if (prevState.measures.length === 0 && this.state.measures.length > 0) { - addWhitePageClass(); - addSideBarClass(); - } - } - - componentWillUnmount() { - this.mounted = false; - removeWhitePageClass(); - removeSideBarClass(); - } - - fetchMeasures(metrics: State['metrics']) { - const { branchLike } = this.props; - const query = parseQuery(this.props.location.query); - const componentKey = query.selected || this.props.component.key; - - const filteredKeys = getMeasuresPageMetricKeys(metrics, branchLike); - - getMeasuresWithPeriod(componentKey, filteredKeys, getBranchLikeQuery(branchLike)).then( - ({ component, period }) => { - if (this.mounted) { - const measures = banQualityGateMeasure(component).map((measure) => - enhanceMeasure(measure, metrics) - ); - - const leakPeriod = - component.qualifier === ComponentQualifier.Project ? period : undefined; - - this.setState({ - loading: false, - leakPeriod, - measures: measures.filter( - (measure) => measure.value !== undefined || measure.leak !== undefined - ), - }); - } - }, - () => { - if (this.mounted) { - this.setState({ loading: false }); - } - } - ); - } - - getHelmetTitle = (query: Query, displayOverview: boolean, metric?: Metric) => { - if (displayOverview && query.metric) { - return isProjectOverview(query.metric) - ? translate('component_measures.overview.project_overview.facet') - : translateWithParameters( - 'component_measures.domain_x_overview', - getLocalizedMetricDomain(query.metric) - ); - } - return metric ? metric.name : translate('layout.measures'); - }; - - getSelectedMetric = (query: Query, displayOverview: boolean) => { - if (displayOverview) { - return undefined; - } - const metric = this.state.metrics[query.metric]; - if (!metric) { - const domainMeasures = groupByDomains(this.state.measures); - const firstMeasure = - domainMeasures[0] && sortMeasures(domainMeasures[0].name, domainMeasures[0].measures)[0]; - if (firstMeasure && typeof firstMeasure !== 'string') { - return firstMeasure.metric; - } - } - return metric; - }; - - handleIssueChange = (_: Issue) => { - this.refreshBranchStatus(); - }; - - updateQuery = (newQuery: Partial) => { - const query: Query = { ...parseQuery(this.props.location.query), ...newQuery }; - - const metric = this.getSelectedMetric(query, false); - if (metric) { - if (query.view === 'treemap' && !hasTreemap(metric.key, metric.type)) { - query.view = 'tree'; - } else if (query.view === 'tree' && !hasTree(metric.key)) { - query.view = 'list'; - } - } - - this.props.router.push({ - pathname: this.props.location.pathname, - query: { - ...serializeQuery(query), - ...getBranchLikeQuery(this.props.branchLike), - id: this.props.component.key, - }, - }); - }; - - refreshBranchStatus = () => { - const { branchLike, component } = this.props; - if (branchLike && component && isPullRequest(branchLike)) { - this.props.fetchBranchStatus(branchLike, component.key); - } - }; - - renderContent = (displayOverview: boolean, query: Query, metric?: Metric) => { - const { branchLike, component } = this.props; - const { leakPeriod } = this.state; - if (displayOverview) { - return ( - - ); - } - - if (!metric) { - return ; - } - - const hideDrilldown = - isPullRequest(branchLike) && - (metric.key === 'coverage' || metric.key === 'duplicated_lines_density'); - - if (hideDrilldown) { - return ( -
-
-
{translate('component_measures.details_are_not_available')}
-
-
- ); - } - - return ( - - ); - }; - - render() { - if (this.state.loading) { - return ( -
- -
- ); - } - - const { branchLike } = this.props; - const { measures } = this.state; - const { canBrowseAllChildProjects, qualifier } = this.props.component; - const query = parseQuery(this.props.location.query); - const showFullMeasures = hasFullMeasures(branchLike); - const displayOverview = hasBubbleChart(query.metric); - const metric = this.getSelectedMetric(query, displayOverview); - - return ( -
- - - {measures.length > 0 ? ( -
- - {({ top }) => ( -
-
- {!canBrowseAllChildProjects && isPortfolioLike(qualifier) && ( - - - {translate('component_measures.not_all_measures_are_shown')} - - - - )} -
- -
-
-
- )} -
- {this.renderContent(displayOverview, query, metric)} -
- ) : ( - - )} -
- ); - } -} - -const AlertContent = styled.div` - display: flex; - align-items: center; -`; - -/* - * This needs to be refactored: the issue - * is that we can't use the usual withComponentContext HOC, because the type - * of `component` isn't the same. It probably used to work because of the lazy loading - */ -const WrappedApp = withRouter(withBranchStatusActions(App)); - -function AppWithComponentContext() { - const { branchLike, component } = React.useContext(ComponentContext); - - return ; -} - -export default AppWithComponentContext; diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/ComponentMeasuresApp.tsx b/server/sonar-web/src/main/js/apps/component-measures/components/ComponentMeasuresApp.tsx new file mode 100644 index 00000000000..b5d7f9da79c --- /dev/null +++ b/server/sonar-web/src/main/js/apps/component-measures/components/ComponentMeasuresApp.tsx @@ -0,0 +1,362 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 styled from '@emotion/styled'; +import { debounce, keyBy } from 'lodash'; +import * as React from 'react'; +import { Helmet } from 'react-helmet-async'; +import { getMeasuresWithPeriod } from '../../../api/measures'; +import { getAllMetrics } from '../../../api/metrics'; +import withBranchStatusActions from '../../../app/components/branch-status/withBranchStatusActions'; +import { ComponentContext } from '../../../app/components/componentContext/ComponentContext'; +import ScreenPositionHelper from '../../../components/common/ScreenPositionHelper'; +import HelpTooltip from '../../../components/controls/HelpTooltip'; +import Suggestions from '../../../components/embed-docs-modal/Suggestions'; +import { Location, Router, withRouter } from '../../../components/hoc/withRouter'; +import { enhanceMeasure } from '../../../components/measure/utils'; +import '../../../components/search-navigator.css'; +import { Alert } from '../../../components/ui/Alert'; +import { getBranchLikeQuery, isPullRequest, isSameBranchLike } from '../../../helpers/branch-like'; +import { translate } from '../../../helpers/l10n'; +import { + addSideBarClass, + addWhitePageClass, + removeSideBarClass, + removeWhitePageClass, +} from '../../../helpers/pages'; +import { BranchLike } from '../../../types/branch-like'; +import { ComponentQualifier, isPortfolioLike } from '../../../types/component'; +import { + ComponentMeasure, + Dict, + Issue, + MeasureEnhanced, + Metric, + Period, +} from '../../../types/types'; +import Sidebar from '../sidebar/Sidebar'; +import '../style.css'; +import { + banQualityGateMeasure, + getMeasuresPageMetricKeys, + groupByDomains, + hasBubbleChart, + hasFullMeasures, + hasTree, + hasTreemap, + parseQuery, + Query, + serializeQuery, + sortMeasures, +} from '../utils'; +import MeasureContent from './MeasureContent'; +import MeasureOverviewContainer from './MeasureOverviewContainer'; +import MeasuresEmpty from './MeasuresEmpty'; + +interface Props { + branchLike?: BranchLike; + component: ComponentMeasure; + fetchBranchStatus: (branchLike: BranchLike, projectKey: string) => Promise; + location: Location; + router: Router; +} + +interface State { + leakPeriod?: Period; + loading: boolean; + measures: MeasureEnhanced[]; + metrics: Dict; +} + +export class ComponentMeasuresApp extends React.PureComponent { + mounted = false; + state: State; + + constructor(props: Props) { + super(props); + this.state = { + loading: true, + measures: [], + metrics: {}, + }; + this.refreshBranchStatus = debounce(this.refreshBranchStatus, 1000); + } + + componentDidMount() { + this.mounted = true; + + getAllMetrics().then( + (metrics) => { + const byKey = keyBy(metrics, 'key'); + this.setState({ metrics: byKey }); + this.fetchMeasures(byKey); + }, + () => {} + ); + } + + componentDidUpdate(prevProps: Props, prevState: State) { + const prevQuery = parseQuery(prevProps.location.query); + const query = parseQuery(this.props.location.query); + + if ( + !isSameBranchLike(prevProps.branchLike, this.props.branchLike) || + prevProps.component.key !== this.props.component.key || + prevQuery.selected !== query.selected + ) { + this.fetchMeasures(this.state.metrics); + } + + if (prevState.measures.length === 0 && this.state.measures.length > 0) { + addWhitePageClass(); + addSideBarClass(); + } + } + + componentWillUnmount() { + this.mounted = false; + removeWhitePageClass(); + removeSideBarClass(); + } + + fetchMeasures(metrics: State['metrics']) { + const { branchLike } = this.props; + const query = parseQuery(this.props.location.query); + const componentKey = query.selected || this.props.component.key; + + const filteredKeys = getMeasuresPageMetricKeys(metrics, branchLike); + + getMeasuresWithPeriod(componentKey, filteredKeys, getBranchLikeQuery(branchLike)).then( + ({ component, period }) => { + if (this.mounted) { + const measures = banQualityGateMeasure(component).map((measure) => + enhanceMeasure(measure, metrics) + ); + + const leakPeriod = + component.qualifier === ComponentQualifier.Project ? period : undefined; + + this.setState({ + loading: false, + leakPeriod, + measures: measures.filter( + (measure) => measure.value !== undefined || measure.leak !== undefined + ), + }); + } + }, + () => { + if (this.mounted) { + this.setState({ loading: false }); + } + } + ); + } + + getSelectedMetric = (query: Query, displayOverview: boolean) => { + if (displayOverview) { + return undefined; + } + const metric = this.state.metrics[query.metric]; + if (!metric) { + const domainMeasures = groupByDomains(this.state.measures); + const firstMeasure = + domainMeasures[0] && sortMeasures(domainMeasures[0].name, domainMeasures[0].measures)[0]; + if (firstMeasure && typeof firstMeasure !== 'string') { + return firstMeasure.metric; + } + } + return metric; + }; + + handleIssueChange = (_: Issue) => { + this.refreshBranchStatus(); + }; + + updateQuery = (newQuery: Partial) => { + const query: Query = { ...parseQuery(this.props.location.query), ...newQuery }; + + const metric = this.getSelectedMetric(query, false); + if (metric) { + if (query.view === 'treemap' && !hasTreemap(metric.key, metric.type)) { + query.view = 'tree'; + } else if (query.view === 'tree' && !hasTree(metric.key)) { + query.view = 'list'; + } + } + + this.props.router.push({ + pathname: this.props.location.pathname, + query: { + ...serializeQuery(query), + ...getBranchLikeQuery(this.props.branchLike), + id: this.props.component.key, + }, + }); + }; + + refreshBranchStatus = () => { + const { branchLike, component } = this.props; + if (branchLike && component && isPullRequest(branchLike)) { + this.props.fetchBranchStatus(branchLike, component.key); + } + }; + + renderContent = (displayOverview: boolean, query: Query, metric?: Metric) => { + const { branchLike, component } = this.props; + const { leakPeriod } = this.state; + if (displayOverview) { + return ( + + ); + } + + if (!metric) { + return ; + } + + const hideDrilldown = + isPullRequest(branchLike) && + (metric.key === 'coverage' || metric.key === 'duplicated_lines_density'); + + if (hideDrilldown) { + return ( +
+
+
{translate('component_measures.details_are_not_available')}
+
+
+ ); + } + + return ( + + ); + }; + + render() { + if (this.state.loading) { + return ( +
+ +
+ ); + } + + const { branchLike } = this.props; + const { measures } = this.state; + const { canBrowseAllChildProjects, qualifier } = this.props.component; + const query = parseQuery(this.props.location.query); + const showFullMeasures = hasFullMeasures(branchLike); + const displayOverview = hasBubbleChart(query.metric); + const metric = this.getSelectedMetric(query, displayOverview); + + return ( +
+ + + {measures.length > 0 ? ( +
+ + {({ top }) => ( +
+
+ {!canBrowseAllChildProjects && isPortfolioLike(qualifier) && ( + + + {translate('component_measures.not_all_measures_are_shown')} + + + + )} +
+ +
+
+
+ )} +
+ {this.renderContent(displayOverview, query, metric)} +
+ ) : ( + + )} +
+ ); + } +} + +const AlertContent = styled.div` + display: flex; + align-items: center; +`; + +/* + * This needs to be refactored: the issue + * is that we can't use the usual withComponentContext HOC, because the type + * of `component` isn't the same. It probably used to work because of the lazy loading + */ +const WrappedApp = withRouter(withBranchStatusActions(ComponentMeasuresApp)); + +function AppWithComponentContext() { + const { branchLike, component } = React.useContext(ComponentContext); + + return ; +} + +export default AppWithComponentContext; diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/__tests__/App-test.tsx b/server/sonar-web/src/main/js/apps/component-measures/components/__tests__/App-test.tsx deleted file mode 100644 index 373b501acf9..00000000000 --- a/server/sonar-web/src/main/js/apps/component-measures/components/__tests__/App-test.tsx +++ /dev/null @@ -1,165 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2023 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 { getMeasuresWithPeriod } from '../../../../api/measures'; -import ScreenPositionHelper from '../../../../components/common/ScreenPositionHelper'; -import { Alert } from '../../../../components/ui/Alert'; -import { mockMainBranch, mockPullRequest } from '../../../../helpers/mocks/branch-like'; -import { mockComponent } from '../../../../helpers/mocks/component'; -import { mockIssue, mockLocation, mockRouter } from '../../../../helpers/testMocks'; -import { waitAndUpdate } from '../../../../helpers/testUtils'; -import { ComponentQualifier } from '../../../../types/component'; -import { App } from '../App'; - -jest.mock('../../../../api/metrics', () => ({ - getAllMetrics: jest.fn().mockResolvedValue([ - { - id: '1', - key: 'lines_to_cover', - type: 'INT', - name: 'Lines to Cover', - domain: 'Coverage', - }, - { - id: '2', - key: 'coverage', - type: 'PERCENT', - name: 'Coverage', - domain: 'Coverage', - }, - { - id: '3', - key: 'duplicated_lines_density', - type: 'PERCENT', - name: 'Duplicated Lines (%)', - domain: 'Duplications', - }, - { - id: '4', - key: 'new_bugs', - type: 'INT', - name: 'New Bugs', - domain: 'Reliability', - }, - ]), -})); - -jest.mock('../../../../api/measures', () => ({ - getMeasuresWithPeriod: jest.fn(), -})); - -beforeEach(() => { - (getMeasuresWithPeriod as jest.Mock).mockResolvedValue({ - component: { measures: [{ metric: 'coverage', value: '80.0' }] }, - period: { mode: 'previous_version' }, - }); -}); - -it('should render correctly', async () => { - const wrapper = shallowRender(); - expect(wrapper.find('.spinner')).toHaveLength(1); - await waitAndUpdate(wrapper); - expect(wrapper).toMatchSnapshot(); -}); - -it('should render a measure overview', async () => { - const wrapper = shallowRender({ - location: mockLocation({ pathname: '/component_measures', query: { metric: 'Reliability' } }), - }); - expect(wrapper.find('.spinner')).toHaveLength(1); - await waitAndUpdate(wrapper); - expect(wrapper.find('MeasureOverviewContainer')).toHaveLength(1); -}); - -it('should render a message when there are no measures', async () => { - (getMeasuresWithPeriod as jest.Mock).mockResolvedValue({ - component: { measures: [] }, - period: { mode: 'previous_version' }, - }); - const wrapper = shallowRender(); - await waitAndUpdate(wrapper); - expect(wrapper).toMatchSnapshot(); -}); - -it('should not render drilldown for estimated duplications', async () => { - const wrapper = shallowRender({ branchLike: mockPullRequest({ title: '' }) }); - await waitAndUpdate(wrapper); - expect(wrapper).toMatchSnapshot(); -}); - -it('should refresh branch status if issues are updated', async () => { - const fetchBranchStatus = jest.fn(); - const branchLike = mockPullRequest(); - const wrapper = shallowRender({ branchLike, fetchBranchStatus }); - const instance = wrapper.instance(); - await waitAndUpdate(wrapper); - - instance.handleIssueChange(mockIssue()); - expect(fetchBranchStatus).toHaveBeenCalledWith(branchLike, 'foo'); -}); - -it('should render a warning message when user does not have access to all projects whithin a Portfolio', async () => { - const wrapper = shallowRender({ - component: mockComponent({ - qualifier: ComponentQualifier.Portfolio, - canBrowseAllChildProjects: false, - }), - }); - await waitAndUpdate(wrapper); - expect(wrapper.find(ScreenPositionHelper).dive()).toMatchSnapshot( - 'Measure menu with warning (ScreenPositionHelper)' - ); -}); - -it.each([ - [ComponentQualifier.Portfolio, true, false], - [ComponentQualifier.Project, false, false], - [ComponentQualifier.Portfolio, false, true], -])( - 'should not render a warning message', - async ( - componentQualifier: ComponentQualifier, - canBrowseAllChildProjects: boolean, - alertIsVisible: boolean - ) => { - const wrapper = shallowRender({ - component: mockComponent({ - qualifier: componentQualifier, - canBrowseAllChildProjects, - }), - }); - await waitAndUpdate(wrapper); - expect(wrapper.find(ScreenPositionHelper).dive().find(Alert).exists()).toBe(alertIsVisible); - } -); - -function shallowRender(props: Partial = {}) { - return shallow( - - ); -} diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/__tests__/ComponentMeasuresApp-test.tsx b/server/sonar-web/src/main/js/apps/component-measures/components/__tests__/ComponentMeasuresApp-test.tsx new file mode 100644 index 00000000000..f7751cebc97 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/component-measures/components/__tests__/ComponentMeasuresApp-test.tsx @@ -0,0 +1,165 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { getMeasuresWithPeriod } from '../../../../api/measures'; +import ScreenPositionHelper from '../../../../components/common/ScreenPositionHelper'; +import { Alert } from '../../../../components/ui/Alert'; +import { mockMainBranch, mockPullRequest } from '../../../../helpers/mocks/branch-like'; +import { mockComponent } from '../../../../helpers/mocks/component'; +import { mockIssue, mockLocation, mockRouter } from '../../../../helpers/testMocks'; +import { waitAndUpdate } from '../../../../helpers/testUtils'; +import { ComponentQualifier } from '../../../../types/component'; +import { ComponentMeasuresApp } from '../ComponentMeasuresApp'; + +jest.mock('../../../../api/metrics', () => ({ + getAllMetrics: jest.fn().mockResolvedValue([ + { + id: '1', + key: 'lines_to_cover', + type: 'INT', + name: 'Lines to Cover', + domain: 'Coverage', + }, + { + id: '2', + key: 'coverage', + type: 'PERCENT', + name: 'Coverage', + domain: 'Coverage', + }, + { + id: '3', + key: 'duplicated_lines_density', + type: 'PERCENT', + name: 'Duplicated Lines (%)', + domain: 'Duplications', + }, + { + id: '4', + key: 'new_bugs', + type: 'INT', + name: 'New Bugs', + domain: 'Reliability', + }, + ]), +})); + +jest.mock('../../../../api/measures', () => ({ + getMeasuresWithPeriod: jest.fn(), +})); + +beforeEach(() => { + (getMeasuresWithPeriod as jest.Mock).mockResolvedValue({ + component: { measures: [{ metric: 'coverage', value: '80.0' }] }, + period: { mode: 'previous_version' }, + }); +}); + +it('should render correctly', async () => { + const wrapper = shallowRender(); + expect(wrapper.find('.spinner')).toHaveLength(1); + await waitAndUpdate(wrapper); + expect(wrapper).toMatchSnapshot(); +}); + +it('should render a measure overview', async () => { + const wrapper = shallowRender({ + location: mockLocation({ pathname: '/component_measures', query: { metric: 'Reliability' } }), + }); + expect(wrapper.find('.spinner')).toHaveLength(1); + await waitAndUpdate(wrapper); + expect(wrapper.find('MeasureOverviewContainer')).toHaveLength(1); +}); + +it('should render a message when there are no measures', async () => { + (getMeasuresWithPeriod as jest.Mock).mockResolvedValue({ + component: { measures: [] }, + period: { mode: 'previous_version' }, + }); + const wrapper = shallowRender(); + await waitAndUpdate(wrapper); + expect(wrapper).toMatchSnapshot(); +}); + +it('should not render drilldown for estimated duplications', async () => { + const wrapper = shallowRender({ branchLike: mockPullRequest({ title: '' }) }); + await waitAndUpdate(wrapper); + expect(wrapper).toMatchSnapshot(); +}); + +it('should refresh branch status if issues are updated', async () => { + const fetchBranchStatus = jest.fn(); + const branchLike = mockPullRequest(); + const wrapper = shallowRender({ branchLike, fetchBranchStatus }); + const instance = wrapper.instance(); + await waitAndUpdate(wrapper); + + instance.handleIssueChange(mockIssue()); + expect(fetchBranchStatus).toHaveBeenCalledWith(branchLike, 'foo'); +}); + +it('should render a warning message when user does not have access to all projects whithin a Portfolio', async () => { + const wrapper = shallowRender({ + component: mockComponent({ + qualifier: ComponentQualifier.Portfolio, + canBrowseAllChildProjects: false, + }), + }); + await waitAndUpdate(wrapper); + expect(wrapper.find(ScreenPositionHelper).dive()).toMatchSnapshot( + 'Measure menu with warning (ScreenPositionHelper)' + ); +}); + +it.each([ + [ComponentQualifier.Portfolio, true, false], + [ComponentQualifier.Project, false, false], + [ComponentQualifier.Portfolio, false, true], +])( + 'should not render a warning message', + async ( + componentQualifier: ComponentQualifier, + canBrowseAllChildProjects: boolean, + alertIsVisible: boolean + ) => { + const wrapper = shallowRender({ + component: mockComponent({ + qualifier: componentQualifier, + canBrowseAllChildProjects, + }), + }); + await waitAndUpdate(wrapper); + expect(wrapper.find(ScreenPositionHelper).dive().find(Alert).exists()).toBe(alertIsVisible); + } +); + +function shallowRender(props: Partial = {}) { + return shallow( + + ); +} diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/__tests__/__snapshots__/App-test.tsx.snap b/server/sonar-web/src/main/js/apps/component-measures/components/__tests__/__snapshots__/App-test.tsx.snap deleted file mode 100644 index 523e596b871..00000000000 --- a/server/sonar-web/src/main/js/apps/component-measures/components/__tests__/__snapshots__/App-test.tsx.snap +++ /dev/null @@ -1,228 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`should not render drilldown for estimated duplications 1`] = ` -
- - -
- - - -
-
-
- component_measures.details_are_not_available -
-
-
-
-
-`; - -exports[`should render a message when there are no measures 1`] = ` -
- - - -
-`; - -exports[`should render a warning message when user does not have access to all projects whithin a Portfolio: Measure menu with warning (ScreenPositionHelper) 1`] = ` -
-
-
- - - component_measures.not_all_measures_are_shown - - - -
- -
-
-
-
-`; - -exports[`should render correctly 1`] = ` -
- - -
- - - - -
-
-`; diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/__tests__/__snapshots__/ComponentMeasuresApp-test.tsx.snap b/server/sonar-web/src/main/js/apps/component-measures/components/__tests__/__snapshots__/ComponentMeasuresApp-test.tsx.snap new file mode 100644 index 00000000000..671b12b6fdf --- /dev/null +++ b/server/sonar-web/src/main/js/apps/component-measures/components/__tests__/__snapshots__/ComponentMeasuresApp-test.tsx.snap @@ -0,0 +1,228 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should not render drilldown for estimated duplications 1`] = ` +
+ + +
+ + + +
+
+
+ component_measures.details_are_not_available +
+
+
+
+
+`; + +exports[`should render a message when there are no measures 1`] = ` +
+ + + +
+`; + +exports[`should render a warning message when user does not have access to all projects whithin a Portfolio: Measure menu with warning (ScreenPositionHelper) 1`] = ` +
+
+
+ + + component_measures.not_all_measures_are_shown + + + +
+ +
+
+
+
+`; + +exports[`should render correctly 1`] = ` +
+ + +
+ + + + +
+
+`; diff --git a/server/sonar-web/src/main/js/apps/component-measures/routes.tsx b/server/sonar-web/src/main/js/apps/component-measures/routes.tsx index edee717bbb5..4e1e0411f4d 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/routes.tsx +++ b/server/sonar-web/src/main/js/apps/component-measures/routes.tsx @@ -22,11 +22,11 @@ import { Navigate, Route, useParams, useSearchParams } from 'react-router-dom'; import NavigateWithParams from '../../app/utils/NavigateWithParams'; import { omitNil } from '../../helpers/request'; import { searchParamsToQuery } from '../../helpers/urls'; -import App from './components/App'; +import ComponentMeasuresApp from './components/ComponentMeasuresApp'; const routes = () => ( - } /> + } /> { id="issues-page" > - + {openIssue ? ( + + ) : ( + + )}

{translate('issues.page')}

diff --git a/server/sonar-web/src/main/js/apps/quality-gates/components/App.tsx b/server/sonar-web/src/main/js/apps/quality-gates/components/App.tsx index 87cb5a6097c..a0ce8d48ab9 100644 --- a/server/sonar-web/src/main/js/apps/quality-gates/components/App.tsx +++ b/server/sonar-web/src/main/js/apps/quality-gates/components/App.tsx @@ -25,7 +25,7 @@ import ScreenPositionHelper from '../../../components/common/ScreenPositionHelpe import Suggestions from '../../../components/embed-docs-modal/Suggestions'; import '../../../components/search-navigator.css'; import DeferredSpinner from '../../../components/ui/DeferredSpinner'; -import { translate } from '../../../helpers/l10n'; +import { translate, translateWithParameters } from '../../../helpers/l10n'; import { addSideBarClass, addWhitePageClass, @@ -113,11 +113,16 @@ class App extends React.PureComponent { render() { const { name } = this.props; const { canCreate, qualityGates } = this.state; - const defaultTitle = translate('quality_gates.page'); return ( <> - +
diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/components/ProfileContainer.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/components/ProfileContainer.tsx index 5588a4d15b9..77ac4b8d845 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/components/ProfileContainer.tsx +++ b/server/sonar-web/src/main/js/apps/quality-profiles/components/ProfileContainer.tsx @@ -21,6 +21,7 @@ import * as React from 'react'; import { Helmet } from 'react-helmet-async'; import { Outlet, useSearchParams } from 'react-router-dom'; import { useLocation } from '../../../components/hoc/withRouter'; +import { translate, translateWithParameters } from '../../../helpers/l10n'; import ProfileHeader from '../details/ProfileHeader'; import { useQualityProfilesContext } from '../qualityProfilesContext'; import ProfileNotFound from './ProfileNotFound'; @@ -58,7 +59,14 @@ export default function ProfileContainer() { return (
- + 1} diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index 9ff58aa9d78..f2b0b5cdebd 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -675,6 +675,8 @@ regulatory_page.select_branch=Select Branch # #------------------------------------------------------------------------------ +page_title.template.default=%s - SonarQube +page_title.template.with_category=%s - {0} - SonarQube overview.page=Overview code.page=Code permissions.page=Permissions @@ -1656,7 +1658,7 @@ project.info.see_more_info_on_x_locs=See more information on your {0} lines of c #------------------------------------------------------------------------------ quality_profiles.page_title_changelog_x={0} - Quality profile changelog -quality_profiles.page_title_compare_x={0} - Quality profile comparaison +quality_profiles.page_title_compare_x={0} - Quality profile comparison quality_profiles.new_profile=New Quality Profile quality_profiles.compare_with=Compare with quality_profiles.filter_by=Filter profiles by