]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-18128 SONAR-18358 SONAR-18368 Page titles do not identify purpose of pages
authorstanislavh <stanislav.honcharov@sonarsource.com>
Mon, 13 Feb 2023 10:44:11 +0000 (11:44 +0100)
committersonartech <sonartech@sonarsource.com>
Mon, 13 Feb 2023 20:02:53 +0000 (20:02 +0000)
22 files changed:
server/sonar-web/src/main/js/app/components/AdminContainer.tsx
server/sonar-web/src/main/js/app/components/__tests__/__snapshots__/AdminContainer-test.tsx.snap
server/sonar-web/src/main/js/app/utils/startReactApp.tsx
server/sonar-web/src/main/js/apps/coding-rules/__tests__/CodingRules-it.ts
server/sonar-web/src/main/js/apps/coding-rules/components/App.tsx [deleted file]
server/sonar-web/src/main/js/apps/coding-rules/components/CodingRulesApp.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/coding-rules/components/__tests__/App-test.tsx [deleted file]
server/sonar-web/src/main/js/apps/coding-rules/components/__tests__/CodingRulesApp-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/coding-rules/components/__tests__/__snapshots__/App-test.tsx.snap [deleted file]
server/sonar-web/src/main/js/apps/coding-rules/components/__tests__/__snapshots__/CodingRulesApp-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/coding-rules/routes.tsx
server/sonar-web/src/main/js/apps/component-measures/components/App.tsx [deleted file]
server/sonar-web/src/main/js/apps/component-measures/components/ComponentMeasuresApp.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/component-measures/components/__tests__/App-test.tsx [deleted file]
server/sonar-web/src/main/js/apps/component-measures/components/__tests__/ComponentMeasuresApp-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/component-measures/components/__tests__/__snapshots__/App-test.tsx.snap [deleted file]
server/sonar-web/src/main/js/apps/component-measures/components/__tests__/__snapshots__/ComponentMeasuresApp-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/component-measures/routes.tsx
server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx
server/sonar-web/src/main/js/apps/quality-gates/components/App.tsx
server/sonar-web/src/main/js/apps/quality-profiles/components/ProfileContainer.tsx
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index bcc5dc09267b00bbe0d7627c91dd4af02484a1dc..82ab8d9764146da605c04f822d8a7cda9b8f712a 100644 (file)
@@ -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<AdminContainerProps, Sta
     }
 
     const { pendingPlugins, systemStatus } = this.state;
-    const defaultTitle = translate('layout.settings');
-
     const adminPagesContext: AdminPagesContext = { adminPages };
 
     return (
       <div>
-        <Helmet defaultTitle={defaultTitle} defer={false} titleTemplate={`%s - ${defaultTitle}`} />
+        <Helmet
+          defer={false}
+          titleTemplate={translateWithParameters(
+            'page_title.template.with_category',
+            translate('layout.settings')
+          )}
+        />
         <SettingsNav
           extensions={adminPages}
           fetchPendingPlugins={this.fetchPendingPlugins}
index 5b721b10020a889ecf8f284fcfb5e8a335787564..6d04b34fa6da1a1300e27208e99b3c9e6515f488 100644 (file)
@@ -3,11 +3,10 @@
 exports[`should render correctly 1`] = `
 <div>
   <Helmet
-    defaultTitle="layout.settings"
     defer={false}
     encodeSpecialCharacters={true}
     prioritizeSeoTags={false}
-    titleTemplate="%s - layout.settings"
+    titleTemplate="page_title.template.with_category.layout.settings"
   />
   <WithLocation
     extensions={[]}
index c34ad1b5e8bc13b419d89527f9894652352a0333..490a7f9043a51e2392688e2fa4142faf6d6d1ca1 100644 (file)
@@ -19,7 +19,7 @@
  */
 import * as React from 'react';
 import { render } from 'react-dom';
-import { HelmetProvider } from 'react-helmet-async';
+import { Helmet, HelmetProvider } from 'react-helmet-async';
 import { IntlProvider } from 'react-intl';
 import { BrowserRouter, Route, Routes } from 'react-router-dom';
 import accountRoutes from '../../apps/account/routes';
@@ -57,6 +57,7 @@ import tutorialsRoutes from '../../apps/tutorials/routes';
 import usersRoutes from '../../apps/users/routes';
 import webAPIRoutes from '../../apps/web-api/routes';
 import webhooksRoutes from '../../apps/webhooks/routes';
+import { translate } from '../../helpers/l10n';
 import { getBaseUrl } from '../../helpers/system';
 import { AppState } from '../../types/appstate';
 import { Feature } from '../../types/features';
@@ -185,6 +186,7 @@ export default function startReactApp(
             <IntlProvider defaultLocale={lang} locale={lang}>
               <GlobalMessagesContainer />
               <BrowserRouter basename={getBaseUrl()}>
+                <Helmet titleTemplate={translate('page_title.template.default')} />
                 <Routes>
                   {renderRedirects()}
 
index 00fe9b0ff433be3397bf98a8af5e9f7815ac31c9..9750dfc676269c397d9f240136c1860141665422 100644 (file)
@@ -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 (file)
index c69234e..0000000
+++ /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<Profile>;
-  referencedRepositories: Dict<{ key: string; language: string; name: string }>;
-  rules: Rule[];
-}
-
-export class App extends React.PureComponent<Props, State> {
-  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<HTMLAnchorElement>) => {
-    const usingPermalink = hasRuleKey(this.props.location.query);
-
-    if (event) {
-      event.preventDefault();
-      event.currentTarget.blur();
-    }
-
-    if (usingPermalink) {
-      this.handleReset();
-    } else {
-      this.closeRule();
-    }
-  };
-
-  handleFilterChange = (changes: Partial<Query>) => {
-    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 <div />;
-    }
-
-    return (
-      paging && (
-        <BulkChange query={query} referencedProfiles={referencedProfiles} total={paging.total} />
-      )
-    );
-  };
-
-  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 (
-      <>
-        <Suggestions suggestions="coding_rules" />
-        <Helmet
-          defer={false}
-          title={
-            openRule
-              ? translateWithParameters('coding_rule.page', openRule.langName, openRule.name)
-              : translate('coding_rules.page')
-          }
-        >
-          <meta content="noindex" name="robots" />
-        </Helmet>
-        <div className="layout-page" id="coding-rules-page">
-          <ScreenPositionHelper className="layout-page-side-outer">
-            {({ top }) => (
-              <section
-                aria-label={translate('filters')}
-                className="layout-page-side"
-                style={{ top }}
-              >
-                <div className="layout-page-side-inner">
-                  <div className="layout-page-filters">
-                    <A11ySkipTarget
-                      anchor="rules_filters"
-                      label={translate('coding_rules.skip_to_filters')}
-                      weight={10}
-                    />
-                    <FiltersHeader displayReset={this.isFiltered()} onReset={this.handleReset} />
-                    <SearchBox
-                      className="spacer-bottom"
-                      id="coding-rules-search"
-                      maxLength={MAX_SEARCH_LENGTH}
-                      minLength={2}
-                      onChange={this.handleSearch}
-                      placeholder={translate('search.search_for_rules')}
-                      value={query.searchQuery || ''}
-                    />
-                    <FacetsList
-                      facets={this.state.facets}
-                      onFacetToggle={this.handleFacetToggle}
-                      onFilterChange={this.handleFilterChange}
-                      openFacets={this.state.openFacets}
-                      query={query}
-                      referencedProfiles={this.state.referencedProfiles}
-                      referencedRepositories={this.state.referencedRepositories}
-                      selectedProfile={this.getSelectedProfile()}
-                    />
-                  </div>
-                </div>
-              </section>
-            )}
-          </ScreenPositionHelper>
-
-          <main className="layout-page-main">
-            <div className="layout-page-header-panel layout-page-main-header">
-              <div className="layout-page-header-panel-inner layout-page-main-header-inner">
-                <div className="layout-page-main-inner">
-                  <A11ySkipTarget anchor="rules_main" />
-                  <div className="display-flex-space-between">
-                    {openRule ? (
-                      <a
-                        className="js-back display-inline-flex-center link-no-underline"
-                        href="#"
-                        onClick={this.handleBack}
-                      >
-                        <BackIcon className="spacer-right" />
-                        {usingPermalink
-                          ? translate('coding_rules.see_all')
-                          : translate('coding_rules.return_to_list')}
-                      </a>
-                    ) : (
-                      this.renderBulkButton()
-                    )}
-                    {!usingPermalink && (
-                      <PageActions paging={paging} selectedIndex={selectedIndex} />
-                    )}
-                  </div>
-                </div>
-              </div>
-            </div>
-
-            <div className="layout-page-main-inner">
-              {openRule ? (
-                <RuleDetails
-                  allowCustomRules={true}
-                  canWrite={this.state.canWrite}
-                  onActivate={this.handleRuleActivate}
-                  onDeactivate={this.handleRuleDeactivate}
-                  onDelete={this.handleRuleDelete}
-                  onFilterChange={this.handleFilterChange}
-                  referencedProfiles={this.state.referencedProfiles}
-                  referencedRepositories={this.state.referencedRepositories}
-                  ruleKey={openRule.key}
-                  selectedProfile={this.getSelectedProfile()}
-                />
-              ) : (
-                <>
-                  <h2 className="a11y-hidden">{translate('list_of_rules')}</h2>
-                  <ul>
-                    {rules.map((rule) => (
-                      <RuleListItem
-                        activation={this.getRuleActivation(rule.key)}
-                        isLoggedIn={isLoggedIn(this.props.currentUser)}
-                        key={rule.key}
-                        onActivate={this.handleRuleActivate}
-                        onDeactivate={this.handleRuleDeactivate}
-                        onFilterChange={this.handleFilterChange}
-                        onOpen={this.handleRuleOpen}
-                        rule={rule}
-                        selected={rule.key === selected}
-                        selectedProfile={this.getSelectedProfile()}
-                      />
-                    ))}
-                  </ul>
-                  {paging !== undefined && (
-                    <ListFooter
-                      count={rules.length}
-                      loadMore={this.fetchMoreRules}
-                      ready={!this.state.loading}
-                      total={paging.total}
-                    />
-                  )}
-                </>
-              )}
-            </div>
-          </main>
-        </div>
-      </>
-    );
-  }
-}
-
-function parseActives(rawActives: Dict<RuleActivation[]>) {
-  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<number> = {};
-    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 (file)
index 0000000..79dcc48
--- /dev/null
@@ -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<Profile>;
+  referencedRepositories: Dict<{ key: string; language: string; name: string }>;
+  rules: Rule[];
+}
+
+export class CodingRulesApp extends React.PureComponent<Props, State> {
+  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<HTMLAnchorElement>) => {
+    const usingPermalink = hasRuleKey(this.props.location.query);
+
+    if (event) {
+      event.preventDefault();
+      event.currentTarget.blur();
+    }
+
+    if (usingPermalink) {
+      this.handleReset();
+    } else {
+      this.closeRule();
+    }
+  };
+
+  handleFilterChange = (changes: Partial<Query>) => {
+    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 <div />;
+    }
+
+    return (
+      paging && (
+        <BulkChange query={query} referencedProfiles={referencedProfiles} total={paging.total} />
+      )
+    );
+  };
+
+  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 (
+      <>
+        <Suggestions suggestions="coding_rules" />
+        {openRule ? (
+          <Helmet
+            defer={false}
+            title={translateWithParameters('coding_rule.page', openRule.langName, openRule.name)}
+            titleTemplate={translateWithParameters(
+              'page_title.template.with_category',
+              translate('coding_rules.page')
+            )}
+          />
+        ) : (
+          <Helmet defer={false} title={translate('coding_rules.page')}>
+            <meta content="noindex" name="robots" />
+          </Helmet>
+        )}
+        <div className="layout-page" id="coding-rules-page">
+          <ScreenPositionHelper className="layout-page-side-outer">
+            {({ top }) => (
+              <section
+                aria-label={translate('filters')}
+                className="layout-page-side"
+                style={{ top }}
+              >
+                <div className="layout-page-side-inner">
+                  <div className="layout-page-filters">
+                    <A11ySkipTarget
+                      anchor="rules_filters"
+                      label={translate('coding_rules.skip_to_filters')}
+                      weight={10}
+                    />
+                    <FiltersHeader displayReset={this.isFiltered()} onReset={this.handleReset} />
+                    <SearchBox
+                      className="spacer-bottom"
+                      id="coding-rules-search"
+                      maxLength={MAX_SEARCH_LENGTH}
+                      minLength={2}
+                      onChange={this.handleSearch}
+                      placeholder={translate('search.search_for_rules')}
+                      value={query.searchQuery || ''}
+                    />
+                    <FacetsList
+                      facets={this.state.facets}
+                      onFacetToggle={this.handleFacetToggle}
+                      onFilterChange={this.handleFilterChange}
+                      openFacets={this.state.openFacets}
+                      query={query}
+                      referencedProfiles={this.state.referencedProfiles}
+                      referencedRepositories={this.state.referencedRepositories}
+                      selectedProfile={this.getSelectedProfile()}
+                    />
+                  </div>
+                </div>
+              </section>
+            )}
+          </ScreenPositionHelper>
+
+          <main className="layout-page-main">
+            <div className="layout-page-header-panel layout-page-main-header">
+              <div className="layout-page-header-panel-inner layout-page-main-header-inner">
+                <div className="layout-page-main-inner">
+                  <A11ySkipTarget anchor="rules_main" />
+                  <div className="display-flex-space-between">
+                    {openRule ? (
+                      <a
+                        className="js-back display-inline-flex-center link-no-underline"
+                        href="#"
+                        onClick={this.handleBack}
+                      >
+                        <BackIcon className="spacer-right" />
+                        {usingPermalink
+                          ? translate('coding_rules.see_all')
+                          : translate('coding_rules.return_to_list')}
+                      </a>
+                    ) : (
+                      this.renderBulkButton()
+                    )}
+                    {!usingPermalink && (
+                      <PageActions paging={paging} selectedIndex={selectedIndex} />
+                    )}
+                  </div>
+                </div>
+              </div>
+            </div>
+
+            <div className="layout-page-main-inner">
+              {openRule ? (
+                <RuleDetails
+                  allowCustomRules={true}
+                  canWrite={this.state.canWrite}
+                  onActivate={this.handleRuleActivate}
+                  onDeactivate={this.handleRuleDeactivate}
+                  onDelete={this.handleRuleDelete}
+                  onFilterChange={this.handleFilterChange}
+                  referencedProfiles={this.state.referencedProfiles}
+                  referencedRepositories={this.state.referencedRepositories}
+                  ruleKey={openRule.key}
+                  selectedProfile={this.getSelectedProfile()}
+                />
+              ) : (
+                <>
+                  <h2 className="a11y-hidden">{translate('list_of_rules')}</h2>
+                  <ul>
+                    {rules.map((rule) => (
+                      <RuleListItem
+                        activation={this.getRuleActivation(rule.key)}
+                        isLoggedIn={isLoggedIn(this.props.currentUser)}
+                        key={rule.key}
+                        onActivate={this.handleRuleActivate}
+                        onDeactivate={this.handleRuleDeactivate}
+                        onFilterChange={this.handleFilterChange}
+                        onOpen={this.handleRuleOpen}
+                        rule={rule}
+                        selected={rule.key === selected}
+                        selectedProfile={this.getSelectedProfile()}
+                      />
+                    ))}
+                  </ul>
+                  {paging !== undefined && (
+                    <ListFooter
+                      count={rules.length}
+                      loadMore={this.fetchMoreRules}
+                      ready={!this.state.loading}
+                      total={paging.total}
+                    />
+                  )}
+                </>
+              )}
+            </div>
+          </main>
+        </div>
+      </>
+    );
+  }
+}
+
+function parseActives(rawActives: Dict<RuleActivation[]>) {
+  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<number> = {};
+    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 (file)
index aa18b5c..0000000
+++ /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<App['props']> = {}) {
-  return shallow<App>(
-    <App
-      currentUser={mockCurrentUser({
-        isLoggedIn: true,
-      })}
-      location={mockLocation()}
-      router={mockRouter()}
-      {...props}
-    />
-  );
-}
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 (file)
index 0000000..09ccc34
--- /dev/null
@@ -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<CodingRulesApp['props']> = {}) {
+  return shallow<CodingRulesApp>(
+    <CodingRulesApp
+      currentUser={mockCurrentUser({
+        isLoggedIn: true,
+      })}
+      location={mockLocation()}
+      router={mockRouter()}
+      {...props}
+    />
+  );
+}
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 (file)
index 08da991..0000000
+++ /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`] = `<div />`;
-
-exports[`renderBulkButton should be null when the user is not logged in 1`] = `<div />`;
-
-exports[`renderBulkButton should show bulk change button when user has edit rights on specific quality profile 1`] = `
-<BulkChange
-  query={
-    {
-      "activation": undefined,
-      "activationSeverities": [],
-      "availableSince": undefined,
-      "compareToProfile": undefined,
-      "cwe": [],
-      "inheritance": undefined,
-      "languages": [],
-      "owaspTop10": [],
-      "owaspTop10-2021": [],
-      "profile": undefined,
-      "repositories": [],
-      "ruleKey": undefined,
-      "sansTop25": [],
-      "searchQuery": undefined,
-      "severities": [],
-      "sonarsourceSecurity": [],
-      "statuses": [],
-      "tags": [],
-      "template": undefined,
-      "types": [],
-    }
-  }
-  referencedProfiles={
-    {
-      "foo": {
-        "actions": {
-          "edit": true,
-        },
-        "activeDeprecatedRuleCount": 2,
-        "activeRuleCount": 10,
-        "childrenCount": 0,
-        "depth": 1,
-        "isBuiltIn": false,
-        "isDefault": false,
-        "isInherited": false,
-        "key": "foo",
-        "language": "js",
-        "languageName": "JavaScript",
-        "name": "name",
-        "projectCount": 3,
-      },
-      "key": {
-        "activeDeprecatedRuleCount": 2,
-        "activeRuleCount": 10,
-        "childrenCount": 0,
-        "depth": 1,
-        "isBuiltIn": false,
-        "isDefault": false,
-        "isInherited": false,
-        "key": "key",
-        "language": "js",
-        "languageName": "JavaScript",
-        "name": "name",
-        "projectCount": 3,
-      },
-    }
-  }
-  total={0}
-/>
-`;
-
-exports[`renderBulkButton should show bulk change button when user has global admin rights on quality profiles 1`] = `
-<BulkChange
-  query={
-    {
-      "activation": undefined,
-      "activationSeverities": [],
-      "availableSince": undefined,
-      "compareToProfile": undefined,
-      "cwe": [],
-      "inheritance": undefined,
-      "languages": [],
-      "owaspTop10": [],
-      "owaspTop10-2021": [],
-      "profile": undefined,
-      "repositories": [],
-      "ruleKey": undefined,
-      "sansTop25": [],
-      "searchQuery": undefined,
-      "severities": [],
-      "sonarsourceSecurity": [],
-      "statuses": [],
-      "tags": [],
-      "template": undefined,
-      "types": [],
-    }
-  }
-  referencedProfiles={{}}
-  total={0}
-/>
-`;
-
-exports[`should render correctly: loaded (ScreenPositionHelper) 1`] = `
-<section
-  aria-label="filters"
-  className="layout-page-side"
-  style={
-    {
-      "top": 0,
-    }
-  }
->
-  <div
-    className="layout-page-side-inner"
-  >
-    <div
-      className="layout-page-filters"
-    >
-      <A11ySkipTarget
-        anchor="rules_filters"
-        label="coding_rules.skip_to_filters"
-        weight={10}
-      />
-      <FiltersHeader
-        displayReset={false}
-        onReset={[Function]}
-      />
-      <SearchBox
-        className="spacer-bottom"
-        id="coding-rules-search"
-        maxLength={200}
-        minLength={2}
-        onChange={[Function]}
-        placeholder="search.search_for_rules"
-        value=""
-      />
-      <FacetsList
-        facets={{}}
-        onFacetToggle={[Function]}
-        onFilterChange={[Function]}
-        openFacets={
-          {
-            "languages": true,
-            "owaspTop10": false,
-            "owaspTop10-2021": false,
-            "sansTop25": false,
-            "sonarsourceSecurity": false,
-            "standards": false,
-            "types": true,
-          }
-        }
-        query={
-          {
-            "activation": undefined,
-            "activationSeverities": [],
-            "availableSince": undefined,
-            "compareToProfile": undefined,
-            "cwe": [],
-            "inheritance": undefined,
-            "languages": [],
-            "owaspTop10": [],
-            "owaspTop10-2021": [],
-            "profile": undefined,
-            "repositories": [],
-            "ruleKey": undefined,
-            "sansTop25": [],
-            "searchQuery": undefined,
-            "severities": [],
-            "sonarsourceSecurity": [],
-            "statuses": [],
-            "tags": [],
-            "template": undefined,
-            "types": [],
-          }
-        }
-        referencedProfiles={{}}
-        referencedRepositories={{}}
-      />
-    </div>
-  </div>
-</section>
-`;
-
-exports[`should render correctly: loaded 1`] = `
-<Fragment>
-  <Suggestions
-    suggestions="coding_rules"
-  />
-  <Helmet
-    defer={false}
-    encodeSpecialCharacters={true}
-    prioritizeSeoTags={false}
-    title="coding_rules.page"
-  >
-    <meta
-      content="noindex"
-      name="robots"
-    />
-  </Helmet>
-  <div
-    className="layout-page"
-    id="coding-rules-page"
-  >
-    <ScreenPositionHelper
-      className="layout-page-side-outer"
-    >
-      <Component />
-    </ScreenPositionHelper>
-    <main
-      className="layout-page-main"
-    >
-      <div
-        className="layout-page-header-panel layout-page-main-header"
-      >
-        <div
-          className="layout-page-header-panel-inner layout-page-main-header-inner"
-        >
-          <div
-            className="layout-page-main-inner"
-          >
-            <A11ySkipTarget
-              anchor="rules_main"
-            />
-            <div
-              className="display-flex-space-between"
-            >
-              <BulkChange
-                query={
-                  {
-                    "activation": undefined,
-                    "activationSeverities": [],
-                    "availableSince": undefined,
-                    "compareToProfile": undefined,
-                    "cwe": [],
-                    "inheritance": undefined,
-                    "languages": [],
-                    "owaspTop10": [],
-                    "owaspTop10-2021": [],
-                    "profile": undefined,
-                    "repositories": [],
-                    "ruleKey": undefined,
-                    "sansTop25": [],
-                    "searchQuery": undefined,
-                    "severities": [],
-                    "sonarsourceSecurity": [],
-                    "statuses": [],
-                    "tags": [],
-                    "template": undefined,
-                    "types": [],
-                  }
-                }
-                referencedProfiles={{}}
-                total={0}
-              />
-              <PageActions
-                paging={
-                  {
-                    "pageIndex": 0,
-                    "pageSize": 100,
-                    "total": 0,
-                  }
-                }
-              />
-            </div>
-          </div>
-        </div>
-      </div>
-      <div
-        className="layout-page-main-inner"
-      >
-        <h2
-          className="a11y-hidden"
-        >
-          list_of_rules
-        </h2>
-        <ul>
-          <RuleListItem
-            isLoggedIn={true}
-            key="javascript:S1067"
-            onActivate={[Function]}
-            onDeactivate={[Function]}
-            onFilterChange={[Function]}
-            onOpen={[Function]}
-            rule={
-              {
-                "key": "javascript:S1067",
-                "lang": "js",
-                "langName": "JavaScript",
-                "name": "Use foo",
-                "severity": "MAJOR",
-                "status": "READY",
-                "sysTags": [
-                  "a",
-                  "b",
-                ],
-                "tags": [
-                  "x",
-                ],
-                "type": "CODE_SMELL",
-              }
-            }
-            selected={false}
-          />
-          <RuleListItem
-            isLoggedIn={true}
-            key="javascript:S1067"
-            onActivate={[Function]}
-            onDeactivate={[Function]}
-            onFilterChange={[Function]}
-            onOpen={[Function]}
-            rule={
-              {
-                "key": "javascript:S1067",
-                "lang": "js",
-                "langName": "JavaScript",
-                "name": "Use foo",
-                "severity": "MAJOR",
-                "status": "READY",
-                "sysTags": [
-                  "a",
-                  "b",
-                ],
-                "tags": [
-                  "x",
-                ],
-                "type": "CODE_SMELL",
-              }
-            }
-            selected={false}
-          />
-        </ul>
-        <ListFooter
-          count={2}
-          loadMore={[Function]}
-          ready={true}
-          total={0}
-        />
-      </div>
-    </main>
-  </div>
-</Fragment>
-`;
-
-exports[`should render correctly: loading 1`] = `
-<Fragment>
-  <Suggestions
-    suggestions="coding_rules"
-  />
-  <Helmet
-    defer={false}
-    encodeSpecialCharacters={true}
-    prioritizeSeoTags={false}
-    title="coding_rules.page"
-  >
-    <meta
-      content="noindex"
-      name="robots"
-    />
-  </Helmet>
-  <div
-    className="layout-page"
-    id="coding-rules-page"
-  >
-    <ScreenPositionHelper
-      className="layout-page-side-outer"
-    >
-      <Component />
-    </ScreenPositionHelper>
-    <main
-      className="layout-page-main"
-    >
-      <div
-        className="layout-page-header-panel layout-page-main-header"
-      >
-        <div
-          className="layout-page-header-panel-inner layout-page-main-header-inner"
-        >
-          <div
-            className="layout-page-main-inner"
-          >
-            <A11ySkipTarget
-              anchor="rules_main"
-            />
-            <div
-              className="display-flex-space-between"
-            >
-              <div />
-              <PageActions />
-            </div>
-          </div>
-        </div>
-      </div>
-      <div
-        className="layout-page-main-inner"
-      >
-        <h2
-          className="a11y-hidden"
-        >
-          list_of_rules
-        </h2>
-        <ul />
-      </div>
-    </main>
-  </div>
-</Fragment>
-`;
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 (file)
index 0000000..08da991
--- /dev/null
@@ -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`] = `<div />`;
+
+exports[`renderBulkButton should be null when the user is not logged in 1`] = `<div />`;
+
+exports[`renderBulkButton should show bulk change button when user has edit rights on specific quality profile 1`] = `
+<BulkChange
+  query={
+    {
+      "activation": undefined,
+      "activationSeverities": [],
+      "availableSince": undefined,
+      "compareToProfile": undefined,
+      "cwe": [],
+      "inheritance": undefined,
+      "languages": [],
+      "owaspTop10": [],
+      "owaspTop10-2021": [],
+      "profile": undefined,
+      "repositories": [],
+      "ruleKey": undefined,
+      "sansTop25": [],
+      "searchQuery": undefined,
+      "severities": [],
+      "sonarsourceSecurity": [],
+      "statuses": [],
+      "tags": [],
+      "template": undefined,
+      "types": [],
+    }
+  }
+  referencedProfiles={
+    {
+      "foo": {
+        "actions": {
+          "edit": true,
+        },
+        "activeDeprecatedRuleCount": 2,
+        "activeRuleCount": 10,
+        "childrenCount": 0,
+        "depth": 1,
+        "isBuiltIn": false,
+        "isDefault": false,
+        "isInherited": false,
+        "key": "foo",
+        "language": "js",
+        "languageName": "JavaScript",
+        "name": "name",
+        "projectCount": 3,
+      },
+      "key": {
+        "activeDeprecatedRuleCount": 2,
+        "activeRuleCount": 10,
+        "childrenCount": 0,
+        "depth": 1,
+        "isBuiltIn": false,
+        "isDefault": false,
+        "isInherited": false,
+        "key": "key",
+        "language": "js",
+        "languageName": "JavaScript",
+        "name": "name",
+        "projectCount": 3,
+      },
+    }
+  }
+  total={0}
+/>
+`;
+
+exports[`renderBulkButton should show bulk change button when user has global admin rights on quality profiles 1`] = `
+<BulkChange
+  query={
+    {
+      "activation": undefined,
+      "activationSeverities": [],
+      "availableSince": undefined,
+      "compareToProfile": undefined,
+      "cwe": [],
+      "inheritance": undefined,
+      "languages": [],
+      "owaspTop10": [],
+      "owaspTop10-2021": [],
+      "profile": undefined,
+      "repositories": [],
+      "ruleKey": undefined,
+      "sansTop25": [],
+      "searchQuery": undefined,
+      "severities": [],
+      "sonarsourceSecurity": [],
+      "statuses": [],
+      "tags": [],
+      "template": undefined,
+      "types": [],
+    }
+  }
+  referencedProfiles={{}}
+  total={0}
+/>
+`;
+
+exports[`should render correctly: loaded (ScreenPositionHelper) 1`] = `
+<section
+  aria-label="filters"
+  className="layout-page-side"
+  style={
+    {
+      "top": 0,
+    }
+  }
+>
+  <div
+    className="layout-page-side-inner"
+  >
+    <div
+      className="layout-page-filters"
+    >
+      <A11ySkipTarget
+        anchor="rules_filters"
+        label="coding_rules.skip_to_filters"
+        weight={10}
+      />
+      <FiltersHeader
+        displayReset={false}
+        onReset={[Function]}
+      />
+      <SearchBox
+        className="spacer-bottom"
+        id="coding-rules-search"
+        maxLength={200}
+        minLength={2}
+        onChange={[Function]}
+        placeholder="search.search_for_rules"
+        value=""
+      />
+      <FacetsList
+        facets={{}}
+        onFacetToggle={[Function]}
+        onFilterChange={[Function]}
+        openFacets={
+          {
+            "languages": true,
+            "owaspTop10": false,
+            "owaspTop10-2021": false,
+            "sansTop25": false,
+            "sonarsourceSecurity": false,
+            "standards": false,
+            "types": true,
+          }
+        }
+        query={
+          {
+            "activation": undefined,
+            "activationSeverities": [],
+            "availableSince": undefined,
+            "compareToProfile": undefined,
+            "cwe": [],
+            "inheritance": undefined,
+            "languages": [],
+            "owaspTop10": [],
+            "owaspTop10-2021": [],
+            "profile": undefined,
+            "repositories": [],
+            "ruleKey": undefined,
+            "sansTop25": [],
+            "searchQuery": undefined,
+            "severities": [],
+            "sonarsourceSecurity": [],
+            "statuses": [],
+            "tags": [],
+            "template": undefined,
+            "types": [],
+          }
+        }
+        referencedProfiles={{}}
+        referencedRepositories={{}}
+      />
+    </div>
+  </div>
+</section>
+`;
+
+exports[`should render correctly: loaded 1`] = `
+<Fragment>
+  <Suggestions
+    suggestions="coding_rules"
+  />
+  <Helmet
+    defer={false}
+    encodeSpecialCharacters={true}
+    prioritizeSeoTags={false}
+    title="coding_rules.page"
+  >
+    <meta
+      content="noindex"
+      name="robots"
+    />
+  </Helmet>
+  <div
+    className="layout-page"
+    id="coding-rules-page"
+  >
+    <ScreenPositionHelper
+      className="layout-page-side-outer"
+    >
+      <Component />
+    </ScreenPositionHelper>
+    <main
+      className="layout-page-main"
+    >
+      <div
+        className="layout-page-header-panel layout-page-main-header"
+      >
+        <div
+          className="layout-page-header-panel-inner layout-page-main-header-inner"
+        >
+          <div
+            className="layout-page-main-inner"
+          >
+            <A11ySkipTarget
+              anchor="rules_main"
+            />
+            <div
+              className="display-flex-space-between"
+            >
+              <BulkChange
+                query={
+                  {
+                    "activation": undefined,
+                    "activationSeverities": [],
+                    "availableSince": undefined,
+                    "compareToProfile": undefined,
+                    "cwe": [],
+                    "inheritance": undefined,
+                    "languages": [],
+                    "owaspTop10": [],
+                    "owaspTop10-2021": [],
+                    "profile": undefined,
+                    "repositories": [],
+                    "ruleKey": undefined,
+                    "sansTop25": [],
+                    "searchQuery": undefined,
+                    "severities": [],
+                    "sonarsourceSecurity": [],
+                    "statuses": [],
+                    "tags": [],
+                    "template": undefined,
+                    "types": [],
+                  }
+                }
+                referencedProfiles={{}}
+                total={0}
+              />
+              <PageActions
+                paging={
+                  {
+                    "pageIndex": 0,
+                    "pageSize": 100,
+                    "total": 0,
+                  }
+                }
+              />
+            </div>
+          </div>
+        </div>
+      </div>
+      <div
+        className="layout-page-main-inner"
+      >
+        <h2
+          className="a11y-hidden"
+        >
+          list_of_rules
+        </h2>
+        <ul>
+          <RuleListItem
+            isLoggedIn={true}
+            key="javascript:S1067"
+            onActivate={[Function]}
+            onDeactivate={[Function]}
+            onFilterChange={[Function]}
+            onOpen={[Function]}
+            rule={
+              {
+                "key": "javascript:S1067",
+                "lang": "js",
+                "langName": "JavaScript",
+                "name": "Use foo",
+                "severity": "MAJOR",
+                "status": "READY",
+                "sysTags": [
+                  "a",
+                  "b",
+                ],
+                "tags": [
+                  "x",
+                ],
+                "type": "CODE_SMELL",
+              }
+            }
+            selected={false}
+          />
+          <RuleListItem
+            isLoggedIn={true}
+            key="javascript:S1067"
+            onActivate={[Function]}
+            onDeactivate={[Function]}
+            onFilterChange={[Function]}
+            onOpen={[Function]}
+            rule={
+              {
+                "key": "javascript:S1067",
+                "lang": "js",
+                "langName": "JavaScript",
+                "name": "Use foo",
+                "severity": "MAJOR",
+                "status": "READY",
+                "sysTags": [
+                  "a",
+                  "b",
+                ],
+                "tags": [
+                  "x",
+                ],
+                "type": "CODE_SMELL",
+              }
+            }
+            selected={false}
+          />
+        </ul>
+        <ListFooter
+          count={2}
+          loadMore={[Function]}
+          ready={true}
+          total={0}
+        />
+      </div>
+    </main>
+  </div>
+</Fragment>
+`;
+
+exports[`should render correctly: loading 1`] = `
+<Fragment>
+  <Suggestions
+    suggestions="coding_rules"
+  />
+  <Helmet
+    defer={false}
+    encodeSpecialCharacters={true}
+    prioritizeSeoTags={false}
+    title="coding_rules.page"
+  >
+    <meta
+      content="noindex"
+      name="robots"
+    />
+  </Helmet>
+  <div
+    className="layout-page"
+    id="coding-rules-page"
+  >
+    <ScreenPositionHelper
+      className="layout-page-side-outer"
+    >
+      <Component />
+    </ScreenPositionHelper>
+    <main
+      className="layout-page-main"
+    >
+      <div
+        className="layout-page-header-panel layout-page-main-header"
+      >
+        <div
+          className="layout-page-header-panel-inner layout-page-main-header-inner"
+        >
+          <div
+            className="layout-page-main-inner"
+          >
+            <A11ySkipTarget
+              anchor="rules_main"
+            />
+            <div
+              className="display-flex-space-between"
+            >
+              <div />
+              <PageActions />
+            </div>
+          </div>
+        </div>
+      </div>
+      <div
+        className="layout-page-main-inner"
+      >
+        <h2
+          className="a11y-hidden"
+        >
+          list_of_rules
+        </h2>
+        <ul />
+      </div>
+    </main>
+  </div>
+</Fragment>
+`;
index 2e9bb7a9355c17e15001e1b7aece56bc86806f7b..5ba7f224b13afd162e5bae43c59ec8fadd2e2e22 100644 (file)
@@ -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 <App />;
+  return <CodingRulesApp />;
 }
 
 const routes = () => <Route path="coding_rules" element={<HashEditWrapper />} />;
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 (file)
index 922fa2d..0000000
+++ /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<void>;
-  location: Location;
-  router: Router;
-}
-
-interface State {
-  leakPeriod?: Period;
-  loading: boolean;
-  measures: MeasureEnhanced[];
-  metrics: Dict<Metric>;
-}
-
-export class App extends React.PureComponent<Props, State> {
-  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<Query>) => {
-    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 (
-        <MeasureOverviewContainer
-          branchLike={branchLike}
-          className="layout-page-main"
-          domain={query.metric}
-          leakPeriod={leakPeriod}
-          metrics={this.state.metrics}
-          onIssueChange={this.handleIssueChange}
-          rootComponent={component}
-          router={this.props.router}
-          selected={query.selected}
-          updateQuery={this.updateQuery}
-        />
-      );
-    }
-
-    if (!metric) {
-      return <MeasuresEmpty />;
-    }
-
-    const hideDrilldown =
-      isPullRequest(branchLike) &&
-      (metric.key === 'coverage' || metric.key === 'duplicated_lines_density');
-
-    if (hideDrilldown) {
-      return (
-        <main className="layout-page-main">
-          <div className="layout-page-main-inner">
-            <div className="note">{translate('component_measures.details_are_not_available')}</div>
-          </div>
-        </main>
-      );
-    }
-
-    return (
-      <MeasureContent
-        branchLike={branchLike}
-        leakPeriod={leakPeriod}
-        metrics={this.state.metrics}
-        onIssueChange={this.handleIssueChange}
-        requestedMetric={metric}
-        rootComponent={component}
-        router={this.props.router}
-        selected={query.selected}
-        asc={query.asc}
-        updateQuery={this.updateQuery}
-        view={query.view}
-      />
-    );
-  };
-
-  render() {
-    if (this.state.loading) {
-      return (
-        <div className="display-flex-justify-center huge-spacer-top">
-          <i className="spinner" />
-        </div>
-      );
-    }
-
-    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 (
-      <div id="component-measures">
-        <Suggestions suggestions="component_measures" />
-        <Helmet defer={false} title={this.getHelmetTitle(query, displayOverview, metric)} />
-        {measures.length > 0 ? (
-          <div className="layout-page">
-            <ScreenPositionHelper className="layout-page-side-outer">
-              {({ top }) => (
-                <div className="layout-page-side" style={{ top }}>
-                  <div className="layout-page-side-inner">
-                    {!canBrowseAllChildProjects && isPortfolioLike(qualifier) && (
-                      <Alert
-                        className="big-spacer-top big-spacer-right big-spacer-left it__portfolio_warning"
-                        variant="warning"
-                      >
-                        <AlertContent>
-                          {translate('component_measures.not_all_measures_are_shown')}
-                          <HelpTooltip
-                            className="spacer-left"
-                            ariaLabel={translate(
-                              'component_measures.not_all_measures_are_shown.help'
-                            )}
-                            overlay={translate(
-                              'component_measures.not_all_measures_are_shown.help'
-                            )}
-                          />
-                        </AlertContent>
-                      </Alert>
-                    )}
-                    <div className="layout-page-filters">
-                      <Sidebar
-                        measures={measures}
-                        selectedMetric={metric ? metric.key : query.metric}
-                        showFullMeasures={showFullMeasures}
-                        updateQuery={this.updateQuery}
-                      />
-                    </div>
-                  </div>
-                </div>
-              )}
-            </ScreenPositionHelper>
-            {this.renderContent(displayOverview, query, metric)}
-          </div>
-        ) : (
-          <MeasuresEmpty />
-        )}
-      </div>
-    );
-  }
-}
-
-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 <WrappedApp branchLike={branchLike} component={component as ComponentMeasure} />;
-}
-
-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 (file)
index 0000000..b5d7f9d
--- /dev/null
@@ -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<void>;
+  location: Location;
+  router: Router;
+}
+
+interface State {
+  leakPeriod?: Period;
+  loading: boolean;
+  measures: MeasureEnhanced[];
+  metrics: Dict<Metric>;
+}
+
+export class ComponentMeasuresApp extends React.PureComponent<Props, State> {
+  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<Query>) => {
+    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 (
+        <MeasureOverviewContainer
+          branchLike={branchLike}
+          className="layout-page-main"
+          domain={query.metric}
+          leakPeriod={leakPeriod}
+          metrics={this.state.metrics}
+          onIssueChange={this.handleIssueChange}
+          rootComponent={component}
+          router={this.props.router}
+          selected={query.selected}
+          updateQuery={this.updateQuery}
+        />
+      );
+    }
+
+    if (!metric) {
+      return <MeasuresEmpty />;
+    }
+
+    const hideDrilldown =
+      isPullRequest(branchLike) &&
+      (metric.key === 'coverage' || metric.key === 'duplicated_lines_density');
+
+    if (hideDrilldown) {
+      return (
+        <main className="layout-page-main">
+          <div className="layout-page-main-inner">
+            <div className="note">{translate('component_measures.details_are_not_available')}</div>
+          </div>
+        </main>
+      );
+    }
+
+    return (
+      <MeasureContent
+        branchLike={branchLike}
+        leakPeriod={leakPeriod}
+        metrics={this.state.metrics}
+        onIssueChange={this.handleIssueChange}
+        requestedMetric={metric}
+        rootComponent={component}
+        router={this.props.router}
+        selected={query.selected}
+        asc={query.asc}
+        updateQuery={this.updateQuery}
+        view={query.view}
+      />
+    );
+  };
+
+  render() {
+    if (this.state.loading) {
+      return (
+        <div className="display-flex-justify-center huge-spacer-top">
+          <i className="spinner" />
+        </div>
+      );
+    }
+
+    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 (
+      <div id="component-measures">
+        <Suggestions suggestions="component_measures" />
+        <Helmet defer={false} title={translate('layout.measures')} />
+        {measures.length > 0 ? (
+          <div className="layout-page">
+            <ScreenPositionHelper className="layout-page-side-outer">
+              {({ top }) => (
+                <div className="layout-page-side" style={{ top }}>
+                  <div className="layout-page-side-inner">
+                    {!canBrowseAllChildProjects && isPortfolioLike(qualifier) && (
+                      <Alert
+                        className="big-spacer-top big-spacer-right big-spacer-left it__portfolio_warning"
+                        variant="warning"
+                      >
+                        <AlertContent>
+                          {translate('component_measures.not_all_measures_are_shown')}
+                          <HelpTooltip
+                            className="spacer-left"
+                            ariaLabel={translate(
+                              'component_measures.not_all_measures_are_shown.help'
+                            )}
+                            overlay={translate(
+                              'component_measures.not_all_measures_are_shown.help'
+                            )}
+                          />
+                        </AlertContent>
+                      </Alert>
+                    )}
+                    <div className="layout-page-filters">
+                      <Sidebar
+                        measures={measures}
+                        selectedMetric={metric ? metric.key : query.metric}
+                        showFullMeasures={showFullMeasures}
+                        updateQuery={this.updateQuery}
+                      />
+                    </div>
+                  </div>
+                </div>
+              )}
+            </ScreenPositionHelper>
+            {this.renderContent(displayOverview, query, metric)}
+          </div>
+        ) : (
+          <MeasuresEmpty />
+        )}
+      </div>
+    );
+  }
+}
+
+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 <WrappedApp branchLike={branchLike} component={component as ComponentMeasure} />;
+}
+
+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 (file)
index 373b501..0000000
+++ /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<App['props']> = {}) {
-  return shallow<App>(
-    <App
-      branchLike={mockMainBranch()}
-      component={mockComponent({ key: 'foo', name: 'Foo' })}
-      fetchBranchStatus={jest.fn()}
-      location={mockLocation({ pathname: '/component_measures', query: { metric: 'coverage' } })}
-      router={mockRouter()}
-      {...props}
-    />
-  );
-}
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 (file)
index 0000000..f7751ce
--- /dev/null
@@ -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<ComponentMeasuresApp['props']> = {}) {
+  return shallow<ComponentMeasuresApp>(
+    <ComponentMeasuresApp
+      branchLike={mockMainBranch()}
+      component={mockComponent({ key: 'foo', name: 'Foo' })}
+      fetchBranchStatus={jest.fn()}
+      location={mockLocation({ pathname: '/component_measures', query: { metric: 'coverage' } })}
+      router={mockRouter()}
+      {...props}
+    />
+  );
+}
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 (file)
index 523e596..0000000
+++ /dev/null
@@ -1,228 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`should not render drilldown for estimated duplications 1`] = `
-<div
-  id="component-measures"
->
-  <Suggestions
-    suggestions="component_measures"
-  />
-  <Helmet
-    defer={false}
-    encodeSpecialCharacters={true}
-    prioritizeSeoTags={false}
-    title="Coverage"
-  />
-  <div
-    className="layout-page"
-  >
-    <ScreenPositionHelper
-      className="layout-page-side-outer"
-    >
-      <Component />
-    </ScreenPositionHelper>
-    <main
-      className="layout-page-main"
-    >
-      <div
-        className="layout-page-main-inner"
-      >
-        <div
-          className="note"
-        >
-          component_measures.details_are_not_available
-        </div>
-      </div>
-    </main>
-  </div>
-</div>
-`;
-
-exports[`should render a message when there are no measures 1`] = `
-<div
-  id="component-measures"
->
-  <Suggestions
-    suggestions="component_measures"
-  />
-  <Helmet
-    defer={false}
-    encodeSpecialCharacters={true}
-    prioritizeSeoTags={false}
-    title="Coverage"
-  />
-  <MeasuresEmpty />
-</div>
-`;
-
-exports[`should render a warning message when user does not have access to all projects whithin a Portfolio: Measure menu with warning (ScreenPositionHelper) 1`] = `
-<div
-  className="layout-page-side-outer"
->
-  <div
-    className="layout-page-side"
-    style={
-      {
-        "top": 0,
-      }
-    }
-  >
-    <div
-      className="layout-page-side-inner"
-    >
-      <Alert
-        className="big-spacer-top big-spacer-right big-spacer-left it__portfolio_warning"
-        variant="warning"
-      >
-        <Styled(div)>
-          component_measures.not_all_measures_are_shown
-          <HelpTooltip
-            ariaLabel="component_measures.not_all_measures_are_shown.help"
-            className="spacer-left"
-            overlay="component_measures.not_all_measures_are_shown.help"
-          />
-        </Styled(div)>
-      </Alert>
-      <div
-        className="layout-page-filters"
-      >
-        <Sidebar
-          measures={
-            [
-              {
-                "leak": undefined,
-                "metric": {
-                  "domain": "Coverage",
-                  "id": "2",
-                  "key": "coverage",
-                  "name": "Coverage",
-                  "type": "PERCENT",
-                },
-                "value": "80.0",
-              },
-            ]
-          }
-          selectedMetric="coverage"
-          showFullMeasures={true}
-          updateQuery={[Function]}
-        />
-      </div>
-    </div>
-  </div>
-</div>
-`;
-
-exports[`should render correctly 1`] = `
-<div
-  id="component-measures"
->
-  <Suggestions
-    suggestions="component_measures"
-  />
-  <Helmet
-    defer={false}
-    encodeSpecialCharacters={true}
-    prioritizeSeoTags={false}
-    title="Coverage"
-  />
-  <div
-    className="layout-page"
-  >
-    <ScreenPositionHelper
-      className="layout-page-side-outer"
-    >
-      <Component />
-    </ScreenPositionHelper>
-    <MeasureContent
-      branchLike={
-        {
-          "analysisDate": "2018-01-01",
-          "excludedFromPurge": true,
-          "isMain": true,
-          "name": "master",
-        }
-      }
-      metrics={
-        {
-          "coverage": {
-            "domain": "Coverage",
-            "id": "2",
-            "key": "coverage",
-            "name": "Coverage",
-            "type": "PERCENT",
-          },
-          "duplicated_lines_density": {
-            "domain": "Duplications",
-            "id": "3",
-            "key": "duplicated_lines_density",
-            "name": "Duplicated Lines (%)",
-            "type": "PERCENT",
-          },
-          "lines_to_cover": {
-            "domain": "Coverage",
-            "id": "1",
-            "key": "lines_to_cover",
-            "name": "Lines to Cover",
-            "type": "INT",
-          },
-          "new_bugs": {
-            "domain": "Reliability",
-            "id": "4",
-            "key": "new_bugs",
-            "name": "New Bugs",
-            "type": "INT",
-          },
-        }
-      }
-      onIssueChange={[Function]}
-      requestedMetric={
-        {
-          "domain": "Coverage",
-          "id": "2",
-          "key": "coverage",
-          "name": "Coverage",
-          "type": "PERCENT",
-        }
-      }
-      rootComponent={
-        {
-          "breadcrumbs": [],
-          "key": "foo",
-          "name": "Foo",
-          "qualifier": "TRK",
-          "qualityGate": {
-            "isDefault": true,
-            "key": "30",
-            "name": "Sonar way",
-          },
-          "qualityProfiles": [
-            {
-              "deleted": false,
-              "key": "my-qp",
-              "language": "ts",
-              "name": "Sonar way",
-            },
-          ],
-          "tags": [],
-        }
-      }
-      router={
-        {
-          "createHref": [MockFunction],
-          "createPath": [MockFunction],
-          "go": [MockFunction],
-          "goBack": [MockFunction],
-          "goForward": [MockFunction],
-          "isActive": [MockFunction],
-          "push": [MockFunction],
-          "replace": [MockFunction],
-          "setRouteLeaveHook": [MockFunction],
-        }
-      }
-      selected=""
-      updateQuery={[Function]}
-      view="tree"
-    />
-  </div>
-</div>
-`;
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 (file)
index 0000000..671b12b
--- /dev/null
@@ -0,0 +1,228 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should not render drilldown for estimated duplications 1`] = `
+<div
+  id="component-measures"
+>
+  <Suggestions
+    suggestions="component_measures"
+  />
+  <Helmet
+    defer={false}
+    encodeSpecialCharacters={true}
+    prioritizeSeoTags={false}
+    title="layout.measures"
+  />
+  <div
+    className="layout-page"
+  >
+    <ScreenPositionHelper
+      className="layout-page-side-outer"
+    >
+      <Component />
+    </ScreenPositionHelper>
+    <main
+      className="layout-page-main"
+    >
+      <div
+        className="layout-page-main-inner"
+      >
+        <div
+          className="note"
+        >
+          component_measures.details_are_not_available
+        </div>
+      </div>
+    </main>
+  </div>
+</div>
+`;
+
+exports[`should render a message when there are no measures 1`] = `
+<div
+  id="component-measures"
+>
+  <Suggestions
+    suggestions="component_measures"
+  />
+  <Helmet
+    defer={false}
+    encodeSpecialCharacters={true}
+    prioritizeSeoTags={false}
+    title="layout.measures"
+  />
+  <MeasuresEmpty />
+</div>
+`;
+
+exports[`should render a warning message when user does not have access to all projects whithin a Portfolio: Measure menu with warning (ScreenPositionHelper) 1`] = `
+<div
+  className="layout-page-side-outer"
+>
+  <div
+    className="layout-page-side"
+    style={
+      {
+        "top": 0,
+      }
+    }
+  >
+    <div
+      className="layout-page-side-inner"
+    >
+      <Alert
+        className="big-spacer-top big-spacer-right big-spacer-left it__portfolio_warning"
+        variant="warning"
+      >
+        <Styled(div)>
+          component_measures.not_all_measures_are_shown
+          <HelpTooltip
+            ariaLabel="component_measures.not_all_measures_are_shown.help"
+            className="spacer-left"
+            overlay="component_measures.not_all_measures_are_shown.help"
+          />
+        </Styled(div)>
+      </Alert>
+      <div
+        className="layout-page-filters"
+      >
+        <Sidebar
+          measures={
+            [
+              {
+                "leak": undefined,
+                "metric": {
+                  "domain": "Coverage",
+                  "id": "2",
+                  "key": "coverage",
+                  "name": "Coverage",
+                  "type": "PERCENT",
+                },
+                "value": "80.0",
+              },
+            ]
+          }
+          selectedMetric="coverage"
+          showFullMeasures={true}
+          updateQuery={[Function]}
+        />
+      </div>
+    </div>
+  </div>
+</div>
+`;
+
+exports[`should render correctly 1`] = `
+<div
+  id="component-measures"
+>
+  <Suggestions
+    suggestions="component_measures"
+  />
+  <Helmet
+    defer={false}
+    encodeSpecialCharacters={true}
+    prioritizeSeoTags={false}
+    title="layout.measures"
+  />
+  <div
+    className="layout-page"
+  >
+    <ScreenPositionHelper
+      className="layout-page-side-outer"
+    >
+      <Component />
+    </ScreenPositionHelper>
+    <MeasureContent
+      branchLike={
+        {
+          "analysisDate": "2018-01-01",
+          "excludedFromPurge": true,
+          "isMain": true,
+          "name": "master",
+        }
+      }
+      metrics={
+        {
+          "coverage": {
+            "domain": "Coverage",
+            "id": "2",
+            "key": "coverage",
+            "name": "Coverage",
+            "type": "PERCENT",
+          },
+          "duplicated_lines_density": {
+            "domain": "Duplications",
+            "id": "3",
+            "key": "duplicated_lines_density",
+            "name": "Duplicated Lines (%)",
+            "type": "PERCENT",
+          },
+          "lines_to_cover": {
+            "domain": "Coverage",
+            "id": "1",
+            "key": "lines_to_cover",
+            "name": "Lines to Cover",
+            "type": "INT",
+          },
+          "new_bugs": {
+            "domain": "Reliability",
+            "id": "4",
+            "key": "new_bugs",
+            "name": "New Bugs",
+            "type": "INT",
+          },
+        }
+      }
+      onIssueChange={[Function]}
+      requestedMetric={
+        {
+          "domain": "Coverage",
+          "id": "2",
+          "key": "coverage",
+          "name": "Coverage",
+          "type": "PERCENT",
+        }
+      }
+      rootComponent={
+        {
+          "breadcrumbs": [],
+          "key": "foo",
+          "name": "Foo",
+          "qualifier": "TRK",
+          "qualityGate": {
+            "isDefault": true,
+            "key": "30",
+            "name": "Sonar way",
+          },
+          "qualityProfiles": [
+            {
+              "deleted": false,
+              "key": "my-qp",
+              "language": "ts",
+              "name": "Sonar way",
+            },
+          ],
+          "tags": [],
+        }
+      }
+      router={
+        {
+          "createHref": [MockFunction],
+          "createPath": [MockFunction],
+          "go": [MockFunction],
+          "goBack": [MockFunction],
+          "goForward": [MockFunction],
+          "isActive": [MockFunction],
+          "push": [MockFunction],
+          "replace": [MockFunction],
+          "setRouteLeaveHook": [MockFunction],
+        }
+      }
+      selected=""
+      updateQuery={[Function]}
+      view="tree"
+    />
+  </div>
+</div>
+`;
index edee717bbb52d4252ff14d21f87702a48bba4d9b..4e1e0411f4d7ca9d99f479f50f04fbb361ee9271 100644 (file)
@@ -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 = () => (
   <Route path="component_measures">
-    <Route index={true} element={<App />} />
+    <Route index={true} element={<ComponentMeasuresApp />} />
     <Route
       path="domain/:domainName"
       element={
index 4a3ee0c59803d2088bf7d4feba7761148b5be532..35c82364238ee443efe5783559b6804179d768e5 100644 (file)
@@ -1164,7 +1164,18 @@ export class App extends React.PureComponent<Props, State> {
         id="issues-page"
       >
         <Suggestions suggestions="issues" />
-        <Helmet defer={false} title={openIssue ? openIssue.message : translate('issues.page')} />
+        {openIssue ? (
+          <Helmet
+            defer={false}
+            title={openIssue.message}
+            titleTemplate={translateWithParameters(
+              'page_title.template.with_category',
+              translate('issues.page')
+            )}
+          />
+        ) : (
+          <Helmet defer={false} title={translate('issues.page')} />
+        )}
 
         <h1 className="a11y-hidden">{translate('issues.page')}</h1>
 
index 87cb5a6097c8c0d12e0e0b8c2ec97df1a34ada1a..a0ce8d48ab9b5dedf246e9b95f4c261b198f5079 100644 (file)
@@ -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<Props, State> {
   render() {
     const { name } = this.props;
     const { canCreate, qualityGates } = this.state;
-    const defaultTitle = translate('quality_gates.page');
 
     return (
       <>
-        <Helmet defaultTitle={defaultTitle} defer={false} titleTemplate={`%s - ${defaultTitle}`} />
+        <Helmet
+          defer={false}
+          titleTemplate={translateWithParameters(
+            'page_title.template.with_category',
+            translate('quality_gates.page')
+          )}
+        />
         <div className="layout-page" id="quality-gates-page">
           <Suggestions suggestions="quality_gates" />
 
index 5588a4d15b91cf05cb27f57b41cea5ba551f9339..77ac4b8d8453133361305267693a0d6b48682903 100644 (file)
@@ -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 (
     <div id="quality-profile">
-      <Helmet defer={false} title={profile.name} />
+      <Helmet
+        defer={false}
+        title={profile.name}
+        titleTemplate={translateWithParameters(
+          'page_title.template.with_category',
+          translate('quality_profiles.page')
+        )}
+      />
       <ProfileHeader
         profile={profile}
         isComparable={filteredProfiles.length > 1}
index 9ff58aa9d78f7d82a3b57a3eac253999b175d6e4..f2b0b5cdebd90c5a7b2d7728faa1082c6d412a67 100644 (file)
@@ -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