mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
GrafanaUI: Add select all toggle to selectbox (#92483)
* Add select all toggle
This commit is contained in:
parent
d724d463b1
commit
acbd50c7d6
@ -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"]',
|
||||
},
|
||||
|
@ -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>>>();
|
||||
|
||||
|
@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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}
|
||||
|
@ -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,
|
||||
|
@ -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',
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
@ -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;
|
||||
|
@ -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",
|
||||
|
@ -2257,6 +2257,11 @@
|
||||
"placeholder": "Ŝęäřčĥ ƒőř đäşĥþőäřđş äʼnđ ƒőľđęřş"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"select-menu": {
|
||||
"selected-count": "Ŝęľęčŧęđ "
|
||||
}
|
||||
},
|
||||
"service-accounts": {
|
||||
"empty-state": {
|
||||
"button-title": "Åđđ şęřvįčę äččőūʼnŧ",
|
||||
|
Loading…
Reference in New Issue
Block a user