]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-19391 Measures page sidebar adopts the new UI
authorstanislavh <stanislav.honcharov@sonarsource.com>
Tue, 30 May 2023 10:58:20 +0000 (12:58 +0200)
committersonartech <sonartech@sonarsource.com>
Mon, 5 Jun 2023 20:02:47 +0000 (20:02 +0000)
17 files changed:
server/sonar-web/design-system/src/components/icons/Icon.tsx
server/sonar-web/design-system/src/components/subnavigation/SubnavigationAccordion.tsx
server/sonar-web/design-system/src/components/subnavigation/SubnavigationSubheading.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/subnavigation/__tests__/SubnavigationAccordion-test.tsx
server/sonar-web/design-system/src/components/subnavigation/index.ts
server/sonar-web/src/main/js/apps/component-measures/components/ComponentMeasuresApp.tsx
server/sonar-web/src/main/js/apps/component-measures/sidebar/DomainFacet.tsx [deleted file]
server/sonar-web/src/main/js/apps/component-measures/sidebar/DomainSubnavigation.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/component-measures/sidebar/DomainSubnavigationItem.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/component-measures/sidebar/FacetMeasureValue.tsx [deleted file]
server/sonar-web/src/main/js/apps/component-measures/sidebar/ProjectOverviewFacet.tsx [deleted file]
server/sonar-web/src/main/js/apps/component-measures/sidebar/Sidebar.tsx
server/sonar-web/src/main/js/apps/component-measures/sidebar/SubnavigationMeasureValue.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotListItem.tsx
server/sonar-web/src/main/js/components/measure/Measure.tsx
server/sonar-web/src/main/js/hooks/useFollowScroll.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 3a90211b5257da75e08717f1b90b51971e85a0dd..80e0e64be8850804dd81797b84a0875e4d8eebe1 100644 (file)
@@ -26,9 +26,11 @@ import { themeColor } from '../../helpers/theme';
 import { CSSColor, ThemeColors } from '../../types/theme';
 
 interface Props {
+  'aria-hidden'?: boolean | 'true' | 'false';
   'aria-label'?: string;
   children: React.ReactNode;
   className?: string;
+  description?: React.ReactNode;
 }
 
 export interface IconProps extends Omit<Props, 'children'> {
@@ -38,10 +40,17 @@ export interface IconProps extends Omit<Props, 'children'> {
 }
 
 export function CustomIcon(props: Props) {
-  const { 'aria-label': ariaLabel, children, className, ...iconProps } = props;
+  const {
+    'aria-label': ariaLabel,
+    'aria-hidden': ariaHidden,
+    children,
+    className,
+    description,
+    ...iconProps
+  } = props;
   return (
     <svg
-      aria-hidden={ariaLabel ? 'false' : 'true'}
+      aria-hidden={ariaHidden ?? ariaLabel ? 'false' : 'true'}
       aria-label={ariaLabel}
       className={className}
       fill="none"
@@ -63,6 +72,7 @@ export function CustomIcon(props: Props) {
       xmlnsXlink="http://www.w3.org/1999/xlink"
       {...iconProps}
     >
+      {description && <desc>{description}</desc>}
       {children}
     </svg>
   );
index e38644eea629dcf44ada5e313c498214ed266aa3..01e017f100f4c833050c7922f51580945660085c 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import styled from '@emotion/styled';
-import { ReactNode, useCallback } from 'react';
+import { ReactNode, useCallback, useState } from 'react';
 import tw from 'twin.macro';
 import { themeColor, themeContrast } from '../../helpers/theme';
 import { BareButton } from '../buttons';
 import { OpenCloseIndicator } from '../icons/OpenCloseIndicator';
 import { SubnavigationGroup } from './SubnavigationGroup';
 
-interface Props {
+interface CommonProps {
   children: ReactNode;
   className?: string;
-  expanded?: boolean;
   header: ReactNode;
   id: string;
   onSetExpanded?: (expanded: boolean) => void;
 }
 
+interface ControlledProps extends CommonProps {
+  expanded: boolean | undefined;
+  initExpanded?: never;
+}
+
+interface UncontrolledProps extends CommonProps {
+  expanded?: never;
+  initExpanded?: boolean;
+}
+
+type Props = ControlledProps | UncontrolledProps;
+
 export function SubnavigationAccordion(props: Props) {
-  const { children, className, header, id, expanded = true, onSetExpanded } = props;
+  const { children, className, expanded, header, id, initExpanded, onSetExpanded } = props;
+
+  const [innerExpanded, setInnerExpanded] = useState(initExpanded ?? false);
+  const finalExpanded = expanded ?? innerExpanded;
+
   const toggleExpanded = useCallback(() => {
-    onSetExpanded?.(!expanded);
-  }, [expanded, onSetExpanded]);
+    setInnerExpanded(!finalExpanded);
+    onSetExpanded?.(!finalExpanded);
+  }, [finalExpanded, onSetExpanded]);
 
   return (
     <SubnavigationGroup
@@ -49,14 +65,14 @@ export function SubnavigationAccordion(props: Props) {
     >
       <SubnavigationAccordionItem
         aria-controls={`${id}-subnavigation-accordion`}
-        aria-expanded={expanded}
+        aria-expanded={finalExpanded}
         id={`${id}-subnavigation-accordion-button`}
         onClick={toggleExpanded}
       >
         {header}
-        <OpenCloseIndicator open={Boolean(expanded)} />
+        <OpenCloseIndicator open={finalExpanded} />
       </SubnavigationAccordionItem>
-      {expanded && children}
+      {finalExpanded && children}
     </SubnavigationGroup>
   );
 }
diff --git a/server/sonar-web/design-system/src/components/subnavigation/SubnavigationSubheading.tsx b/server/sonar-web/design-system/src/components/subnavigation/SubnavigationSubheading.tsx
new file mode 100644 (file)
index 0000000..5742475
--- /dev/null
@@ -0,0 +1,34 @@
+/*
+ * 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 tw from 'twin.macro';
+import { themeColor, themeContrast } from '../../helpers/theme';
+
+export const SubnavigationSubheading = styled.div`
+  ${tw`sw-flex`}
+  ${tw`sw-box-border`}
+  ${tw`sw-body-sm`}
+  ${tw`sw-px-4 sw-pt-6 sw-pb-2`}
+  ${tw`sw-w-full`}
+
+  color: ${themeContrast('subnavigationSubheading')};
+  background-color: ${themeColor('subnavigationSubheading')};
+`;
+SubnavigationSubheading.displayName = 'SubnavigationSubheading';
index ebf76171247315ede38bf1f9c80c5e85b9568b71..884fd13db3b0fdf1ceb8caeabb1c08c9c6673ce2 100644 (file)
@@ -31,17 +31,17 @@ it('should have correct style and html structure', () => {
 });
 
 it('should display expanded', () => {
-  setupWithProps({ expanded: true });
+  setupWithProps({ initExpanded: true });
 
   expect(screen.getByRole('button', { expanded: true })).toBeVisible();
   expect(screen.getByText('Foo')).toBeVisible();
 });
 
-it('should display expanded by default', () => {
+it('should display collapsed by default', () => {
   setupWithProps();
 
   expect(screen.getByRole('button')).toBeVisible();
-  expect(screen.getByText('Foo')).toBeVisible();
+  expect(screen.queryByText('Foo')).not.toBeInTheDocument();
 });
 
 it('should toggle expand', async () => {
index 89410cd2e49e55674d12f153d0f14bde37a33e9d..0d4265cbfe178a8cebec36389d4c8eded6c28aeb 100644 (file)
@@ -21,3 +21,4 @@ export * from './SubnavigationAccordion';
 export * from './SubnavigationGroup';
 export * from './SubnavigationHeading';
 export * from './SubnavigationItem';
+export * from './SubnavigationSubheading';
index 2aba7aba06678d291ba4b37ab39196b7c2db65fc..426abce10331650278b2a56e8681a01a6968cd23 100644 (file)
@@ -42,6 +42,7 @@ import { getBranchLikeQuery, isPullRequest, isSameBranchLike } from '../../../he
 import { translate } from '../../../helpers/l10n';
 import { BranchLike } from '../../../types/branch-like';
 import { ComponentQualifier } from '../../../types/component';
+import { MetricKey } from '../../../types/metrics';
 import {
   ComponentMeasure,
   Dict,
@@ -243,7 +244,7 @@ class ComponentMeasuresApp extends React.PureComponent<Props, State> {
 
     const hideDrilldown =
       isPullRequest(branchLike) &&
-      (metric.key === 'coverage' || metric.key === 'duplicated_lines_density');
+      (metric.key === MetricKey.coverage || metric.key === MetricKey.duplicated_lines_density);
 
     if (hideDrilldown) {
       return (
diff --git a/server/sonar-web/src/main/js/apps/component-measures/sidebar/DomainFacet.tsx b/server/sonar-web/src/main/js/apps/component-measures/sidebar/DomainFacet.tsx
deleted file mode 100644 (file)
index 638040d..0000000
+++ /dev/null
@@ -1,173 +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 * as React from 'react';
-import FacetBox from '../../../components/facet/FacetBox';
-import FacetHeader from '../../../components/facet/FacetHeader';
-import FacetItem from '../../../components/facet/FacetItem';
-import FacetItemsList from '../../../components/facet/FacetItemsList';
-import BubblesIcon from '../../../components/icons/BubblesIcon';
-import {
-  getLocalizedCategoryMetricName,
-  getLocalizedMetricDomain,
-  getLocalizedMetricName,
-  hasMessage,
-  translate,
-} from '../../../helpers/l10n';
-import { MeasureEnhanced } from '../../../types/types';
-import {
-  addMeasureCategories,
-  filterMeasures,
-  hasBubbleChart,
-  hasFacetStat,
-  sortMeasures,
-} from '../utils';
-import FacetMeasureValue from './FacetMeasureValue';
-
-interface Props {
-  domain: { name: string; measures: MeasureEnhanced[] };
-  onChange: (metric: string) => void;
-  onToggle: (property: string) => void;
-  open: boolean;
-  selected: string;
-  showFullMeasures: boolean;
-}
-
-export default class DomainFacet extends React.PureComponent<Props> {
-  getValues = () => {
-    const { domain, selected } = this.props;
-    const measureSelected = domain.measures.find((measure) => measure.metric.key === selected);
-    const overviewSelected = domain.name === selected && this.hasOverview(domain.name);
-    if (measureSelected) {
-      return [getLocalizedMetricName(measureSelected.metric)];
-    }
-    return overviewSelected ? [translate('component_measures.domain_overview')] : [];
-  };
-
-  handleHeaderClick = () => {
-    this.props.onToggle(this.props.domain.name);
-  };
-
-  hasFacetSelected = (domain: { name: string }, measures: MeasureEnhanced[], selected: string) => {
-    const measureSelected = measures.find((measure) => measure.metric.key === selected);
-    const overviewSelected = domain.name === selected && this.hasOverview(domain.name);
-    return measureSelected || overviewSelected;
-  };
-
-  hasOverview = (domain: string) => {
-    return this.props.showFullMeasures && hasBubbleChart(domain);
-  };
-
-  renderItemFacetStat = (item: MeasureEnhanced) => {
-    return hasFacetStat(item.metric.key) ? (
-      <FacetMeasureValue displayLeak={this.props.showFullMeasures} measure={item} />
-    ) : null;
-  };
-
-  renderCategoryItem = (item: string) => {
-    return this.props.showFullMeasures || item === 'new_code_category' ? (
-      <span className="facet search-navigator-facet facet-category" key={item}>
-        <span className="facet-name">{translate('component_measures.facet_category', item)}</span>
-      </span>
-    ) : null;
-  };
-
-  renderItemsFacet = () => {
-    const { domain, selected } = this.props;
-    const items = addMeasureCategories(domain.name, filterMeasures(domain.measures));
-    const hasCategories = items.some((item) => typeof item === 'string');
-    const translateMetric = hasCategories ? getLocalizedCategoryMetricName : getLocalizedMetricName;
-    let sortedItems = sortMeasures(domain.name, items);
-
-    sortedItems = sortedItems.filter((item, index) => {
-      return (
-        typeof item !== 'string' ||
-        (index + 1 !== sortedItems.length && typeof sortedItems[index + 1] !== 'string')
-      );
-    });
-
-    return sortedItems.map((item) =>
-      typeof item === 'string' ? (
-        this.renderCategoryItem(item)
-      ) : (
-        <FacetItem
-          active={item.metric.key === selected}
-          key={item.metric.key}
-          name={
-            <span className="big-spacer-left" id={`measure-${item.metric.key}-name`}>
-              {translateMetric(item.metric)}
-            </span>
-          }
-          onClick={this.props.onChange}
-          stat={this.renderItemFacetStat(item)}
-          tooltip={translateMetric(item.metric)}
-          value={item.metric.key}
-        />
-      )
-    );
-  };
-
-  renderOverviewFacet = () => {
-    const { domain, selected } = this.props;
-    if (!this.hasOverview(domain.name)) {
-      return null;
-    }
-    return (
-      <FacetItem
-        active={domain.name === selected}
-        key={domain.name}
-        name={
-          <span id={`measure-overview-${domain.name}-name`}>
-            {translate('component_measures.domain_overview')}
-          </span>
-        }
-        onClick={this.props.onChange}
-        stat={<BubblesIcon size={14} />}
-        tooltip={translate('component_measures.domain_overview')}
-        value={domain.name}
-      />
-    );
-  };
-
-  render() {
-    const { domain, open } = this.props;
-    const helperMessageKey = `component_measures.domain_facets.${domain.name}.help`;
-    const helper = hasMessage(helperMessageKey) ? translate(helperMessageKey) : undefined;
-    const headerId = `facet_${domain.name}`;
-    return (
-      <FacetBox property={domain.name}>
-        <FacetHeader
-          helper={helper}
-          id={headerId}
-          name={getLocalizedMetricDomain(domain.name)}
-          onClick={this.handleHeaderClick}
-          open={open}
-          values={this.getValues()}
-        />
-
-        {open && (
-          <FacetItemsList labelledby={headerId}>
-            {this.renderOverviewFacet()}
-            {this.renderItemsFacet()}
-          </FacetItemsList>
-        )}
-      </FacetBox>
-    );
-  }
-}
diff --git a/server/sonar-web/src/main/js/apps/component-measures/sidebar/DomainSubnavigation.tsx b/server/sonar-web/src/main/js/apps/component-measures/sidebar/DomainSubnavigation.tsx
new file mode 100644 (file)
index 0000000..f308d9a
--- /dev/null
@@ -0,0 +1,111 @@
+/*
+ * 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 {
+  BareButton,
+  HelperHintIcon,
+  SubnavigationAccordion,
+  SubnavigationItem,
+  SubnavigationSubheading,
+} from 'design-system';
+import React from 'react';
+import HelpTooltip from '../../../components/controls/HelpTooltip';
+import {
+  getLocalizedCategoryMetricName,
+  getLocalizedMetricDomain,
+  getLocalizedMetricName,
+  hasMessage,
+  translate,
+} from '../../../helpers/l10n';
+import { MeasureEnhanced } from '../../../types/types';
+import { addMeasureCategories, hasBubbleChart, sortMeasures } from '../utils';
+import DomainSubnavigationItem from './DomainSubnavigationItem';
+
+interface Props {
+  domain: { measures: MeasureEnhanced[]; name: string };
+  onChange: (metric: string) => void;
+  open: boolean;
+  selected: string;
+  showFullMeasures: boolean;
+}
+
+export default function DomainSubnavigation(props: Props) {
+  const { domain, onChange, open, selected, showFullMeasures } = props;
+  const helperMessageKey = `component_measures.domain_subnavigation.${domain.name}.help`;
+  const helper = hasMessage(helperMessageKey) ? translate(helperMessageKey) : undefined;
+  const items = addMeasureCategories(domain.name, domain.measures);
+  const hasCategories = items.some((item) => typeof item === 'string');
+  const translateMetric = hasCategories ? getLocalizedCategoryMetricName : getLocalizedMetricName;
+  let sortedItems = sortMeasures(domain.name, items);
+
+  const hasOverview = (domain: string) => {
+    return showFullMeasures && hasBubbleChart(domain);
+  };
+
+  // sortedItems contains both measures (type object) and categories (type string)
+  // here we are filtering out categories that don't contain any measures (happen on the measures page for PRs)
+  sortedItems = sortedItems.filter((item, index) => {
+    return (
+      typeof item === 'object' ||
+      (index < sortedItems.length - 1 && typeof sortedItems[index + 1] === 'object')
+    );
+  });
+  return (
+    <SubnavigationAccordion
+      header={
+        <div className="sw-flex sw-items-center sw-gap-3">
+          <strong className="sw-body-sm-highlight">{getLocalizedMetricDomain(domain.name)}</strong>
+          {helper && (
+            <HelpTooltip overlay={helper}>
+              <HelperHintIcon aria-hidden="false" description={helper} />
+            </HelpTooltip>
+          )}
+        </div>
+      }
+      initExpanded={open}
+      id={`measure-${domain.name}`}
+    >
+      {hasOverview(domain.name) && (
+        <SubnavigationItem active={domain.name === selected} onClick={onChange} value={domain.name}>
+          <BareButton aria-current={domain.name === selected}>
+            {translate('component_measures.domain_overview')}
+          </BareButton>
+        </SubnavigationItem>
+      )}
+
+      {sortedItems.map((item) =>
+        typeof item === 'string' ? (
+          showFullMeasures && (
+            <SubnavigationSubheading>
+              {translate('component_measures.subnavigation_category', item)}
+            </SubnavigationSubheading>
+          )
+        ) : (
+          <DomainSubnavigationItem
+            key={item.metric.key}
+            measure={item}
+            name={translateMetric(item.metric)}
+            onChange={onChange}
+            selected={selected}
+          />
+        )
+      )}
+    </SubnavigationAccordion>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/component-measures/sidebar/DomainSubnavigationItem.tsx b/server/sonar-web/src/main/js/apps/component-measures/sidebar/DomainSubnavigationItem.tsx
new file mode 100644 (file)
index 0000000..eab341f
--- /dev/null
@@ -0,0 +1,47 @@
+/*
+ * 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 { BareButton, SubnavigationItem } from 'design-system';
+import React from 'react';
+import { MeasureEnhanced } from '../../../types/types';
+import SubnavigationMeasureValue from './SubnavigationMeasureValue';
+
+interface Props {
+  measure: MeasureEnhanced;
+  name: string;
+  onChange: (metric: string) => void;
+  selected: string;
+}
+
+export default function DomainSubnavigationItem({ measure, name, onChange, selected }: Props) {
+  const { key } = measure.metric;
+  return (
+    <SubnavigationItem active={key === selected} key={key} onClick={onChange} value={key}>
+      <BareButton
+        aria-current={key === selected}
+        className="sw-ml-2 sw-w-full sw-flex sw-justify-between"
+        id={`measure-${key}-name`}
+      >
+        {name}
+        <SubnavigationMeasureValue measure={measure} />
+      </BareButton>
+    </SubnavigationItem>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/component-measures/sidebar/FacetMeasureValue.tsx b/server/sonar-web/src/main/js/apps/component-measures/sidebar/FacetMeasureValue.tsx
deleted file mode 100644 (file)
index 097aa52..0000000
+++ /dev/null
@@ -1,56 +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 classNames from 'classnames';
-import * as React from 'react';
-import Measure from '../../../components/measure/Measure';
-import { isDiffMetric } from '../../../helpers/measures';
-import { MeasureEnhanced } from '../../../types/types';
-
-interface Props {
-  displayLeak?: boolean;
-  measure: MeasureEnhanced;
-}
-
-export default function FacetMeasureValue({ measure, displayLeak }: Props) {
-  if (isDiffMetric(measure.metric.key)) {
-    return (
-      <div
-        className={classNames('domain-measures-value', { 'leak-box': displayLeak })}
-        id={`measure-${measure.metric.key}-leak`}
-      >
-        <Measure
-          metricKey={measure.metric.key}
-          metricType={measure.metric.type}
-          value={measure.leak}
-        />
-      </div>
-    );
-  }
-
-  return (
-    <div className="domain-measures-value" id={`measure-${measure.metric.key}-value`}>
-      <Measure
-        metricKey={measure.metric.key}
-        metricType={measure.metric.type}
-        value={measure.value}
-      />
-    </div>
-  );
-}
diff --git a/server/sonar-web/src/main/js/apps/component-measures/sidebar/ProjectOverviewFacet.tsx b/server/sonar-web/src/main/js/apps/component-measures/sidebar/ProjectOverviewFacet.tsx
deleted file mode 100644 (file)
index bb33f44..0000000
+++ /dev/null
@@ -1,48 +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 * as React from 'react';
-import FacetBox from '../../../components/facet/FacetBox';
-import FacetItem from '../../../components/facet/FacetItem';
-import FacetItemsList from '../../../components/facet/FacetItemsList';
-import { translate } from '../../../helpers/l10n';
-
-interface Props {
-  onChange: (metric: string) => void;
-  selected: string;
-  value: string;
-}
-
-export default function ProjectOverviewFacet({ value, selected, onChange }: Props) {
-  const facetName = translate('component_measures.overview', value, 'facet');
-  return (
-    <FacetBox property={value}>
-      <FacetItemsList label={facetName}>
-        <FacetItem
-          active={value === selected}
-          key={value}
-          name={<strong id={`measure-overview-${value}-name`}>{facetName}</strong>}
-          onClick={onChange}
-          tooltip={facetName}
-          value={value}
-        />
-      </FacetItemsList>
-    </FacetBox>
-  );
-}
index 0b7869549984a7b397fd853e754342b1a2983be5..caa4aa7f631ac3b58f7ada0ba4e6fe4e264f078b 100644 (file)
 import { withTheme } from '@emotion/react';
 import styled from '@emotion/styled';
 import {
+  BareButton,
   FlagMessage,
   LAYOUT_FOOTER_HEIGHT,
   LAYOUT_GLOBAL_NAV_HEIGHT,
   LAYOUT_PROJECT_NAV_HEIGHT,
+  SubnavigationGroup,
+  SubnavigationItem,
   themeBorder,
   themeColor,
-} from 'design-system/lib';
+} from 'design-system';
 import * as React from 'react';
 import A11ySkipTarget from '../../../components/a11y/A11ySkipTarget';
 import HelpTooltip from '../../../components/controls/HelpTooltip';
 import { translate } from '../../../helpers/l10n';
 import useFollowScroll from '../../../hooks/useFollowScroll';
 import { isPortfolioLike } from '../../../types/component';
-import { Dict, MeasureEnhanced } from '../../../types/types';
-import { KNOWN_DOMAINS, PROJECT_OVERVEW, Query, groupByDomains } from '../utils';
-import DomainFacet from './DomainFacet';
-import ProjectOverviewFacet from './ProjectOverviewFacet';
+import { MeasureEnhanced } from '../../../types/types';
+import { PROJECT_OVERVEW, Query, groupByDomains, isProjectOverview } from '../utils';
+import DomainSubnavigation from './DomainSubnavigation';
 
 interface Props {
   canBrowseAllChildProjects: boolean;
@@ -56,15 +58,7 @@ export default function Sidebar(props: Props) {
     selectedMetric,
     measures,
   } = props;
-  const [openFacets, setOpenFacets] = React.useState(getOpenFacets({}, props));
-  const { top: topScroll } = useFollowScroll();
-
-  const handleToggleFacet = React.useCallback(
-    (name: string) => {
-      setOpenFacets((openFacets) => ({ ...openFacets, [name]: !openFacets[name] }));
-    },
-    [setOpenFacets]
-  );
+  const { top: topScroll, scrolledOnce } = useFollowScroll();
 
   const handleChangeMetric = React.useCallback(
     (metric: string) => {
@@ -73,9 +67,17 @@ export default function Sidebar(props: Props) {
     [updateQuery]
   );
 
-  const distanceFromBottom = topScroll + window.innerHeight - document.body.clientHeight;
+  const handleProjectOverviewClick = () => {
+    handleChangeMetric(PROJECT_OVERVEW);
+  };
+
+  const distanceFromBottom = topScroll + window.innerHeight - document.body.scrollHeight;
   const footerVisibleHeight =
-    distanceFromBottom > -LAYOUT_FOOTER_HEIGHT ? LAYOUT_FOOTER_HEIGHT + distanceFromBottom : 0;
+    (scrolledOnce &&
+      (distanceFromBottom > -LAYOUT_FOOTER_HEIGHT
+        ? LAYOUT_FOOTER_HEIGHT + distanceFromBottom
+        : 0)) ||
+    0;
 
   return (
     <StyledSidebar
@@ -90,7 +92,7 @@ export default function Sidebar(props: Props) {
       {!canBrowseAllChildProjects && isPortfolioLike(qualifier) && (
         <FlagMessage
           ariaLabel={translate('component_measures.not_all_measures_are_shown')}
-          className="it__portfolio_warning"
+          className="sw-mt-4 it__portfolio_warning"
           variant="warning"
         >
           {translate('component_measures.not_all_measures_are_shown')}
@@ -109,18 +111,23 @@ export default function Sidebar(props: Props) {
           label={translate('component_measures.skip_to_navigation')}
           weight={10}
         />
-        <ProjectOverviewFacet
-          onChange={handleChangeMetric}
-          selected={selectedMetric}
-          value={PROJECT_OVERVEW}
-        />
-        {groupByDomains(measures).map((domain) => (
-          <DomainFacet
+        <SubnavigationGroup>
+          <SubnavigationItem
+            active={isProjectOverview(selectedMetric)}
+            onClick={handleProjectOverviewClick}
+          >
+            <BareButton>
+              {translate('component_measures.overview', PROJECT_OVERVEW, 'subnavigation')}
+            </BareButton>
+          </SubnavigationItem>
+        </SubnavigationGroup>
+
+        {groupByDomains(measures).map((domain: Domain) => (
+          <DomainSubnavigation
             domain={domain}
             key={domain.name}
             onChange={handleChangeMetric}
-            onToggle={handleToggleFacet}
-            open={openFacets[domain.name] === true}
+            open={isDomainSelected(selectedMetric, domain)}
             selected={selectedMetric}
             showFullMeasures={showFullMeasures}
           />
@@ -130,15 +137,16 @@ export default function Sidebar(props: Props) {
   );
 }
 
-function getOpenFacets(openFacets: Dict<boolean>, { measures, selectedMetric }: Props) {
-  const newOpenFacets = { ...openFacets };
-  const measure = measures.find((measure) => measure.metric.key === selectedMetric);
-  if (measure && measure.metric && measure.metric.domain) {
-    newOpenFacets[measure.metric.domain] = true;
-  } else if (KNOWN_DOMAINS.includes(selectedMetric)) {
-    newOpenFacets[selectedMetric] = true;
-  }
-  return newOpenFacets;
+interface Domain {
+  measures: MeasureEnhanced[];
+  name: string;
+}
+
+function isDomainSelected(selectedMetric: string, domain: Domain) {
+  return (
+    selectedMetric === domain.name ||
+    domain.measures.some((measure) => measure.metric.key === selectedMetric)
+  );
 }
 
 const StyledSidebar = withTheme(styled.div`
@@ -147,4 +155,6 @@ const StyledSidebar = withTheme(styled.div`
 
   background-color: ${themeColor('filterbar')};
   border-right: ${themeBorder('default', 'filterbarBorder')};
+  position: sticky;
+  overflow-x: hidden;
 `);
diff --git a/server/sonar-web/src/main/js/apps/component-measures/sidebar/SubnavigationMeasureValue.tsx b/server/sonar-web/src/main/js/apps/component-measures/sidebar/SubnavigationMeasureValue.tsx
new file mode 100644 (file)
index 0000000..4dce88e
--- /dev/null
@@ -0,0 +1,46 @@
+/*
+ * 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 { Note } from 'design-system';
+import React from 'react';
+import Measure from '../../../components/measure/Measure';
+import { isDiffMetric } from '../../../helpers/measures';
+import { MeasureEnhanced } from '../../../types/types';
+
+interface Props {
+  measure: MeasureEnhanced;
+}
+
+export default function SubnavigationMeasureValue({ measure }: Props) {
+  const isDiff = isDiffMetric(measure.metric.key);
+
+  return (
+    <Note
+      className="sw-flex sw-items-center sw-mr-1"
+      id={`measure-${measure.metric.key}-${isDiff ? 'leak' : 'value'}`}
+    >
+      <Measure
+        metricKey={measure.metric.key}
+        metricType={measure.metric.type}
+        value={isDiff ? measure.leak : measure.value}
+      />
+    </Note>
+  );
+}
index d123cf260d7af1386f9c4da97980174730e0a229..e7d8d9fd11b26e449ce900ec2ac6cf00c780f1a2 100644 (file)
@@ -74,9 +74,7 @@ export default function HotspotListItem(props: HotspotListItemProps) {
       onClick={handleClick}
       className="sw-flex-col sw-items-start"
     >
-      <StyledHotspotTitle aria-current={selected} role="button">
-        {hotspot.message}
-      </StyledHotspotTitle>
+      <StyledHotspotTitle aria-current={selected}>{hotspot.message}</StyledHotspotTitle>
       {locations.length > 0 && (
         <StyledHotspotInfo className="sw-flex sw-justify-end sw-w-full">
           <div className="sw-flex sw-mt-2 sw-items-center sw-justify-center sw-gap-1 sw-overflow-hidden">
index a980441cf9ef94599995c9f8b5bdd7c039b702b3..bf83d336a6a4ac7a6761ac5e0ec804b816d3dc66 100644 (file)
@@ -22,6 +22,7 @@ import Tooltip from '../../components/controls/Tooltip';
 import Level from '../../components/ui/Level';
 import Rating from '../../components/ui/Rating';
 import { formatMeasure } from '../../helpers/measures';
+import { MetricType } from '../../types/metrics';
 import RatingTooltipContent from './RatingTooltipContent';
 
 interface Props {
@@ -47,14 +48,14 @@ export default function Measure({
     return <span className={className}>–</span>;
   }
 
-  if (metricType === 'LEVEL') {
+  if (metricType === MetricType.Level) {
     return <Level className={className} level={value} small={small} />;
   }
 
-  if (metricType !== 'RATING') {
+  if (metricType !== MetricType.Rating) {
     const formattedValue = formatMeasure(value, metricType, {
       decimals,
-      omitExtraDecimalZeros: metricType === 'PERCENT',
+      omitExtraDecimalZeros: metricType === MetricType.Percent,
     });
     return <span className={className}>{formattedValue != null ? formattedValue : '–'}</span>;
   }
index 0692d5e2ab7f7cf99bd80826a0c960b42fe78bf1..ee30b41aae7ff10f09fd1bcc0a490ecc2f407347 100644 (file)
@@ -25,12 +25,14 @@ const THROTTLE_DELAY = 10;
 export default function useFollowScroll() {
   const [left, setLeft] = useState(0);
   const [top, setTop] = useState(0);
+  const [scrolledOnce, setScrolledOnce] = useState(false);
 
   useEffect(() => {
     const followScroll = throttle(() => {
       if (document.documentElement) {
         setLeft(document.documentElement.scrollLeft);
         setTop(document.documentElement.scrollTop);
+        setScrolledOnce(true);
       }
     }, THROTTLE_DELAY);
 
@@ -38,5 +40,5 @@ export default function useFollowScroll() {
     return () => document.removeEventListener('scroll', followScroll);
   }, []);
 
-  return { left, top };
+  return { left, top, scrolledOnce };
 }
index 6ea6b7b2ae9a88699d7be49a610435f313d11a00..2433b4b33feea3d77cd74096a60ee1ee833cbc87 100644 (file)
@@ -3659,7 +3659,7 @@ component_measures.hidden_best_score_metrics=There are {0} hidden components wit
 component_measures.navigation=Measures navigation
 component_measures.skip_to_navigation=Skip to measure navigation
 
-component_measures.overview.project_overview.facet=Project Overview
+component_measures.overview.project_overview.subnavigation=Project Overview
 component_measures.overview.project_overview.title=Risk
 component_measures.overview.project_overview.description=Get quick insights into the operational risks. For users relying on their keyboard, elements are sorted by the number of lines of code for each file. Any color but green indicates immediate risks: Bugs or Vulnerabilities that should be examined. A position at the top or right of the graph means that the longer-term health may be at risk. Green bubbles at the bottom-left are best.
 component_measures.overview.Reliability.description=See bugs' operational risks. For users relying on their keyboard, elements are sorted by volume of bugs per file. The closer a bubble's color is to red, the more severe the worst bugs are. Bubble size indicates bug volume, and each bubble's vertical position reflects the estimated time to address the bugs. Small green bubbles on the bottom edge are best.
@@ -3669,16 +3669,17 @@ component_measures.overview.Coverage.description=See missing test coverage's lon
 component_measures.overview.Duplications.description=See duplications' long-term risks. For users relying on their keyboard, elements are sorted by the number of duplicated blocks per file. Bubble size indicates the volume of duplicated blocks, and each bubble's vertical position reflects the volume of lines in those blocks. Small bubbles on the bottom edge are best.
 component_measures.overview.see_data_as_list=See the data presented on this chart as a list
 
-component_measures.domain_facets.Reliability.help=Issues in this domain mark code where you will get behavior other than what was expected.
-component_measures.domain_facets.Maintainability.help=Issues in this domain mark code that will be more difficult to update competently than it should.
-component_measures.domain_facets.Security.help=Issues in this domain mark potential weaknesses to hackers.
-component_measures.domain_facets.SecurityReview.help=This domain represents potential security risks in the form of hotspots and their review status.
-component_measures.domain_facets.Complexity.help=How simple or complicated the control flow of the application is. Cyclomatic Complexity measures the minimum number of test cases required for full test coverage. Cognitive Complexity is a measure of how difficult the application is to understand
+component_measures.domain_subnavigation.Reliability.help=Issues in this domain mark code where you will get behavior other than what was expected.
+component_measures.domain_subnavigation.Maintainability.help=Issues in this domain mark code that will be more difficult to update competently than it should.
+component_measures.domain_subnavigation.Security.help=Issues in this domain mark potential weaknesses to hackers.
+component_measures.domain_subnavigation.SecurityReview.help=This domain represents potential security risks in the form of hotspots and their review status.
+component_measures.domain_subnavigation.Complexity.help=How simple or complicated the control flow of the application is. Cyclomatic Complexity measures the minimum number of test cases required for full test coverage. Cognitive Complexity is a measure of how difficult the application is to understand
+
+component_measures.subnavigation_category.new_code_category=New Code
+component_measures.subnavigation_category.overall_category=Overall Code
+component_measures.subnavigation_category.tests_category=Tests
 
-component_measures.facet_category.new_code_category=On new code
-component_measures.facet_category.overall_category=Overall
 component_measures.facet_category.overall_category.estimated=Estimated after merge
-component_measures.facet_category.tests_category=Tests
 component_measures.bubble_chart.zoom_level=Current zoom level. Scroll on the chart to zoom or unzoom, click here to reset.
 component_measures.not_all_measures_are_shown=Not all projects and applications are included
 component_measures.not_all_measures_are_shown.help=You do not have access to all projects and/or applications. Measures are still computed based on all projects and applications.