]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-19604 New UI for the Activity Graph header
authorJeremy Davis <jeremy.davis@sonarsource.com>
Mon, 19 Jun 2023 09:17:47 +0000 (11:17 +0200)
committersonartech <sonartech@sonarsource.com>
Mon, 26 Jun 2023 20:03:54 +0000 (20:03 +0000)
13 files changed:
server/sonar-web/design-system/src/components/MultiSelectMenu.tsx
server/sonar-web/design-system/src/components/MultiSelectMenuOption.tsx
server/sonar-web/design-system/src/theme/light.ts
server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphs.tsx
server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/ProjectActivityApp-it.tsx
server/sonar-web/src/main/js/components/activity-graph/AddGraphMetric.tsx
server/sonar-web/src/main/js/components/activity-graph/AddGraphMetricPopup.tsx
server/sonar-web/src/main/js/components/activity-graph/GraphsHeader.tsx
server/sonar-web/src/main/js/components/activity-graph/GraphsLegendItem.tsx
server/sonar-web/src/main/js/components/activity-graph/__tests__/ActivityGraph-it.tsx
server/sonar-web/src/main/js/components/activity-graph/__tests__/utils-test.ts
server/sonar-web/src/main/js/components/activity-graph/utils.ts
server/sonar-web/src/main/js/types/metrics.ts

index b0c77bfcd747772a00a9fc074b0d6f30e7de1906..ecfaa4595123d1feb7ec55cc9660c3b11ee6e8c5 100644 (file)
@@ -264,6 +264,8 @@ export class MultiSelectMenu extends PureComponent<Props, State> {
       noResultsLabel,
       searchInputAriaLabel,
     } = this.props;
+    const { renderLabel } = this.props as PropsWithDefault;
+
     const { query, activeIdx, selectedElements, unselectedElements } = this.state;
     const activeElement = this.getAllElements(this.props, this.state)[activeIdx];
     const showNewElement = allowNewElements && this.isNewElement(query, this.props);
@@ -301,6 +303,7 @@ export class MultiSelectMenu extends PureComponent<Props, State> {
                 key={element}
                 onHover={this.handleElementHover}
                 onSelectChange={this.handleSelectChange}
+                renderLabel={renderLabel}
                 selected
               />
             ))}
@@ -314,6 +317,7 @@ export class MultiSelectMenu extends PureComponent<Props, State> {
                 key={element}
                 onHover={this.handleElementHover}
                 onSelectChange={this.handleSelectChange}
+                renderLabel={renderLabel}
               />
             ))}
           {showNewElement && (
index 27147703f96072a2e96999fe6ba970915d889617..e68dd7c36871508f14139d894bf3b63d91e23709 100644 (file)
@@ -18,6 +18,7 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import classNames from 'classnames';
+import { identity } from 'lodash';
 import { ItemCheckbox } from './DropdownMenu';
 
 export interface MultiSelectOptionProps {
@@ -28,12 +29,27 @@ export interface MultiSelectOptionProps {
   element: string;
   onHover: (element: string) => void;
   onSelectChange: (selected: boolean, element: string) => void;
+  renderLabel?: (element: string) => React.ReactNode;
   selected?: boolean;
 }
 
 export function MultiSelectMenuOption(props: MultiSelectOptionProps) {
-  const { active, createElementLabel, custom, disabled, element, onSelectChange, selected } = props;
-  const onHover = () => props.onHover(element);
+  const {
+    active,
+    createElementLabel,
+    custom,
+    disabled,
+    element,
+    onSelectChange,
+    selected,
+    renderLabel = identity,
+  } = props;
+
+  const onHover = () => {
+    props.onHover(element);
+  };
+
+  const label = renderLabel(element);
 
   return (
     <ItemCheckbox
@@ -57,7 +73,7 @@ export function MultiSelectMenuOption(props: MultiSelectOptionProps) {
           {element}
         </span>
       ) : (
-        <span className="sw-ml-3">{element}</span>
+        <span className="sw-ml-3">{label}</span>
       )}
     </ItemCheckbox>
   );
index ac7d1fdd012ab2d061ebde5f149c4e676cb02f0c..b7acbc729b25af5e0b915a43d6ff9fa6e123b19f 100644 (file)
@@ -460,6 +460,7 @@ export const lightTheme = {
     graphZoomBackgroundColor: COLORS.blueGrey[25],
     graphZoomBorderColor: COLORS.blueGrey[100],
     graphZoomHandleColor: COLORS.blueGrey[400],
+    graphLegendBorder: secondary.darker,
 
     // page
     pageTitle: COLORS.blueGrey[700],
index 3f54362aef64d7f98f009a4661ee7fb04fa8218a..3acdcfb0e13eddb5709647b5fd5aa85b6fb3b2ca 100644 (file)
@@ -39,7 +39,7 @@ import {
   Serie,
 } from '../../../types/project-activity';
 import { Metric } from '../../../types/types';
-import { datesQueryChanged, historyQueryChanged, Query } from '../utils';
+import { Query, datesQueryChanged, historyQueryChanged } from '../utils';
 import { PROJECT_ACTIVITY_GRAPH } from './ProjectActivityApp';
 
 interface Props {
@@ -132,8 +132,8 @@ export default class ProjectActivityGraphs extends React.PureComponent<Props, St
         'x'
       );
       return {
-        graphEndDate: lastValid && lastValid.x,
-        graphStartDate: firstValid && firstValid.x,
+        graphEndDate: lastValid?.x,
+        graphStartDate: firstValid?.x,
       };
     }
     return null;
index f1c8eeb93114605c792f33edbfce5b5ccb6ab0c5..2fd8e4b83c1c65ddb4622c7797c77bb480f96442 100644 (file)
@@ -461,7 +461,9 @@ function getPageObject() {
       },
 
       async changeGraphType(type: GraphType) {
-        await selectEvent.select(ui.graphTypeSelect.get(), [`project_activity.graphs.${type}`]);
+        await user.click(ui.graphTypeSelect.get());
+        const optionForType = await screen.findByText(`project_activity.graphs.${type}`);
+        await user.click(optionForType);
       },
 
       async openMetricsDropdown() {
index 22f33da079afb9959579748e0577b3e0386ff16a..57c72a6dc34573613b5d1bd935f17a969663127e 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+import { ButtonSecondary, ChevronDownIcon, Dropdown, TextMuted } from 'design-system';
 import { sortBy } from 'lodash';
 import * as React from 'react';
-import { Button } from '../../components/controls/buttons';
-import Dropdown from '../../components/controls/Dropdown';
-import DropdownIcon from '../../components/icons/DropdownIcon';
 import { getLocalizedMetricName, translate } from '../../helpers/l10n';
 import { isDiffMetric } from '../../helpers/measures';
+import { MetricType } from '../../types/metrics';
 import { Metric } from '../../types/types';
 import AddGraphMetricPopup from './AddGraphMetricPopup';
 
@@ -63,7 +62,7 @@ export default class AddGraphMetric extends React.PureComponent<Props, State> {
         if (
           metric.hidden ||
           isDiffMetric(metric.key) ||
-          ['DATA', 'DISTRIB'].includes(metric.type) ||
+          [MetricType.Data, MetricType.Distribution].includes(metric.type as MetricType) ||
           selectedMetrics.includes(metric.key) ||
           !getLocalizedMetricName(metric).toLowerCase().includes(query.toLowerCase())
         ) {
@@ -120,11 +119,13 @@ export default class AddGraphMetric extends React.PureComponent<Props, State> {
       this.props.metrics,
       this.props.selectedMetrics
     );
+
     return (
       <Dropdown
-        className="display-inline-block"
+        allowResizing
+        size="large"
         closeOnClick={false}
-        closeOnClickOutside
+        id="activity-graph-custom-metric-selector"
         overlay={
           <AddGraphMetricPopup
             elements={filteredMetrics}
@@ -138,12 +139,15 @@ export default class AddGraphMetric extends React.PureComponent<Props, State> {
           />
         }
       >
-        <Button className="spacer-left">
-          <span className="text-ellipsis text-middle">
-            {translate('project_activity.graphs.custom.add')}
-          </span>
-          <DropdownIcon className="text-top little-spacer-left" />
-        </Button>
+        <ButtonSecondary
+          className={
+            'sw-ml-2 sw-body-sm sw-flex sw-flex-row sw-justify-between sw-pl-3 sw-pr-2 sw-w-32 ' +
+            'sw-z-normal' // needed because the legends overlap part of the button
+          }
+        >
+          <TextMuted text={translate('project_activity.graphs.custom.add')} />
+          <ChevronDownIcon className="sw-ml-1 sw-mr-0 sw-pr-0" />
+        </ButtonSecondary>
       </Dropdown>
     );
   }
index 583b849a2f4ef35d232b5f76acf83994e62a1b01..f762e83d8554a2598d690136a871c6d4b118c6bf 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+import { FlagMessage, MultiSelectMenu } from 'design-system';
 import * as React from 'react';
-import { Alert } from '../../components/ui/Alert';
 import { translate, translateWithParameters } from '../../helpers/l10n';
-import MultiSelect from '../common/MultiSelect';
 
 export interface AddGraphMetricPopupProps {
   elements: string[];
@@ -43,13 +42,13 @@ export default function AddGraphMetricPopup({
 
   if (props.selectedElements.length >= 6) {
     footerNode = (
-      <Alert className="spacer-left spacer-right spacer-top" variant="info">
+      <FlagMessage className="sw-m-2" variant="info">
         {translate('project_activity.graphs.custom.add_metric_info')}
-      </Alert>
+      </FlagMessage>
     );
   } else if (metricsTypeFilter && metricsTypeFilter.length > 0) {
     footerNode = (
-      <Alert className="spacer-left spacer-right spacer-top" variant="info">
+      <FlagMessage className="sw-m-2" variant="info">
         {translateWithParameters(
           'project_activity.graphs.custom.type_x_message',
           metricsTypeFilter
@@ -57,26 +56,28 @@ export default function AddGraphMetricPopup({
             .sort((a, b) => a.localeCompare(b))
             .join(', ')
         )}
-      </Alert>
+      </FlagMessage>
     );
   }
 
   return (
-    <div className="menu abs-width-300">
-      <MultiSelect
-        allowNewElements={false}
-        allowSelection={props.selectedElements.length < 6}
-        elements={elements}
-        filterSelected={props.filterSelected}
-        footerNode={footerNode}
-        legend={translate('project_activity.graphs.custom.select_metric')}
-        onSearch={props.onSearch}
-        onSelect={(item: string) => elements.includes(item) && props.onSelect(item)}
-        onUnselect={props.onUnselect}
-        placeholder={translate('search.search_for_metrics')}
-        renderLabel={props.renderLabel}
-        selectedElements={props.selectedElements}
-      />
-    </div>
+    <MultiSelectMenu
+      clearIconAriaLabel={translate('clear_verb')}
+      createElementLabel=""
+      searchInputAriaLabel={translate('project_activity.graphs.custom.select_metric')}
+      allowNewElements={false}
+      allowSelection={props.selectedElements.length < 6}
+      elements={elements}
+      filterSelected={props.filterSelected}
+      footerNode={footerNode}
+      noResultsLabel={translateWithParameters('no_results')}
+      onSearch={props.onSearch}
+      onSelect={(item: string) => elements.includes(item) && props.onSelect(item)}
+      onUnselect={props.onUnselect}
+      placeholder={translate('search.search_for_metrics')}
+      renderLabel={props.renderLabel}
+      selectedElements={props.selectedElements}
+      listSize={0}
+    />
   );
 }
index 971dbd53157daa721e3d9d2233fae1b514829dfe..e2165221004baa38ed10bfed0834da011ee73a03 100644 (file)
@@ -31,7 +31,6 @@ import * as React from 'react';
 import { translate } from '../../helpers/l10n';
 import { GraphType } from '../../types/project-activity';
 import { Metric } from '../../types/types';
-import Select from '../controls/Select';
 import AddGraphMetric from './AddGraphMetric';
 import './styles.css';
 import { getGraphTypes, isCustomGraph } from './utils';
@@ -47,90 +46,76 @@ interface Props {
   onUpdateGraph: (graphType: string) => void;
 }
 
-export default class GraphsHeader extends React.PureComponent<Props> {
-  handleGraphChange = (option: { value: string }) => {
-    if (option.value !== this.props.graph) {
-      this.props.onUpdateGraph(option.value);
-    }
-  };
+export default function GraphsHeader(props: Props) {
+  const {
+    className,
+    graph,
+    metrics,
+    metricsTypeFilter,
+    onUpdateGraph,
+    selectedMetrics = [],
+  } = props;
 
-  render() {
-    const { className, graph, metrics, metricsTypeFilter, selectedMetrics = [] } = this.props;
+  const handleGraphChange = React.useCallback(
+    (value: GraphType) => {
+      if (value !== graph) {
+        onUpdateGraph(value);
+      }
+    },
+    [graph, onUpdateGraph]
+  );
 
-    const noCustomGraph =
-      this.props.onAddCustomMetric === undefined || this.props.onRemoveCustomMetric === undefined;
+  const noCustomGraph =
+    props.onAddCustomMetric === undefined || props.onRemoveCustomMetric === undefined;
 
+  const options = React.useMemo(() => {
     const types = getGraphTypes(noCustomGraph);
 
-    const overlayItems: JSX.Element[] = [];
-
-    const selectOptions: Array<{
-      label: string;
-      value: GraphType;
-    }> = [];
-
-    types.forEach((type) => {
+    return types.map((type) => {
       const label = translate('project_activity.graphs', type);
 
-      selectOptions.push({ label, value: type });
-
-      overlayItems.push(
-        <ItemButton key={label} onClick={() => this.handleGraphChange({ value: type })}>
+      return (
+        <ItemButton key={label} onClick={() => handleGraphChange(type)}>
           {label}
         </ItemButton>
       );
     });
+  }, [noCustomGraph, handleGraphChange]);
 
-    const selectedOption = selectOptions.find((option) => option.value === graph);
-    const selectedLabel = selectedOption?.label ?? '';
+  return (
+    <div className={className}>
+      <div className="sw-flex">
+        <Dropdown
+          id="activity-graph-type"
+          size="auto"
+          placement={PopupPlacement.BottomLeft}
+          zLevel={PopupZLevel.Content}
+          overlay={options}
+        >
+          <ButtonSecondary
+            aria-label={translate('project_activity.graphs.choose_type')}
+            className={
+              'sw-body-sm sw-flex sw-flex-row sw-justify-between sw-pl-3 sw-pr-2 sw-w-32 ' +
+              'sw-z-normal' // needed because the legends overlap part of the button
+            }
+          >
+            <TextMuted text={translate('project_activity.graphs', graph)} />
+            <ChevronDownIcon className="sw-ml-1 sw-mr-0 sw-pr-0" />
+          </ButtonSecondary>
+        </Dropdown>
 
-    return (
-      <div className={className}>
-        <div className="display-flex-end">
-          <div className="display-flex-column">
-            {noCustomGraph ? (
-              <Dropdown
-                id="activity-graph-type"
-                size="auto"
-                placement={PopupPlacement.BottomLeft}
-                zLevel={PopupZLevel.Content}
-                overlay={overlayItems}
-              >
-                <ButtonSecondary
-                  aria-label={translate('project_activity.graphs.choose_type')}
-                  className={
-                    'sw-body-sm sw-flex sw-flex-row sw-justify-between sw-pl-3 sw-pr-2 sw-w-32 ' +
-                    'sw-z-normal' // needed because the legends overlap part of the button
-                  }
-                >
-                  <TextMuted text={selectedLabel} />
-                  <ChevronDownIcon className="sw-ml-1 sw-mr-0 sw-pr-0" />
-                </ButtonSecondary>
-              </Dropdown>
-            ) : (
-              <Select
-                aria-label={translate('project_activity.graphs.choose_type')}
-                className="input-medium"
-                isSearchable={false}
-                onChange={this.handleGraphChange}
-                options={selectOptions}
-                value={selectedOption}
-              />
-            )}
-          </div>
-          {isCustomGraph(graph) &&
-            this.props.onAddCustomMetric !== undefined &&
-            this.props.onRemoveCustomMetric !== undefined && (
-              <AddGraphMetric
-                onAddMetric={this.props.onAddCustomMetric}
-                metrics={metrics}
-                metricsTypeFilter={metricsTypeFilter}
-                onRemoveMetric={this.props.onRemoveCustomMetric}
-                selectedMetrics={selectedMetrics}
-              />
-            )}
-        </div>
+        {isCustomGraph(graph) &&
+          props.onAddCustomMetric !== undefined &&
+          props.onRemoveCustomMetric !== undefined && (
+            <AddGraphMetric
+              onAddMetric={props.onAddCustomMetric}
+              metrics={metrics}
+              metricsTypeFilter={metricsTypeFilter}
+              onRemoveMetric={props.onRemoveCustomMetric}
+              selectedMetrics={selectedMetrics}
+            />
+          )}
       </div>
-    );
-  }
+    </div>
+  );
 }
index e216dbf51ea356680251350b62071ffe6fd2c2e2..a272da3be622aba3ec42f8cc32a6ce9cc1da68f1 100644 (file)
  */
 
 import { useTheme } from '@emotion/react';
+import styled from '@emotion/styled';
 import classNames from 'classnames';
-import { Theme, themeColor } from 'design-system';
+import { CloseIcon, DestructiveIcon, FlagWarningIcon, Theme, themeColor } from 'design-system';
 import * as React from 'react';
-import { ClearButton } from '../../components/controls/buttons';
-import AlertWarnIcon from '../../components/icons/AlertWarnIcon';
 import { ChartLegendIcon } from '../../components/icons/ChartLegendIcon';
 import { translateWithParameters } from '../../helpers/l10n';
 
@@ -48,29 +47,31 @@ export function GraphsLegendItem({
 
   const isActionable = removeMetric !== undefined;
 
-  const legendClass = classNames({ 'activity-graph-legend-actionable': isActionable }, className);
-
   return (
-    <span className={legendClass}>
+    <StyledLegendItem className={classNames('sw-px-2 sw-py-1 sw-rounded-pill', className)}>
       {showWarning ? (
-        <AlertWarnIcon className="sw-mr-2" />
+        <FlagWarningIcon className="sw-mx-2" />
       ) : (
-        <ChartLegendIcon className="sw-align-middle sw-mr-2" index={index} />
+        <ChartLegendIcon className="sw-mx-2" index={index} />
       )}
-      <span
-        className="sw-align-middle sw-body-sm"
-        style={{ color: themeColor('graphCursorLineColor')({ theme }) }}
-      >
+      <span className="sw-body-sm" style={{ color: themeColor('graphCursorLineColor')({ theme }) }}>
         {name}
       </span>
       {isActionable && (
-        <ClearButton
+        <DestructiveIcon
+          Icon={CloseIcon}
           aria-label={translateWithParameters('project_activity.graphs.custom.remove_metric', name)}
-          className="button-tiny sw-align-middle sw-ml-2"
-          iconProps={{ size: 12 }}
+          className="sw-ml-2"
+          size="small"
           onClick={() => removeMetric(metric)}
         />
       )}
-    </span>
+    </StyledLegendItem>
   );
 }
+
+const StyledLegendItem = styled.div`
+  display: flex;
+  align-items: center;
+  border: 1px solid ${themeColor('graphLegendBorder')};
+`;
index c4256bf96f84a614558c8b45d0c9e152b0a9da5e..7292dba8360564d5860bdf70d9adc1f16149d89a 100644 (file)
@@ -21,12 +21,12 @@ import { screen } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { times } from 'lodash';
 import * as React from 'react';
-import selectEvent from 'react-select-event';
 import { parseDate } from '../../../helpers/dates';
 import { mockHistoryItem, mockMeasureHistory } from '../../../helpers/mocks/project-activity';
 import { mockMetric } from '../../../helpers/testMocks';
 import { renderComponent } from '../../../helpers/testReactTestingUtils';
 import { byLabelText, byPlaceholderText, byRole, byText } from '../../../helpers/testSelector';
+import { FCProps } from '../../../helpers/testUtils';
 import { MetricKey } from '../../../types/metrics';
 import { GraphType, MeasureHistory } from '../../../types/project-activity';
 import { Metric } from '../../../types/types';
@@ -143,9 +143,7 @@ it('should correctly handle adding/removing custom metrics', async () => {
   // We cannot select anymore options. It should disable all remaining options, and
   // show a different alert.
   expect(ui.maxOptionsAlert.get()).toBeInTheDocument();
-  // See https://github.com/testing-library/jest-dom/issues/144 for why we cannot
-  // use isDisabled().
-  expect(ui.vulnerabilityCheckbox.get()).toHaveAttribute('aria-disabled', 'true');
+  expect(ui.vulnerabilityCheckbox.get()).toBeDisabled();
 
   // Disable a few options.
   await ui.clickOnMetric(MetricKey.bugs);
@@ -212,7 +210,9 @@ function getPageObject() {
     ui: {
       ...ui,
       async changeGraphType(type: GraphType) {
-        await selectEvent.select(ui.graphTypeSelect.get(), [`project_activity.graphs.${type}`]);
+        await user.click(ui.graphTypeSelect.get());
+        const optionForType = await screen.findByText(`project_activity.graphs.${type}`);
+        await user.click(optionForType);
       },
       async openAddMetrics() {
         await user.click(ui.addMetricBtn.get());
@@ -238,7 +238,7 @@ function getPageObject() {
 
 function renderActivityGraph(
   graphsHistoryProps: Partial<GraphsHistory['props']> = {},
-  graphsHeaderProps: Partial<GraphsHeader['props']> = {}
+  graphsHeaderProps: Partial<FCProps<typeof GraphsHeader>> = {}
 ) {
   function ActivityGraph() {
     const [selectedMetrics, setSelectedMetrics] = React.useState<string[]>([]);
index 22e86d7bdde6d7976007c2f920e82ad366ac5050..83cf694261eaf10e7193417e2f4c305769addc5c 100644 (file)
@@ -189,13 +189,13 @@ describe('saveActivityGraph', () => {
     expect(save).toHaveBeenCalledWith('foo', GraphType.issues, 'bar');
   });
 
-  it.each([undefined, [MetricKey.bugs, MetricKey.alert_status]])(
+  it.each([[[]], [[MetricKey.bugs, MetricKey.alert_status]]])(
     'should correctly store data for custom graph types',
     (metrics) => {
       utils.saveActivityGraph('foo', 'bar', GraphType.custom, metrics);
       expect(save).toHaveBeenCalledWith('foo', GraphType.custom, 'bar');
       // eslint-disable-next-line jest/no-conditional-in-test
-      expect(save).toHaveBeenCalledWith('foo.custom', metrics ? metrics.join(',') : '', 'bar');
+      expect(save).toHaveBeenCalledWith('foo.custom', metrics.join(','), 'bar');
     }
   );
 });
index a24dfad62f55dbd6beed378abd34bbec2c7f1df9..d7628fab4f6cd85d300c94d51e79bb117c8bc023 100644 (file)
@@ -148,11 +148,11 @@ export function saveActivityGraph(
   namespace: string,
   project: string,
   graph: GraphType,
-  metrics: string[] = []
+  metrics?: string[]
 ) {
   save(namespace, graph, project);
 
-  if (isCustomGraph(graph)) {
+  if (isCustomGraph(graph) && metrics) {
     save(`${namespace}.custom`, metrics.join(','), project);
   }
 }
index ce097cb4ada2149ceb4fab826af710516ef3ee8a..5a333151ab8f5a759e5972993c94d07e231b8717 100644 (file)
@@ -154,6 +154,7 @@ export enum MetricType {
   ShortInteger = 'SHORT_INT',
   ShortWorkDuration = 'SHORT_WORK_DUR',
   Data = 'DATA',
+  Distribution = 'DISTRIB',
 }
 
 export function isMetricKey(key: string): key is MetricKey {