[Controls] Bulk select for options list control (#221010)

This commit is contained in:
Catherine Liu 2025-06-26 09:24:45 -07:00 committed by GitHub
parent 48e4ede08a
commit 749aeb70e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 288 additions and 23 deletions

View file

@ -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(
<OptionsListControlContext.Provider
value={{
componentApi,
displaySettings,
}}
>
<OptionsListPopoverActionBar
showOnlySelected={showOnlySelected ?? false}
setShowOnlySelected={() => {}}
/>
</OptionsListControlContext.Provider>
);
};
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();
});
});

View file

@ -7,10 +7,11 @@
* License v3.0 only", or the "Server Side Public License, v 1". * 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 { import {
EuiButtonIcon, EuiButtonIcon,
EuiCheckbox,
EuiFieldSearch, EuiFieldSearch,
EuiFlexGroup, EuiFlexGroup,
EuiFlexItem, EuiFlexItem,
@ -23,12 +24,16 @@ import {
useBatchedPublishingSubjects, useBatchedPublishingSubjects,
useStateFromPublishingSubject, useStateFromPublishingSubject,
} from '@kbn/presentation-publishing'; } from '@kbn/presentation-publishing';
import { lastValueFrom, take } from 'rxjs';
import { css } from '@emotion/react'; import { css } from '@emotion/react';
import { useMemoCss } from '@kbn/css-utils/public/use_memo_css'; 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 { getCompatibleSearchTechniques } from '../../../../../common/options_list/suggestions_searching';
import { useOptionsListContext } from '../options_list_context_provider'; import { useOptionsListContext } from '../options_list_context_provider';
import { OptionsListPopoverSortingButton } from './options_list_popover_sorting_button'; import { OptionsListPopoverSortingButton } from './options_list_popover_sorting_button';
import { OptionsListStrings } from '../options_list_strings'; import { OptionsListStrings } from '../options_list_strings';
import { MAX_OPTIONS_LIST_BULK_SELECT_SIZE, MAX_OPTIONS_LIST_REQUEST_SIZE } from '../constants';
interface OptionsListPopoverProps { interface OptionsListPopoverProps {
showOnlySelected: boolean; showOnlySelected: boolean;
@ -51,6 +56,15 @@ const optionsListPopoverStyles = {
height: ${euiTheme.size.base}; height: ${euiTheme.size.base};
border-right: ${euiTheme.border.thin}; 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 = ({ export const OptionsListPopoverActionBar = ({
@ -58,6 +72,7 @@ export const OptionsListPopoverActionBar = ({
setShowOnlySelected, setShowOnlySelected,
}: OptionsListPopoverProps) => { }: OptionsListPopoverProps) => {
const { componentApi, displaySettings } = useOptionsListContext(); const { componentApi, displaySettings } = useOptionsListContext();
const [areAllSelected, setAllSelected] = useState<boolean>(false);
// Using useStateFromPublishingSubject instead of useBatchedPublishingSubjects // Using useStateFromPublishingSubject instead of useBatchedPublishingSubjects
// to avoid debouncing input value // to avoid debouncing input value
@ -66,17 +81,23 @@ export const OptionsListPopoverActionBar = ({
const [ const [
searchTechnique, searchTechnique,
searchStringValid, searchStringValid,
invalidSelections, selectedOptions = [],
totalCardinality, totalCardinality,
field, field,
fieldName,
allowExpensiveQueries, allowExpensiveQueries,
availableOptions = [],
dataLoading,
] = useBatchedPublishingSubjects( ] = useBatchedPublishingSubjects(
componentApi.searchTechnique$, componentApi.searchTechnique$,
componentApi.searchStringValid$, componentApi.searchStringValid$,
componentApi.invalidSelections$, componentApi.selectedOptions$,
componentApi.totalCardinality$, componentApi.totalCardinality$,
componentApi.field$, componentApi.field$,
componentApi.parentApi.allowExpensiveQueries$ componentApi.fieldName$,
componentApi.parentApi.allowExpensiveQueries$,
componentApi.availableOptions$,
componentApi.dataLoading$
); );
const compatibleSearchTechniques = useMemo(() => { const compatibleSearchTechniques = useMemo(() => {
@ -89,6 +110,42 @@ export const OptionsListPopoverActionBar = ({
[searchTechnique, compatibleSearchTechniques] [searchTechnique, compatibleSearchTechniques]
); );
const loadMoreOptions = useCallback(async (): Promise<OptionsListSuggestions | undefined> => {
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); const styles = useMemoCss(optionsListPopoverStyles);
return ( return (
@ -108,6 +165,7 @@ export const OptionsListPopoverActionBar = ({
placeholder={OptionsListStrings.popover.getSearchPlaceholder( placeholder={OptionsListStrings.popover.getSearchPlaceholder(
allowExpensiveQueries ? defaultSearchTechnique : 'exact' allowExpensiveQueries ? defaultSearchTechnique : 'exact'
)} )}
aria-label={OptionsListStrings.popover.getSearchAriaLabel(fieldName)}
/> />
</EuiFormRow> </EuiFormRow>
)} )}
@ -119,26 +177,49 @@ export const OptionsListPopoverActionBar = ({
responsive={false} responsive={false}
> >
{allowExpensiveQueries && ( {allowExpensiveQueries && (
<EuiFlexItem grow={false}>
<EuiText size="xs" color="subdued" data-test-subj="optionsList-cardinality-label">
{OptionsListStrings.popover.getCardinalityLabel(totalCardinality)}
</EuiText>
</EuiFlexItem>
)}
{invalidSelections && invalidSelections.size > 0 && (
<> <>
{allowExpensiveQueries && (
<EuiFlexItem grow={false}>
<div css={styles.borderDiv} />
</EuiFlexItem>
)}
<EuiFlexItem grow={false}> <EuiFlexItem grow={false}>
<EuiText size="xs" color="subdued"> <EuiText size="xs" color="subdued" data-test-subj="optionsList-cardinality-label">
{OptionsListStrings.popover.getInvalidSelectionsLabel(invalidSelections.size)} {OptionsListStrings.popover.getCardinalityLabel(totalCardinality)}
</EuiText> </EuiText>
</EuiFlexItem> </EuiFlexItem>
<EuiFlexItem grow={false}>
<div css={styles.borderDiv} />
</EuiFlexItem>
</> </>
)} )}
<EuiFlexItem grow={false}>
<EuiToolTip
content={
hasTooManyOptions
? OptionsListStrings.popover.getMaximumBulkSelectionTooltip()
: undefined
}
>
<EuiCheckbox
checked={areAllSelected}
id={`optionsList-control-selectAll-checkbox-${componentApi.uuid}`}
// indeterminate={selectedOptions.length > 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={
<EuiText size="xs">
{OptionsListStrings.popover.getSelectAllButtonLabel()}
</EuiText>
}
/>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={true}> <EuiFlexItem grow={true}>
<EuiFlexGroup <EuiFlexGroup
gutterSize="xs" gutterSize="xs"

View file

@ -57,6 +57,11 @@ export const OptionsListPopoverInvalidSelections = () => {
key: String(key), key: String(key),
label: fieldFormatter(key), label: fieldFormatter(key),
checked: 'on', checked: 'on',
css: css`
.euiSelectableListItem__prepend {
margin-inline-end: 0;
}
`,
className: 'optionsList__selectionInvalid', className: 'optionsList__selectionInvalid',
'data-test-subj': `optionsList-control-invalid-selection-${key}`, 'data-test-subj': `optionsList-control-invalid-selection-${key}`,
prepend: ( prepend: (
@ -76,7 +81,7 @@ export const OptionsListPopoverInvalidSelections = () => {
<> <>
<EuiSpacer size="s" /> <EuiSpacer size="s" />
<EuiTitle size="xxs" data-test-subj="optionList__invalidSelectionLabel" css={styles.title}> <EuiTitle size="xxs" data-test-subj="optionList__invalidSelectionLabel" css={styles.title}>
<EuiFlexGroup gutterSize="s" alignItems="center"> <EuiFlexGroup gutterSize="s" alignItems="center" justifyContent="flexStart">
<EuiFlexItem grow={false}> <EuiFlexItem grow={false}>
<EuiIcon <EuiIcon
type="warning" type="warning"
@ -84,7 +89,7 @@ export const OptionsListPopoverInvalidSelections = () => {
size="s" size="s"
/> />
</EuiFlexItem> </EuiFlexItem>
<EuiFlexItem> <EuiFlexItem grow={false}>
<label> <label>
{OptionsListStrings.popover.getInvalidSelectionsSectionTitle(invalidSelections.size)} {OptionsListStrings.popover.getInvalidSelectionsSectionTitle(invalidSelections.size)}
</label> </label>
@ -97,7 +102,7 @@ export const OptionsListPopoverInvalidSelections = () => {
invalidSelections.size invalidSelections.size
)} )}
options={selectableOptions} options={selectableOptions}
listProps={{ onFocusBadge: false, isVirtualized: false }} listProps={{ onFocusBadge: false }}
onChange={(newSuggestions, _, changedOption) => { onChange={(newSuggestions, _, changedOption) => {
setSelectableOptions(newSuggestions); setSelectableOptions(newSuggestions);
componentApi.deselectOption(changedOption.key); componentApi.deselectOption(changedOption.key);

View file

@ -20,3 +20,4 @@ export const OPTIONS_LIST_DEFAULT_SORT: OptionsListSortingType = {
export const MIN_OPTIONS_LIST_REQUEST_SIZE = 10; export const MIN_OPTIONS_LIST_REQUEST_SIZE = 10;
export const MAX_OPTIONS_LIST_REQUEST_SIZE = 1000; export const MAX_OPTIONS_LIST_REQUEST_SIZE = 1000;
export const MAX_OPTIONS_LIST_BULK_SELECT_SIZE = 100;

View file

@ -398,6 +398,34 @@ export const getOptionsListControlFactory = (): DataControlFactory<
setSort: (sort: OptionsListSortingType | undefined) => { setSort: (sort: OptionsListSortingType | undefined) => {
sort$.next(sort); 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) { if (selectionsManager.api.hasInitialSelections) {

View file

@ -9,6 +9,7 @@
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { OptionsListSearchTechnique } from '../../../../common/options_list/suggestions_searching'; import { OptionsListSearchTechnique } from '../../../../common/options_list/suggestions_searching';
import { MAX_OPTIONS_LIST_BULK_SELECT_SIZE } from './constants';
export const OptionsListStrings = { export const OptionsListStrings = {
control: { control: {
@ -128,6 +129,11 @@ export const OptionsListStrings = {
defaultMessage: 'Popover for {fieldName} control', defaultMessage: 'Popover for {fieldName} control',
values: { fieldName }, values: { fieldName },
}), }),
getSearchAriaLabel: (fieldName: string) =>
i18n.translate('controls.optionsList.popover.ariaLabel', {
defaultMessage: 'Filter suggestions for {fieldName} control',
values: { fieldName },
}),
getSuggestionsAriaLabel: (fieldName: string, optionCount: number) => getSuggestionsAriaLabel: (fieldName: string, optionCount: number) =>
i18n.translate('controls.optionsList.popover.suggestionsAriaLabel', { i18n.translate('controls.optionsList.popover.suggestionsAriaLabel', {
defaultMessage: defaultMessage:
@ -212,13 +218,13 @@ export const OptionsListStrings = {
getInvalidSelectionsSectionAriaLabel: (fieldName: string, invalidSelectionCount: number) => getInvalidSelectionsSectionAriaLabel: (fieldName: string, invalidSelectionCount: number) =>
i18n.translate('controls.optionsList.popover.invalidSelectionsAriaLabel', { i18n.translate('controls.optionsList.popover.invalidSelectionsAriaLabel', {
defaultMessage: defaultMessage:
'Invalid {invalidSelectionCount, plural, one {selection} other {selections}} for {fieldName}', 'Invalid {invalidSelectionCount, plural, one {selection} other {selections}} for {fieldName} ({invalidSelectionCount})',
values: { fieldName, invalidSelectionCount }, values: { fieldName, invalidSelectionCount },
}), }),
getInvalidSelectionsSectionTitle: (invalidSelectionCount: number) => getInvalidSelectionsSectionTitle: (invalidSelectionCount: number) =>
i18n.translate('controls.optionsList.popover.invalidSelectionsSectionTitle', { i18n.translate('controls.optionsList.popover.invalidSelectionsSectionTitle', {
defaultMessage: defaultMessage:
'Invalid {invalidSelectionCount, plural, one {selection} other {selections}}', 'Invalid {invalidSelectionCount, plural, one {selection} other {selections}} ({invalidSelectionCount})',
values: { invalidSelectionCount }, values: { invalidSelectionCount },
}), }),
getInvalidSelectionsLabel: (selectedOptions: number) => getInvalidSelectionsLabel: (selectedOptions: number) =>
@ -267,6 +273,15 @@ export const OptionsListStrings = {
'Appears in {documentCount, number} {documentCount, plural, one {document} other {documents}}', 'Appears in {documentCount, number} {documentCount, plural, one {document} other {documents}}',
values: { documentCount }, 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: { controlAndPopover: {
getExists: (negate: number = +false) => getExists: (negate: number = +false) =>

View file

@ -52,4 +52,6 @@ export type OptionsListComponentApi = OptionsListControlApi &
loadMoreSubject: Subject<void>; loadMoreSubject: Subject<void>;
sort$: PublishingSubject<OptionsListSortingType | undefined>; sort$: PublishingSubject<OptionsListSortingType | undefined>;
setSort: (sort: OptionsListSortingType | undefined) => void; setSort: (sort: OptionsListSortingType | undefined) => void;
selectAll: (keys: string[]) => void;
deselectAll: (keys: string[]) => void;
}; };