From 749aeb70e9b9a98b68048e3ccd5f516530b6a505 Mon Sep 17 00:00:00 2001 From: Catherine Liu Date: Thu, 26 Jun 2025 09:24:45 -0700 Subject: [PATCH] [Controls] Bulk select for options list control (#221010) --- .../options_list_popover_action_bar.test.tsx | 133 ++++++++++++++++++ .../options_list_popover_action_bar.tsx | 117 ++++++++++++--- ...ptions_list_popover_invalid_selections.tsx | 11 +- .../options_list_control/constants.ts | 1 + .../get_options_list_control_factory.tsx | 28 ++++ .../options_list_strings.ts | 19 ++- .../options_list_control/types.ts | 2 + 7 files changed, 288 insertions(+), 23 deletions(-) create mode 100644 src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/components/options_list_popover_action_bar.test.tsx diff --git a/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/components/options_list_popover_action_bar.test.tsx b/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/components/options_list_popover_action_bar.test.tsx new file mode 100644 index 000000000000..c98783029c7f --- /dev/null +++ b/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/components/options_list_popover_action_bar.test.tsx @@ -0,0 +1,133 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { take } from 'lodash'; +import { getOptionsListContextMock } from '../../mocks/api_mocks'; +import { OptionsListControlContext } from '../options_list_context_provider'; +import type { OptionsListComponentApi } from '../types'; +import { OptionsListPopoverActionBar } from './options_list_popover_action_bar'; +import { OptionsListDisplaySettings } from '../../../../../common/options_list'; +import { MAX_OPTIONS_LIST_BULK_SELECT_SIZE } from '../constants'; + +const allOptions = [ + { value: 'moo', docCount: 1 }, + { value: 'tweet', docCount: 2 }, + { value: 'oink', docCount: 3 }, + { value: 'bark', docCount: 4 }, + { value: 'meow', docCount: 5 }, + { value: 'woof', docCount: 6 }, + { value: 'roar', docCount: 7 }, + { value: 'honk', docCount: 8 }, + { value: 'beep', docCount: 9 }, + { value: 'chirp', docCount: 10 }, + { value: 'baa', docCount: 11 }, + { value: 'toot', docCount: 11 }, +]; + +const renderComponent = ({ + componentApi, + displaySettings, + showOnlySelected, +}: { + componentApi: OptionsListComponentApi; + displaySettings: OptionsListDisplaySettings; + showOnlySelected?: boolean; +}) => { + return render( + + {}} + /> + + ); +}; + +const getSelectAllCheckbox = () => screen.getByRole('checkbox', { name: /Select all/i }); + +const getSearchInput = () => screen.getByRole('searchbox', { name: /Filter suggestions/i }); + +describe('Options list popover', () => { + test('displays search input', async () => { + const contextMock = getOptionsListContextMock(); + contextMock.componentApi.setTotalCardinality(allOptions.length); + contextMock.componentApi.setAvailableOptions(take(allOptions, 5)); + contextMock.componentApi.setSearchString('moo'); + renderComponent(contextMock); + + expect(getSearchInput()).toBeEnabled(); + expect(getSearchInput()).toHaveValue('moo'); + }); + + test('displays total cardinality for available options', async () => { + const contextMock = getOptionsListContextMock(); + contextMock.componentApi.setTotalCardinality(allOptions.length); + contextMock.componentApi.setAvailableOptions(take(allOptions, 5)); + renderComponent(contextMock); + + expect(screen.getByTestId('optionsList-cardinality-label')).toHaveTextContent( + allOptions.length.toString() + ); + }); + + test('displays "Select all" checkbox next to total cardinality', async () => { + const contextMock = getOptionsListContextMock(); + contextMock.componentApi.setTotalCardinality(80); + contextMock.componentApi.setAvailableOptions(take(allOptions, 10)); + renderComponent(contextMock); + + expect(getSelectAllCheckbox()).toBeEnabled(); + expect(getSelectAllCheckbox()).not.toBeChecked(); + }); + + test('Select all is checked when all available options are selected ', async () => { + const contextMock = getOptionsListContextMock(); + contextMock.componentApi.setTotalCardinality(80); + contextMock.componentApi.setAvailableOptions([{ value: 'moo', docCount: 1 }]); + contextMock.componentApi.setSelectedOptions(['moo']); + renderComponent(contextMock); + + expect(getSelectAllCheckbox()).toBeEnabled(); + expect(getSelectAllCheckbox()).toBeChecked(); + }); + + test('bulk selections are disabled when there are more than 100 available options', async () => { + const contextMock = getOptionsListContextMock(); + contextMock.componentApi.setTotalCardinality(MAX_OPTIONS_LIST_BULK_SELECT_SIZE + 1); + contextMock.componentApi.setAvailableOptions(take(allOptions, 10)); + renderComponent(contextMock); + + expect(getSelectAllCheckbox()).toBeDisabled(); + }); + + test('bulk selections are disabled when there are no available options', async () => { + const contextMock = getOptionsListContextMock(); + contextMock.componentApi.setTotalCardinality(0); + contextMock.componentApi.setAvailableOptions([]); + renderComponent(contextMock); + + expect(getSelectAllCheckbox()).toBeDisabled(); + }); + + test('bulk selections are disabled when showOnlySelected is true', async () => { + const contextMock = getOptionsListContextMock(); + contextMock.componentApi.setTotalCardinality(0); + contextMock.componentApi.setAvailableOptions([]); + renderComponent({ ...contextMock, showOnlySelected: true }); + + expect(getSelectAllCheckbox()).toBeDisabled(); + }); +}); diff --git a/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/components/options_list_popover_action_bar.tsx b/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/components/options_list_popover_action_bar.tsx index 5f3e168dd0ca..c81113e9749e 100644 --- a/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/components/options_list_popover_action_bar.tsx +++ b/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/components/options_list_popover_action_bar.tsx @@ -7,10 +7,11 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { EuiButtonIcon, + EuiCheckbox, EuiFieldSearch, EuiFlexGroup, EuiFlexItem, @@ -23,12 +24,16 @@ import { useBatchedPublishingSubjects, useStateFromPublishingSubject, } from '@kbn/presentation-publishing'; + +import { lastValueFrom, take } from 'rxjs'; import { css } from '@emotion/react'; import { useMemoCss } from '@kbn/css-utils/public/use_memo_css'; +import { OptionsListSuggestions } from '../../../../../common/options_list'; import { getCompatibleSearchTechniques } from '../../../../../common/options_list/suggestions_searching'; import { useOptionsListContext } from '../options_list_context_provider'; import { OptionsListPopoverSortingButton } from './options_list_popover_sorting_button'; import { OptionsListStrings } from '../options_list_strings'; +import { MAX_OPTIONS_LIST_BULK_SELECT_SIZE, MAX_OPTIONS_LIST_REQUEST_SIZE } from '../constants'; interface OptionsListPopoverProps { showOnlySelected: boolean; @@ -51,6 +56,15 @@ const optionsListPopoverStyles = { height: ${euiTheme.size.base}; border-right: ${euiTheme.border.thin}; `, + selectAllCheckbox: ({ euiTheme }: UseEuiTheme) => css` + .euiCheckbox__square { + margin-block-start: 0; + } + .euiCheckbox__label { + align-items: center; + padding-inline-start: ${euiTheme.size.xs}; + } + `, }; export const OptionsListPopoverActionBar = ({ @@ -58,6 +72,7 @@ export const OptionsListPopoverActionBar = ({ setShowOnlySelected, }: OptionsListPopoverProps) => { const { componentApi, displaySettings } = useOptionsListContext(); + const [areAllSelected, setAllSelected] = useState(false); // Using useStateFromPublishingSubject instead of useBatchedPublishingSubjects // to avoid debouncing input value @@ -66,17 +81,23 @@ export const OptionsListPopoverActionBar = ({ const [ searchTechnique, searchStringValid, - invalidSelections, + selectedOptions = [], totalCardinality, field, + fieldName, allowExpensiveQueries, + availableOptions = [], + dataLoading, ] = useBatchedPublishingSubjects( componentApi.searchTechnique$, componentApi.searchStringValid$, - componentApi.invalidSelections$, + componentApi.selectedOptions$, componentApi.totalCardinality$, componentApi.field$, - componentApi.parentApi.allowExpensiveQueries$ + componentApi.fieldName$, + componentApi.parentApi.allowExpensiveQueries$, + componentApi.availableOptions$, + componentApi.dataLoading$ ); const compatibleSearchTechniques = useMemo(() => { @@ -89,6 +110,42 @@ export const OptionsListPopoverActionBar = ({ [searchTechnique, compatibleSearchTechniques] ); + const loadMoreOptions = useCallback(async (): Promise => { + componentApi.setRequestSize(Math.min(totalCardinality, MAX_OPTIONS_LIST_REQUEST_SIZE)); + componentApi.loadMoreSubject.next(); // trigger refetch with loadMoreSubject + return lastValueFrom(componentApi.availableOptions$.pipe(take(2))); + }, [componentApi, totalCardinality]); + + const hasNoOptions = availableOptions.length < 1; + const hasTooManyOptions = showOnlySelected + ? selectedOptions.length > MAX_OPTIONS_LIST_BULK_SELECT_SIZE + : totalCardinality > MAX_OPTIONS_LIST_BULK_SELECT_SIZE; + + const isBulkSelectDisabled = dataLoading || hasNoOptions || hasTooManyOptions || showOnlySelected; + + const handleBulkAction = useCallback( + async (bulkAction: (keys: string[]) => void) => { + bulkAction(availableOptions.map(({ value }) => value as string)); + + if (totalCardinality > availableOptions.length) { + const newAvailableOptions = (await loadMoreOptions()) ?? []; + bulkAction(newAvailableOptions.map(({ value }) => value as string)); + } + }, + [availableOptions, loadMoreOptions, totalCardinality] + ); + + useEffect(() => { + if (availableOptions.some(({ value }) => !selectedOptions.includes(value as string))) { + if (areAllSelected) { + setAllSelected(false); + } + } else { + if (!areAllSelected) { + setAllSelected(true); + } + } + }, [availableOptions, selectedOptions, areAllSelected]); const styles = useMemoCss(optionsListPopoverStyles); return ( @@ -108,6 +165,7 @@ export const OptionsListPopoverActionBar = ({ placeholder={OptionsListStrings.popover.getSearchPlaceholder( allowExpensiveQueries ? defaultSearchTechnique : 'exact' )} + aria-label={OptionsListStrings.popover.getSearchAriaLabel(fieldName)} /> )} @@ -119,26 +177,49 @@ export const OptionsListPopoverActionBar = ({ responsive={false} > {allowExpensiveQueries && ( - - - {OptionsListStrings.popover.getCardinalityLabel(totalCardinality)} - - - )} - {invalidSelections && invalidSelections.size > 0 && ( <> - {allowExpensiveQueries && ( - -
- - )} - - {OptionsListStrings.popover.getInvalidSelectionsLabel(invalidSelections.size)} + + {OptionsListStrings.popover.getCardinalityLabel(totalCardinality)} + +
+ )} + + + 0 && !areAllSelected} + disabled={isBulkSelectDisabled} + data-test-subj="optionsList-control-selectAll" + onChange={() => { + if (areAllSelected) { + handleBulkAction(componentApi.deselectAll); + setAllSelected(false); + } else { + handleBulkAction(componentApi.selectAll); + setAllSelected(true); + } + }} + css={styles.selectAllCheckbox} + label={ + + {OptionsListStrings.popover.getSelectAllButtonLabel()} + + } + /> + + { key: String(key), label: fieldFormatter(key), checked: 'on', + css: css` + .euiSelectableListItem__prepend { + margin-inline-end: 0; + } + `, className: 'optionsList__selectionInvalid', 'data-test-subj': `optionsList-control-invalid-selection-${key}`, prepend: ( @@ -76,7 +81,7 @@ export const OptionsListPopoverInvalidSelections = () => { <> - + { size="s" /> - + @@ -97,7 +102,7 @@ export const OptionsListPopoverInvalidSelections = () => { invalidSelections.size )} options={selectableOptions} - listProps={{ onFocusBadge: false, isVirtualized: false }} + listProps={{ onFocusBadge: false }} onChange={(newSuggestions, _, changedOption) => { setSelectableOptions(newSuggestions); componentApi.deselectOption(changedOption.key); diff --git a/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/constants.ts b/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/constants.ts index 87415dff252b..b077b43e4848 100644 --- a/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/constants.ts +++ b/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/constants.ts @@ -20,3 +20,4 @@ export const OPTIONS_LIST_DEFAULT_SORT: OptionsListSortingType = { export const MIN_OPTIONS_LIST_REQUEST_SIZE = 10; export const MAX_OPTIONS_LIST_REQUEST_SIZE = 1000; +export const MAX_OPTIONS_LIST_BULK_SELECT_SIZE = 100; diff --git a/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/get_options_list_control_factory.tsx b/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/get_options_list_control_factory.tsx index 153d47b8e92a..1d0c786c23f0 100644 --- a/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/get_options_list_control_factory.tsx +++ b/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/get_options_list_control_factory.tsx @@ -398,6 +398,34 @@ export const getOptionsListControlFactory = (): DataControlFactory< setSort: (sort: OptionsListSortingType | undefined) => { sort$.next(sort); }, + selectAll: (keys: string[]) => { + const field = api.field$.getValue(); + if (keys.length < 1 || !field) { + api.setBlockingError( + new Error(OptionsListStrings.control.getInvalidSelectionMessage()) + ); + return; + } + + const selectedOptions = selectionsManager.api.selectedOptions$.getValue() ?? []; + const newSelections = keys.filter((key) => !selectedOptions.includes(key as string)); + selectionsManager.api.setSelectedOptions([...selectedOptions, ...newSelections]); + }, + deselectAll: (keys: string[]) => { + const field = api.field$.getValue(); + if (keys.length < 1 || !field) { + api.setBlockingError( + new Error(OptionsListStrings.control.getInvalidSelectionMessage()) + ); + return; + } + + const selectedOptions = selectionsManager.api.selectedOptions$.getValue() ?? []; + const remainingSelections = selectedOptions.filter( + (option) => !keys.includes(option as string) + ); + selectionsManager.api.setSelectedOptions(remainingSelections); + }, }; if (selectionsManager.api.hasInitialSelections) { diff --git a/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/options_list_strings.ts b/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/options_list_strings.ts index b910b217063e..84df9acdcea7 100644 --- a/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/options_list_strings.ts +++ b/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/options_list_strings.ts @@ -9,6 +9,7 @@ import { i18n } from '@kbn/i18n'; import { OptionsListSearchTechnique } from '../../../../common/options_list/suggestions_searching'; +import { MAX_OPTIONS_LIST_BULK_SELECT_SIZE } from './constants'; export const OptionsListStrings = { control: { @@ -128,6 +129,11 @@ export const OptionsListStrings = { defaultMessage: 'Popover for {fieldName} control', values: { fieldName }, }), + getSearchAriaLabel: (fieldName: string) => + i18n.translate('controls.optionsList.popover.ariaLabel', { + defaultMessage: 'Filter suggestions for {fieldName} control', + values: { fieldName }, + }), getSuggestionsAriaLabel: (fieldName: string, optionCount: number) => i18n.translate('controls.optionsList.popover.suggestionsAriaLabel', { defaultMessage: @@ -212,13 +218,13 @@ export const OptionsListStrings = { getInvalidSelectionsSectionAriaLabel: (fieldName: string, invalidSelectionCount: number) => i18n.translate('controls.optionsList.popover.invalidSelectionsAriaLabel', { defaultMessage: - 'Invalid {invalidSelectionCount, plural, one {selection} other {selections}} for {fieldName}', + 'Invalid {invalidSelectionCount, plural, one {selection} other {selections}} for {fieldName} ({invalidSelectionCount})', values: { fieldName, invalidSelectionCount }, }), getInvalidSelectionsSectionTitle: (invalidSelectionCount: number) => i18n.translate('controls.optionsList.popover.invalidSelectionsSectionTitle', { defaultMessage: - 'Invalid {invalidSelectionCount, plural, one {selection} other {selections}}', + 'Invalid {invalidSelectionCount, plural, one {selection} other {selections}} ({invalidSelectionCount})', values: { invalidSelectionCount }, }), getInvalidSelectionsLabel: (selectedOptions: number) => @@ -267,6 +273,15 @@ export const OptionsListStrings = { 'Appears in {documentCount, number} {documentCount, plural, one {document} other {documents}}', values: { documentCount }, }), + getSelectAllButtonLabel: () => + i18n.translate('controls.optionsList.popover.selectAllButtonLabel', { + defaultMessage: 'Select all', + }), + getMaximumBulkSelectionTooltip: () => + i18n.translate('controls.optionsList.popover.maximumBulkSelectionTooltip', { + defaultMessage: 'Bulk selection is only available for fewer than {maxOptions} options', + values: { maxOptions: MAX_OPTIONS_LIST_BULK_SELECT_SIZE }, + }), }, controlAndPopover: { getExists: (negate: number = +false) => diff --git a/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/types.ts b/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/types.ts index 904e4de64f82..6f6b93f6dcba 100644 --- a/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/types.ts +++ b/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/types.ts @@ -52,4 +52,6 @@ export type OptionsListComponentApi = OptionsListControlApi & loadMoreSubject: Subject; sort$: PublishingSubject; setSort: (sort: OptionsListSortingType | undefined) => void; + selectAll: (keys: string[]) => void; + deselectAll: (keys: string[]) => void; };