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