mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 10:40:07 -04:00
[Controls] Bulk select for options list control (#221010)
This commit is contained in:
parent
48e4ede08a
commit
749aeb70e9
7 changed files with 288 additions and 23 deletions
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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"
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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) =>
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue