From acbd50c7d6a8b16f5e05fee64c926529c1a8a52b Mon Sep 17 00:00:00 2001 From: Oscar Kilhed Date: Tue, 10 Sep 2024 20:33:17 +0200 Subject: [PATCH] GrafanaUI: Add select all toggle to selectbox (#92483) * Add select all toggle --- .../src/selectors/components.ts | 1 + .../src/components/Select/Select.story.tsx | 27 +++++ .../src/components/Select/SelectBase.test.tsx | 92 ++++++++++++++ .../src/components/Select/SelectBase.tsx | 45 ++++++- .../src/components/Select/SelectMenu.tsx | 113 +++++++++++++++++- .../src/components/Select/getSelectStyles.ts | 6 + .../grafana-ui/src/components/Select/types.ts | 14 +++ public/locales/en-US/grafana.json | 5 + public/locales/pseudo-LOCALE/grafana.json | 5 + 9 files changed, 302 insertions(+), 6 deletions(-) diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index d0c9cd1f8f3..bb6bd78a7b9 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -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"]', }, diff --git a/packages/grafana-ui/src/components/Select/Select.story.tsx b/packages/grafana-ui/src/components/Select/Select.story.tsx index ac0a7277c40..975d81d9ba2 100644 --- a/packages/grafana-ui/src/components/Select/Select.story.tsx +++ b/packages/grafana-ui/src/components/Select/Select.story.tsx @@ -311,6 +311,33 @@ MultiSelectBasic.args = { noMultiValueWrap: false, }; +export const MultiSelectBasicWithSelectAll: StoryFn = (args) => { + const [value, setValue] = useState>>([]); + + return ( +
+ { + setValue(v); + action('onChange')(v); + }} + prefix={getPrefix(args.icon)} + {...args} + /> +
+ ); +}; + +MultiSelectBasicWithSelectAll.args = { + isClearable: false, + closeMenuOnSelect: false, + maxVisibleValues: 5, + noMultiValueWrap: false, +}; + export const MultiSelectAsync: StoryFn = (args) => { const [value, setValue] = useState>>(); diff --git a/packages/grafana-ui/src/components/Select/SelectBase.test.tsx b/packages/grafana-ui/src/components/Select/SelectBase.test.tsx index 895e0d055b1..ac41aa47c03 100644 --- a/packages/grafana-ui/src/components/Select/SelectBase.test.tsx +++ b/packages/grafana-ui/src/components/Select/SelectBase.test.tsx @@ -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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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()); + }); + }); }); }); diff --git a/packages/grafana-ui/src/components/Select/SelectBase.tsx b/packages/grafana-ui/src/components/Select/SelectBase.tsx index a37634653d6..0fe0dea431e 100644 --- a/packages/grafana-ui/src/components/Select/SelectBase.tsx +++ b/packages/grafana-ui/src/components/Select/SelectBase.tsx @@ -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({ allowCustomValue = false, allowCreateWhileLoading = false, @@ -126,6 +137,7 @@ export function SelectBase({ onMenuScrollToTop, onOpenMenu, onFocus, + toggleAllOptions, openMenuOnFocus = false, options = [], placeholder = t('grafana-ui.select.placeholder', 'Choose'), @@ -295,6 +307,30 @@ export function SelectBase({ 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 ( <> ({ Input: CustomInput, ...components, }} + toggleAllOptions={ + toggleAllOptions?.enabled && { + state: toggleAllState, + selectAllClicked: toggleAll, + selectedCount: isArray(selectedValue) ? selectedValue.length : undefined, + } + } styles={selectStyles} className={className} {...commonSelectProps} diff --git a/packages/grafana-ui/src/components/Select/SelectMenu.tsx b/packages/grafana-ui/src/components/Select/SelectMenu.tsx index 161345219f2..95fdd41ecf6 100644 --- a/packages/grafana-ui/src/components/Select/SelectMenu.tsx +++ b/packages/grafana-ui/src/components/Select/SelectMenu.tsx @@ -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; innerProps: {}; + selectProps: { + toggleAllOptions?: ToggleAllOptions; + components?: { Option?: (props: React.PropsWithChildren>) => JSX.Element }; + }; } -export const SelectMenu = ({ children, maxHeight, innerRef, innerProps }: React.PropsWithChildren) => { +export const SelectMenu = ({ + children, + maxHeight, + innerRef, + innerProps, + selectProps, +}: React.PropsWithChildren) => { const theme = useTheme2(); const styles = getSelectStyles(theme); + const { toggleAllOptions, components } = selectProps; + + const optionsElement = components?.Option ?? SelectMenuOptions; + return (
+ {toggleAllOptions && ( + + )} {children}
@@ -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 { + children: React.ReactNode; + innerRef: React.Ref; + focusedOption: T; + innerProps: JSX.IntrinsicElements['div']; + options: T[]; + maxHeight: number; + selectProps: { + toggleAllOptions?: ToggleAllOptions; + components?: { Option?: (props: React.PropsWithChildren>) => JSX.Element }; + }; +} + export const VirtualizedSelectMenu = ({ children, maxHeight, innerRef: scrollRef, options, + selectProps, focusedOption, -}: MenuListProps) => { +}: VirtualSelectMenuProps) => { const theme = useTheme2(); const styles = getSelectStyles(theme); const listRef = useRef(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( + + ); + } + + 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 { isDisabled: boolean; isFocused: boolean; isSelected: boolean; + indeterminate?: boolean; innerProps: JSX.IntrinsicElements['div']; innerRef: RefCallback; renderOptionLabel?: (value: SelectableValue) => JSX.Element; data: SelectableValue; } +const ToggleAllOption = ({ + state, + onClick, + selectedCount, + optionComponent, +}: { + state: ToggleAllState; + onClick: () => void; + selectedCount?: number; + optionComponent: (props: React.PropsWithChildren>) => JSX.Element; +}) => { + const theme = useTheme2(); + const styles = getSelectStyles(theme); + + return ( + + ); +}; + export const SelectMenuOptions = ({ children, data, diff --git a/packages/grafana-ui/src/components/Select/getSelectStyles.ts b/packages/grafana-ui/src/components/Select/getSelectStyles.ts index a75b79417a3..32e30c1da0e 100644 --- a/packages/grafana-ui/src/components/Select/getSelectStyles.ts +++ b/packages/grafana-ui/src/components/Select/getSelectStyles.ts @@ -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', + }), }; }); diff --git a/packages/grafana-ui/src/components/Select/types.ts b/packages/grafana-ui/src/components/Select/types.ts index ae97e63adfd..b5183d97ea3 100644 --- a/packages/grafana-ui/src/components/Select/types.ts +++ b/packages/grafana-ui/src/components/Select/types.ts @@ -15,6 +15,12 @@ export type InputActionMeta = { }; export type LoadOptionsCallback = (options: Array>) => void; +export enum ToggleAllState { + allSelected = 'allSelected', + indeterminate = 'indeterminate', + noneSelected = 'noneSelected', +} + export interface SelectCommonProps { /** Aria label applied to the input field */ ['aria-label']?: string; @@ -78,6 +84,14 @@ export interface SelectCommonProps { onMenuScrollToTop?: (event: WheelEvent | TouchEvent) => void; onOpenMenu?: () => void; onFocus?: () => void; + toggleAllOptions?: { + enabled: boolean; + optionsFilter?: (v: SelectableValue) => boolean; + determineToggleAllState?: ( + selectedValues: Array>, + options: Array> + ) => ToggleAllState; + }; openMenuOnFocus?: boolean; options?: Array>; placeholder?: string; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index c162a553f37..b23d54a50f6 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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", diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 83d7c6a4e0b..0f2fa462f51 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -2257,6 +2257,11 @@ "placeholder": "Ŝęäřčĥ ƒőř đäşĥþőäřđş äʼnđ ƒőľđęřş" } }, + "select": { + "select-menu": { + "selected-count": "Ŝęľęčŧęđ " + } + }, "service-accounts": { "empty-state": { "button-title": "Åđđ şęřvįčę äččőūʼnŧ",