diff options
author | Jeremy Davis <jeremy.davis@sonarsource.com> | 2023-06-19 11:17:47 +0200 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2023-06-26 20:03:54 +0000 |
commit | 3b8f363175f8ef01fe4e888bccfd693bb9953852 (patch) | |
tree | 5300921de4e8a7fbb9c5148874d1c7b1f8d5301b | |
parent | df412e40611054143f326a2d364028b64f4a8bac (diff) | |
download | sonarqube-3b8f363175f8ef01fe4e888bccfd693bb9953852.tar.gz sonarqube-3b8f363175f8ef01fe4e888bccfd693bb9953852.zip |
SONAR-19604 New UI for the Activity Graph header
13 files changed, 155 insertions, 140 deletions
diff --git a/server/sonar-web/design-system/src/components/MultiSelectMenu.tsx b/server/sonar-web/design-system/src/components/MultiSelectMenu.tsx index b0c77bfcd74..ecfaa459512 100644 --- a/server/sonar-web/design-system/src/components/MultiSelectMenu.tsx +++ b/server/sonar-web/design-system/src/components/MultiSelectMenu.tsx @@ -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 && ( diff --git a/server/sonar-web/design-system/src/components/MultiSelectMenuOption.tsx b/server/sonar-web/design-system/src/components/MultiSelectMenuOption.tsx index 27147703f96..e68dd7c3687 100644 --- a/server/sonar-web/design-system/src/components/MultiSelectMenuOption.tsx +++ b/server/sonar-web/design-system/src/components/MultiSelectMenuOption.tsx @@ -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> ); diff --git a/server/sonar-web/design-system/src/theme/light.ts b/server/sonar-web/design-system/src/theme/light.ts index ac7d1fdd012..b7acbc729b2 100644 --- a/server/sonar-web/design-system/src/theme/light.ts +++ b/server/sonar-web/design-system/src/theme/light.ts @@ -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], diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphs.tsx b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphs.tsx index 3f54362aef6..3acdcfb0e13 100644 --- a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphs.tsx +++ b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphs.tsx @@ -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; diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/ProjectActivityApp-it.tsx b/server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/ProjectActivityApp-it.tsx index f1c8eeb9311..2fd8e4b83c1 100644 --- a/server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/ProjectActivityApp-it.tsx +++ b/server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/ProjectActivityApp-it.tsx @@ -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() { diff --git a/server/sonar-web/src/main/js/components/activity-graph/AddGraphMetric.tsx b/server/sonar-web/src/main/js/components/activity-graph/AddGraphMetric.tsx index 22f33da079a..57c72a6dc34 100644 --- a/server/sonar-web/src/main/js/components/activity-graph/AddGraphMetric.tsx +++ b/server/sonar-web/src/main/js/components/activity-graph/AddGraphMetric.tsx @@ -17,13 +17,12 @@ * 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> ); } diff --git a/server/sonar-web/src/main/js/components/activity-graph/AddGraphMetricPopup.tsx b/server/sonar-web/src/main/js/components/activity-graph/AddGraphMetricPopup.tsx index 583b849a2f4..f762e83d855 100644 --- a/server/sonar-web/src/main/js/components/activity-graph/AddGraphMetricPopup.tsx +++ b/server/sonar-web/src/main/js/components/activity-graph/AddGraphMetricPopup.tsx @@ -17,10 +17,9 @@ * 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} + /> ); } diff --git a/server/sonar-web/src/main/js/components/activity-graph/GraphsHeader.tsx b/server/sonar-web/src/main/js/components/activity-graph/GraphsHeader.tsx index 971dbd53157..e2165221004 100644 --- a/server/sonar-web/src/main/js/components/activity-graph/GraphsHeader.tsx +++ b/server/sonar-web/src/main/js/components/activity-graph/GraphsHeader.tsx @@ -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> + ); } diff --git a/server/sonar-web/src/main/js/components/activity-graph/GraphsLegendItem.tsx b/server/sonar-web/src/main/js/components/activity-graph/GraphsLegendItem.tsx index e216dbf51ea..a272da3be62 100644 --- a/server/sonar-web/src/main/js/components/activity-graph/GraphsLegendItem.tsx +++ b/server/sonar-web/src/main/js/components/activity-graph/GraphsLegendItem.tsx @@ -19,11 +19,10 @@ */ 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')}; +`; diff --git a/server/sonar-web/src/main/js/components/activity-graph/__tests__/ActivityGraph-it.tsx b/server/sonar-web/src/main/js/components/activity-graph/__tests__/ActivityGraph-it.tsx index c4256bf96f8..7292dba8360 100644 --- a/server/sonar-web/src/main/js/components/activity-graph/__tests__/ActivityGraph-it.tsx +++ b/server/sonar-web/src/main/js/components/activity-graph/__tests__/ActivityGraph-it.tsx @@ -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[]>([]); diff --git a/server/sonar-web/src/main/js/components/activity-graph/__tests__/utils-test.ts b/server/sonar-web/src/main/js/components/activity-graph/__tests__/utils-test.ts index 22e86d7bdde..83cf694261e 100644 --- a/server/sonar-web/src/main/js/components/activity-graph/__tests__/utils-test.ts +++ b/server/sonar-web/src/main/js/components/activity-graph/__tests__/utils-test.ts @@ -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'); } ); }); diff --git a/server/sonar-web/src/main/js/components/activity-graph/utils.ts b/server/sonar-web/src/main/js/components/activity-graph/utils.ts index a24dfad62f5..d7628fab4f6 100644 --- a/server/sonar-web/src/main/js/components/activity-graph/utils.ts +++ b/server/sonar-web/src/main/js/components/activity-graph/utils.ts @@ -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); } } diff --git a/server/sonar-web/src/main/js/types/metrics.ts b/server/sonar-web/src/main/js/types/metrics.ts index ce097cb4ada..5a333151ab8 100644 --- a/server/sonar-web/src/main/js/types/metrics.ts +++ b/server/sonar-web/src/main/js/types/metrics.ts @@ -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 { |