GrafanaUI: Add select all toggle to selectbox (#92483)

* Add select all toggle
This commit is contained in:
Oscar Kilhed 2024-09-10 20:33:17 +02:00 committed by GitHub
parent d724d463b1
commit acbd50c7d6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 302 additions and 6 deletions

View File

@ -422,6 +422,7 @@ export const Components = {
},
Select: {
option: 'data-testid Select option',
toggleAllOptions: 'data-testid toggle all options',
input: () => 'input[id*="time-options-input"]',
singleValue: () => 'div[class*="-singleValue"]',
},

View File

@ -311,6 +311,33 @@ MultiSelectBasic.args = {
noMultiValueWrap: false,
};
export const MultiSelectBasicWithSelectAll: StoryFn = (args) => {
const [value, setValue] = useState<Array<SelectableValue<string>>>([]);
return (
<div style={{ maxWidth: '450px' }}>
<MultiSelect
options={generateOptions()}
value={value}
toggleAllOptions={{ enabled: true }}
onChange={(v) => {
setValue(v);
action('onChange')(v);
}}
prefix={getPrefix(args.icon)}
{...args}
/>
</div>
);
};
MultiSelectBasicWithSelectAll.args = {
isClearable: false,
closeMenuOnSelect: false,
maxVisibleValues: 5,
noMultiValueWrap: false,
};
export const MultiSelectAsync: StoryFn = (args) => {
const [value, setValue] = useState<Array<SelectableValue<string>>>();

View File

@ -244,6 +244,7 @@ describe('SelectBase', () => {
removedValue: { label: 'Option 1', value: 1 },
});
});
it('does not allow deleting selected values when disabled', async () => {
const value = [
{
@ -264,5 +265,96 @@ describe('SelectBase', () => {
expect(screen.queryByLabelText('Remove Option 1')).not.toBeInTheDocument();
});
describe('toggle all', () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('renders menu with select all toggle', async () => {
render(
<SelectBase
options={options}
isMulti={true}
toggleAllOptions={{ enabled: true }}
onChange={onChangeHandler}
/>
);
await userEvent.click(screen.getByText(/choose/i));
const toggleAllOptions = screen.getByTestId(selectors.components.Select.toggleAllOptions);
expect(toggleAllOptions).toBeInTheDocument();
});
it('correctly displays the number of selected items', async () => {
render(
<SelectBase
options={options}
isMulti={true}
value={[1]}
toggleAllOptions={{ enabled: true }}
onChange={onChangeHandler}
/>
);
await userEvent.click(screen.getByText(/Option 1/i));
const toggleAllOptions = screen.getByTestId(selectors.components.Select.toggleAllOptions);
expect(toggleAllOptions.textContent).toBe('Selected (1)');
});
it('correctly removes all selected options when in indeterminate state', async () => {
render(
<SelectBase
options={options}
isMulti={true}
value={[1]}
toggleAllOptions={{ enabled: true }}
onChange={onChangeHandler}
/>
);
await userEvent.click(screen.getByText(/Option 1/i));
let toggleAllOptions = screen.getByTestId(selectors.components.Select.toggleAllOptions);
expect(toggleAllOptions.textContent).toBe('Selected (1)');
// Toggle all unselected when in indeterminate state
await userEvent.click(toggleAllOptions);
expect(onChangeHandler).toHaveBeenCalledWith([], expect.anything());
});
it('correctly removes all selected options when all options are selected', async () => {
render(
<SelectBase
options={options}
isMulti={true}
value={[1, 2]}
toggleAllOptions={{ enabled: true }}
onChange={onChangeHandler}
/>
);
await userEvent.click(screen.getByText(/Option 1/i));
let toggleAllOptions = screen.getByTestId(selectors.components.Select.toggleAllOptions);
expect(toggleAllOptions.textContent).toBe('Selected (2)');
// Toggle all unselected when in indeterminate state
await userEvent.click(toggleAllOptions);
expect(onChangeHandler).toHaveBeenCalledWith([], expect.anything());
});
it('correctly selects all values when none are selected', async () => {
render(
<SelectBase
options={options}
isMulti={true}
value={[]}
toggleAllOptions={{ enabled: true }}
onChange={onChangeHandler}
/>
);
await userEvent.click(screen.getByText(/Choose/i));
let toggleAllOptions = screen.getByTestId(selectors.components.Select.toggleAllOptions);
expect(toggleAllOptions.textContent).toBe('Selected (0)');
// Toggle all unselected when in indeterminate state
await userEvent.click(toggleAllOptions);
expect(onChangeHandler).toHaveBeenCalledWith(options, expect.anything());
});
});
});
});

View File

@ -1,4 +1,5 @@
import { t } from 'i18next';
import { isArray, negate } from 'lodash';
import { ComponentProps, useCallback, useEffect, useRef, useState } from 'react';
import * as React from 'react';
import {
@ -30,7 +31,7 @@ import { Props, SingleValue } from './SingleValue';
import { ValueContainer } from './ValueContainer';
import { getSelectStyles } from './getSelectStyles';
import { useCustomSelectStyles } from './resetSelectStyles';
import { ActionMeta, InputActionMeta, SelectBaseProps } from './types';
import { ActionMeta, InputActionMeta, SelectBaseProps, ToggleAllState } from './types';
import { cleanValue, findSelectedValue, omitDescriptions } from './utils';
const CustomControl = (props: any) => {
@ -77,6 +78,16 @@ interface SelectPropsWithExtras extends ReactSelectProps {
noMultiValueWrap?: boolean;
}
function determineToggleAllState(selectedValue: SelectableValue[], options: SelectableValue[]) {
if (options.length === selectedValue.length) {
return ToggleAllState.allSelected;
} else if (selectedValue.length === 0) {
return ToggleAllState.noneSelected;
} else {
return ToggleAllState.indeterminate;
}
}
export function SelectBase<T, Rest = {}>({
allowCustomValue = false,
allowCreateWhileLoading = false,
@ -126,6 +137,7 @@ export function SelectBase<T, Rest = {}>({
onMenuScrollToTop,
onOpenMenu,
onFocus,
toggleAllOptions,
openMenuOnFocus = false,
options = [],
placeholder = t('grafana-ui.select.placeholder', 'Choose'),
@ -295,6 +307,30 @@ export function SelectBase<T, Rest = {}>({
const SelectMenuComponent = virtualized ? VirtualizedSelectMenu : SelectMenu;
let toggleAllState = ToggleAllState.noneSelected;
if (toggleAllOptions?.enabled && isArray(selectedValue)) {
if (toggleAllOptions?.determineToggleAllState) {
toggleAllState = toggleAllOptions.determineToggleAllState(selectedValue, options);
} else {
toggleAllState = determineToggleAllState(selectedValue, options);
}
}
const toggleAll = useCallback(() => {
let toSelect = toggleAllState === ToggleAllState.noneSelected ? options : [];
if (toggleAllOptions?.optionsFilter) {
toSelect =
toggleAllState === ToggleAllState.noneSelected
? options.filter(toggleAllOptions.optionsFilter)
: options.filter(negate(toggleAllOptions.optionsFilter));
}
onChange(toSelect, {
action: 'select-option',
option: {},
});
}, [options, toggleAllOptions, onChange, toggleAllState]);
return (
<>
<ReactSelectComponent
@ -347,6 +383,13 @@ export function SelectBase<T, Rest = {}>({
Input: CustomInput,
...components,
}}
toggleAllOptions={
toggleAllOptions?.enabled && {
state: toggleAllState,
selectAllClicked: toggleAll,
selectedCount: isArray(selectedValue) ? selectedValue.length : undefined,
}
}
styles={selectStyles}
className={className}
{...commonSelectProps}

View File

@ -1,32 +1,62 @@
import { cx } from '@emotion/css';
import { css, cx } from '@emotion/css';
import { max } from 'lodash';
import { RefCallback, useLayoutEffect, useMemo, useRef } from 'react';
import * as React from 'react';
import { MenuListProps } from 'react-select';
import { FixedSizeList as List } from 'react-window';
import { SelectableValue, toIconName } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { useTheme2 } from '../../themes/ThemeContext';
import { Trans } from '../../utils/i18n';
import { clearButtonStyles } from '../Button';
import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
import { Icon } from '../Icon/Icon';
import { getSelectStyles } from './getSelectStyles';
import { ToggleAllState } from './types';
export interface ToggleAllOptions {
state: ToggleAllState;
selectAllClicked: () => void;
selectedCount?: number;
}
interface SelectMenuProps {
maxHeight: number;
innerRef: RefCallback<HTMLDivElement>;
innerProps: {};
selectProps: {
toggleAllOptions?: ToggleAllOptions;
components?: { Option?: (props: React.PropsWithChildren<SelectMenuOptionProps<unknown>>) => JSX.Element };
};
}
export const SelectMenu = ({ children, maxHeight, innerRef, innerProps }: React.PropsWithChildren<SelectMenuProps>) => {
export const SelectMenu = ({
children,
maxHeight,
innerRef,
innerProps,
selectProps,
}: React.PropsWithChildren<SelectMenuProps>) => {
const theme = useTheme2();
const styles = getSelectStyles(theme);
const { toggleAllOptions, components } = selectProps;
const optionsElement = components?.Option ?? SelectMenuOptions;
return (
<div {...innerProps} className={styles.menu} style={{ maxHeight }} aria-label="Select options menu">
<CustomScrollbar scrollRefCallback={innerRef} autoHide={false} autoHeightMax="inherit" hideHorizontalTrack>
{toggleAllOptions && (
<ToggleAllOption
state={toggleAllOptions.state}
optionComponent={optionsElement}
selectedCount={toggleAllOptions.selectedCount}
onClick={toggleAllOptions.selectAllClicked}
></ToggleAllOption>
)}
{children}
</CustomScrollbar>
</div>
@ -49,16 +79,33 @@ const VIRTUAL_LIST_WIDTH_EXTRA = 58;
//
// VIRTUAL_LIST_ITEM_HEIGHT and WIDTH_ESTIMATE_MULTIPLIER are both magic numbers.
// Some characters (such as emojis and other unicode characters) may consist of multiple code points in which case the width would be inaccurate (but larger than needed).
interface VirtualSelectMenuProps<T> {
children: React.ReactNode;
innerRef: React.Ref<HTMLDivElement>;
focusedOption: T;
innerProps: JSX.IntrinsicElements['div'];
options: T[];
maxHeight: number;
selectProps: {
toggleAllOptions?: ToggleAllOptions;
components?: { Option?: (props: React.PropsWithChildren<SelectMenuOptionProps<unknown>>) => JSX.Element };
};
}
export const VirtualizedSelectMenu = ({
children,
maxHeight,
innerRef: scrollRef,
options,
selectProps,
focusedOption,
}: MenuListProps<SelectableValue>) => {
}: VirtualSelectMenuProps<SelectableValue>) => {
const theme = useTheme2();
const styles = getSelectStyles(theme);
const listRef = useRef<List>(null);
const { toggleAllOptions, components } = selectProps;
const optionComponent = components?.Option ?? SelectMenuOptions;
// we need to check for option groups (categories)
// these are top level options with child options
@ -105,7 +152,21 @@ export const VirtualizedSelectMenu = ({
return [child];
});
const longestOption = max(flattenedOptions.map((option) => option.label?.length)) ?? 0;
if (toggleAllOptions) {
flattenedChildren.unshift(
<ToggleAllOption
optionComponent={optionComponent}
state={toggleAllOptions.state}
selectedCount={toggleAllOptions.selectedCount}
onClick={toggleAllOptions.selectAllClicked}
></ToggleAllOption>
);
}
let longestOption = max(flattenedOptions.map((option) => option.label?.length)) ?? 0;
if (toggleAllOptions && longestOption < 12) {
longestOption = 12;
}
const widthEstimate =
longestOption * VIRTUAL_LIST_WIDTH_ESTIMATE_MULTIPLIER + VIRTUAL_LIST_PADDING * 2 + VIRTUAL_LIST_WIDTH_EXTRA;
const heightEstimate = Math.min(flattenedChildren.length * VIRTUAL_LIST_ITEM_HEIGHT, maxHeight);
@ -138,12 +199,54 @@ interface SelectMenuOptionProps<T> {
isDisabled: boolean;
isFocused: boolean;
isSelected: boolean;
indeterminate?: boolean;
innerProps: JSX.IntrinsicElements['div'];
innerRef: RefCallback<HTMLDivElement>;
renderOptionLabel?: (value: SelectableValue<T>) => JSX.Element;
data: SelectableValue<T>;
}
const ToggleAllOption = ({
state,
onClick,
selectedCount,
optionComponent,
}: {
state: ToggleAllState;
onClick: () => void;
selectedCount?: number;
optionComponent: (props: React.PropsWithChildren<SelectMenuOptionProps<unknown>>) => JSX.Element;
}) => {
const theme = useTheme2();
const styles = getSelectStyles(theme);
return (
<button
data-testid={selectors.components.Select.toggleAllOptions}
className={css(clearButtonStyles(theme), styles.toggleAllButton, {
height: VIRTUAL_LIST_ITEM_HEIGHT,
})}
onClick={onClick}
>
{optionComponent({
isDisabled: false,
isSelected: state === ToggleAllState.allSelected,
isFocused: false,
data: {},
indeterminate: state === ToggleAllState.indeterminate,
innerRef: () => {},
innerProps: {},
children: (
<>
<Trans i18nKey="select.select-menu.selected-count">Selected </Trans>
{`(${selectedCount ?? 0})`}
</>
),
})}
</button>
);
};
export const SelectMenuOptions = ({
children,
data,

View File

@ -163,5 +163,11 @@ export const getSelectStyles = stylesFactory((theme: GrafanaTheme2) => {
borderBottom: `1px solid ${theme.colors.border.weak}`,
},
}),
toggleAllButton: css({
width: '100%',
border: 0,
padding: 0,
textAlign: 'left',
}),
};
});

View File

@ -15,6 +15,12 @@ export type InputActionMeta = {
};
export type LoadOptionsCallback<T> = (options: Array<SelectableValue<T>>) => void;
export enum ToggleAllState {
allSelected = 'allSelected',
indeterminate = 'indeterminate',
noneSelected = 'noneSelected',
}
export interface SelectCommonProps<T> {
/** Aria label applied to the input field */
['aria-label']?: string;
@ -78,6 +84,14 @@ export interface SelectCommonProps<T> {
onMenuScrollToTop?: (event: WheelEvent | TouchEvent) => void;
onOpenMenu?: () => void;
onFocus?: () => void;
toggleAllOptions?: {
enabled: boolean;
optionsFilter?: (v: SelectableValue<T>) => boolean;
determineToggleAllState?: (
selectedValues: Array<SelectableValue<T>>,
options: Array<SelectableValue<T>>
) => ToggleAllState;
};
openMenuOnFocus?: boolean;
options?: Array<SelectableValue<T>>;
placeholder?: string;

View File

@ -2257,6 +2257,11 @@
"placeholder": "Search for dashboards and folders"
}
},
"select": {
"select-menu": {
"selected-count": "Selected "
}
},
"service-accounts": {
"empty-state": {
"button-title": "Add service account",

View File

@ -2257,6 +2257,11 @@
"placeholder": "Ŝęäřčĥ ƒőř đäşĥþőäřđş äʼnđ ƒőľđęřş"
}
},
"select": {
"select-menu": {
"selected-count": "Ŝęľęčŧęđ "
}
},
"service-accounts": {
"empty-state": {
"button-title": "Åđđ şęřvįčę äččőūʼnŧ",