Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
fefe947
Add Select all and Deselect all buttons to options list popover
cqliu1 May 20, 2025
080d7d7
Remove dup types
cqliu1 May 27, 2025
3b75d48
Merge branch 'main' of https://github.com/elastic/kibana into control…
cqliu1 May 29, 2025
0bf6334
Change to EuiButtonEmpty to EuiLink for bulk selection buttons
cqliu1 May 29, 2025
b3cec9a
Remove filter in select all
cqliu1 May 29, 2025
1d56acc
Disable select and deselect when is enabled
cqliu1 May 29, 2025
b9109fb
Update test
cqliu1 May 29, 2025
c5c2673
Clean up handleBulkAction
cqliu1 May 29, 2025
48967f3
Fix select all
cqliu1 May 29, 2025
5699c18
Add spacing
cqliu1 May 30, 2025
b287c99
Fix alignment in action bar
cqliu1 May 30, 2025
609a32b
Move invalid selection count from action bar to invalid selections co…
cqliu1 May 30, 2025
42c6087
Remove unused invalid selections usage in action bar
cqliu1 May 30, 2025
a56e09b
Merge branch 'main' of https://github.com/elastic/kibana into control…
cqliu1 May 30, 2025
c435182
Merge branch 'main' into controls/options-list/select-deselect-all
cqliu1 Jun 2, 2025
ce31f29
Merge branch 'controls/options-list/select-deselect-all' of https://g…
cqliu1 Jun 2, 2025
04ca74d
Clean up code
cqliu1 Jun 2, 2025
2f2ed5f
Add aria-label to options list popover search filter
cqliu1 Jun 2, 2025
e925d35
Add invalid selection count to title
cqliu1 Jun 2, 2025
dac3d79
switch to checkbox
cqliu1 Jun 2, 2025
40efd4a
Add curly brackets
cqliu1 Jun 2, 2025
73855a4
Merge branch 'main' into controls/options-list/select-deselect-all
cqliu1 Jun 2, 2025
dd6ce97
Fix select all checkbox
cqliu1 Jun 20, 2025
670edb8
Fix select all checkbox label
cqliu1 Jun 20, 2025
b16f91b
Add invalid selection count to header
cqliu1 Jun 20, 2025
5567aa1
Merge branch 'main' of https://github.com/elastic/kibana into control…
cqliu1 Jun 20, 2025
6a739eb
[CI] Auto-commit changed files from 'node scripts/eslint --no-cache -…
kibanamachine Jun 20, 2025
3edfa43
Remove scss file
cqliu1 Jun 20, 2025
58905a8
Add padding between cardinality and select all
cqliu1 Jun 20, 2025
890f9ca
Enable virtualization on EuiSelectable for invalid selections
cqliu1 Jun 20, 2025
4726e3a
Merge branch 'controls/options-list/select-deselect-all' of https://g…
cqliu1 Jun 20, 2025
52466b3
Merge branch 'main' of https://github.com/elastic/kibana into control…
cqliu1 Jun 20, 2025
d3847e6
Merge branch 'main' of https://github.com/elastic/kibana into control…
cqliu1 Jun 23, 2025
a647172
Merge branch 'main' into controls/options-list/select-deselect-all
cqliu1 Jun 25, 2025
74b472b
make the test more user focused
mbondyra Jun 26, 2025
2d3adff
Update options_list_popover_action_bar.test.tsx
mbondyra Jun 26, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 = ({
Copy link
Contributor

@mbondyra mbondyra Jun 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added some edits to these tests to make it more user-focused, like using selectors like getByRole, not getByTestId. or using get selectors and not query selectors (query should only be used when checking for non-existance, since they have worse messaging)

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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -51,13 +56,23 @@ 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 = ({
showOnlySelected,
setShowOnlySelected,
}: OptionsListPopoverProps) => {
const { componentApi, displaySettings } = useOptionsListContext();
const [areAllSelected, setAllSelected] = useState<boolean>(false);

// Using useStateFromPublishingSubject instead of useBatchedPublishingSubjects
// to avoid debouncing input value
Expand All @@ -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(() => {
Expand All @@ -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 (
Expand All @@ -108,6 +165,7 @@ export const OptionsListPopoverActionBar = ({
placeholder={OptionsListStrings.popover.getSearchPlaceholder(
allowExpensiveQueries ? defaultSearchTechnique : 'exact'
)}
aria-label={OptionsListStrings.popover.getSearchAriaLabel(fieldName)}
/>
</EuiFormRow>
)}
Expand All @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: (
Expand All @@ -76,15 +81,15 @@ 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"
title={OptionsListStrings.popover.getInvalidSelectionScreenReaderText()}
size="s"
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexItem grow={false}>
<label>
{OptionsListStrings.popover.getInvalidSelectionsSectionTitle(invalidSelections.size)}
</label>
Expand All @@ -97,7 +102,7 @@ export const OptionsListPopoverInvalidSelections = () => {
invalidSelections.size
)}
options={selectableOptions}
listProps={{ onFocusBadge: false, isVirtualized: false }}
listProps={{ onFocusBadge: false }}
Copy link
Contributor Author

@cqliu1 cqliu1 Jun 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By enable virtualization in the EuiSelectable component in the invalid selections component, we get a list with a fixed height with scrolling enabled similar to the list of available options above it. This fixes the issue described in #222121.

Jun-20-2025.16-34-52.mp4

onChange={(newSuggestions, _, changedOption) => {
setSelectableOptions(newSuggestions);
componentApi.deselectOption(changedOption.key);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Loading
Loading