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);
key={element}
onHover={this.handleElementHover}
onSelectChange={this.handleSelectChange}
+ renderLabel={renderLabel}
selected
/>
))}
key={element}
onHover={this.handleElementHover}
onSelectChange={this.handleSelectChange}
+ renderLabel={renderLabel}
/>
))}
{showNewElement && (
* 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 {
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
{element}
</span>
) : (
- <span className="sw-ml-3">{element}</span>
+ <span className="sw-ml-3">{label}</span>
)}
</ItemCheckbox>
);
graphZoomBackgroundColor: COLORS.blueGrey[25],
graphZoomBorderColor: COLORS.blueGrey[100],
graphZoomHandleColor: COLORS.blueGrey[400],
+ graphLegendBorder: secondary.darker,
// page
pageTitle: COLORS.blueGrey[700],
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 {
'x'
);
return {
- graphEndDate: lastValid && lastValid.x,
- graphStartDate: firstValid && firstValid.x,
+ graphEndDate: lastValid?.x,
+ graphStartDate: firstValid?.x,
};
}
return null;
},
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() {
* 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';
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())
) {
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}
/>
}
>
- <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>
);
}
* 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[];
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
.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}
+ />
);
}
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';
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>
+ );
}
*/
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';
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')};
+`;
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';
// 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);
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());
function renderActivityGraph(
graphsHistoryProps: Partial<GraphsHistory['props']> = {},
- graphsHeaderProps: Partial<GraphsHeader['props']> = {}
+ graphsHeaderProps: Partial<FCProps<typeof GraphsHeader>> = {}
) {
function ActivityGraph() {
const [selectedMetrics, setSelectedMetrics] = React.useState<string[]>([]);
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');
}
);
});
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);
}
}
ShortInteger = 'SHORT_INT',
ShortWorkDuration = 'SHORT_WORK_DUR',
Data = 'DATA',
+ Distribution = 'DISTRIB',
}
export function isMetricKey(key: string): key is MetricKey {