[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".
*/
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<boolean>(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<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);
return (
@ -108,6 +165,7 @@ export const OptionsListPopoverActionBar = ({
placeholder={OptionsListStrings.popover.getSearchPlaceholder(
allowExpensiveQueries ? defaultSearchTechnique : 'exact'
)}
aria-label={OptionsListStrings.popover.getSearchAriaLabel(fieldName)}
/>
</EuiFormRow>
)}
@ -119,26 +177,49 @@ export const OptionsListPopoverActionBar = ({
responsive={false}
>
{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}>
<EuiText size="xs" color="subdued">
{OptionsListStrings.popover.getInvalidSelectionsLabel(invalidSelections.size)}
<EuiText size="xs" color="subdued" data-test-subj="optionsList-cardinality-label">
{OptionsListStrings.popover.getCardinalityLabel(totalCardinality)}
</EuiText>
</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}>
<EuiFlexGroup
gutterSize="xs"

View file

@ -57,6 +57,11 @@ export const OptionsListPopoverInvalidSelections = () => {
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 = () => {
<>
<EuiSpacer size="s" />
<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}>
<EuiIcon
type="warning"
@ -84,7 +89,7 @@ export const OptionsListPopoverInvalidSelections = () => {
size="s"
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexItem grow={false}>
<label>
{OptionsListStrings.popover.getInvalidSelectionsSectionTitle(invalidSelections.size)}
</label>
@ -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);

View file

@ -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;

View file

@ -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) {

View file

@ -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) =>

View file

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