Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add optionMatcher prop to EuiSelectable and EuiComboBox components #7709

Merged
merged 8 commits into from
Apr 29, 2024
46 changes: 45 additions & 1 deletion src/components/combo_box/combo_box.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@
* Side Public License, v 1.
*/

import React, { useState } from 'react';
import React, { useCallback, useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { action } from '@storybook/addon-actions';

import { EuiComboBox, EuiComboBoxProps } from './combo_box';
import { EuiComboBoxOptionMatcher } from './types';
import { EuiCode } from '../code';

const options = [
{ label: 'Item 1' },
Expand Down Expand Up @@ -98,3 +100,45 @@ export const Playground: Story = {
);
},
};

export const CustomMatcher: Story = {
render: function Render({ singleSelection, onCreateOption, ...args }) {
Copy link
Contributor

Choose a reason for hiding this comment

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

🗒️ A general note, mainly for myself here:
Whenever we align on the code-snippet API in this PR and merge it, this story needs to be updated as it's a case of "additional composition wrapper" (it will likely need the parameters.codeSnippet.resolveChildren: true as otherwise this story snippet would be <Render anyStoryArgsHere />

Copy link
Member Author

Choose a reason for hiding this comment

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

This is the case for many of our stories, though, isn't it?

Copy link
Contributor

Choose a reason for hiding this comment

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

Some for sure. There are different cases I've seen so far. It depends on what should be output per story.
E.g. for cases like stateful wrappers that return just the actual story component it's different than a wrapper that returns the story component as nested child with other composition elements.

For stateful wrappers or related wrappers (Where a parent-subcomponent structure is required and can be determined based on naming it's already done automatically)
For anything else it might need adjustments.
I'm currently checking the newly added stories and there are some new cases as well that I did not consider yet (render functions 🙈)

const [selectedOptions, setSelectedOptions] = useState(
args.selectedOptions
);
const onChange: EuiComboBoxProps<{}>['onChange'] = (options, ...args) => {
setSelectedOptions(options);
action('onChange')(options, ...args);
};

const optionMatcher = useCallback<EuiComboBoxOptionMatcher<unknown>>(
({ option, searchValue }) => {
return option.label.startsWith(searchValue);
},
[]
);

return (
<>
<p>
This matcher example uses <EuiCode>option.label.startsWith()</EuiCode>
. Only options that start exactly like the given search string will be
matched.
</p>
<br />
<EuiComboBox
singleSelection={
// @ts-ignore Specific to Storybook control
singleSelection === 'asPlainText'
? { asPlainText: true }
: Boolean(singleSelection)
}
{...args}
selectedOptions={selectedOptions}
onChange={onChange}
optionMatcher={optionMatcher}
/>
</>
);
},
};
18 changes: 17 additions & 1 deletion src/components/combo_box/combo_box.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
getSelectedOptionForSearchValue,
transformForCaseSensitivity,
SortMatchesBy,
createPartialStringEqualityOptionMatcher,
} from './matching_options';
import {
EuiComboBoxInputProps,
Expand All @@ -43,6 +44,7 @@ import {
RefInstance,
EuiComboBoxOptionOption,
EuiComboBoxSingleSelectionShape,
EuiComboBoxOptionMatcher,
} from './types';
import { EuiComboBoxOptionsList } from './combo_box_options_list';

Expand Down Expand Up @@ -125,6 +127,15 @@ export interface _EuiComboBoxProps<T>
* Whether to match options with case sensitivity.
*/
isCaseSensitive?: boolean;
/**
* Optional custom option matcher function
*
* @example
* const exactEqualityMatcher: EuiComboBoxOptionMatcher = ({ option, searchValue }) => {
* return option.label === searchValue;
* }
*/
optionMatcher?: EuiComboBoxOptionMatcher<T>;
/**
* Creates an input group with element(s) coming before input. It won't show if `singleSelection` is set to `false`.
* `string` | `ReactElement` or an array of these
Expand Down Expand Up @@ -181,10 +192,11 @@ export interface _EuiComboBoxProps<T>
*/
type DefaultProps<T> = Omit<
(typeof EuiComboBox)['defaultProps'],
'options' | 'selectedOptions'
'options' | 'selectedOptions' | 'optionMatcher'
> & {
options: Array<EuiComboBoxOptionOption<T>>;
selectedOptions: Array<EuiComboBoxOptionOption<T>>;
optionMatcher: EuiComboBoxOptionMatcher<T>;
};
export type EuiComboBoxProps<T> = Omit<
_EuiComboBoxProps<T>,
Expand Down Expand Up @@ -217,6 +229,7 @@ export class EuiComboBox<T> extends Component<
prepend: undefined,
append: undefined,
sortMatchesBy: 'none' as const,
optionMatcher: createPartialStringEqualityOptionMatcher(),
};

state: EuiComboBoxState<T> = {
Expand All @@ -227,6 +240,7 @@ export class EuiComboBox<T> extends Component<
options: this.props.options,
selectedOptions: this.props.selectedOptions,
searchValue: initialSearchValue,
optionMatcher: this.props.optionMatcher!,
isCaseSensitive: this.props.isCaseSensitive,
isPreFiltered: this.props.async,
showPrevSelected: Boolean(this.props.singleSelection),
Expand Down Expand Up @@ -670,6 +684,7 @@ export class EuiComboBox<T> extends Component<
selectedOptions,
singleSelection,
sortMatchesBy,
optionMatcher,
} = nextProps;
const { activeOptionIndex, searchValue } = prevState;

Expand All @@ -683,6 +698,7 @@ export class EuiComboBox<T> extends Component<
isPreFiltered: async,
showPrevSelected: Boolean(singleSelection),
sortMatchesBy,
optionMatcher: optionMatcher!,
});

const stateUpdate: Partial<EuiComboBoxState<T>> = { matchingOptions };
Expand Down
3 changes: 3 additions & 0 deletions src/components/combo_box/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,7 @@ export * from './combo_box_options_list';
export type {
EuiComboBoxOptionOption,
EuiComboBoxSingleSelectionShape,
EuiComboBoxOptionMatcher,
EuiComboBoxOptionMatcherArgs,
} from './types';
export { createPartialStringEqualityOptionMatcher } from './matching_options';
49 changes: 42 additions & 7 deletions src/components/combo_box/matching_options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@
* Side Public License, v 1.
*/

import { EuiComboBoxOptionOption } from './types';
import { EuiComboBoxOptionOption, EuiComboBoxOptionMatcher } from './types';

export type SortMatchesBy = 'none' | 'startsWith';
interface GetMatchingOptions<T> {
options: Array<EuiComboBoxOptionOption<T>>;
selectedOptions: Array<EuiComboBoxOptionOption<T>>;
searchValue: string;
optionMatcher: EuiComboBoxOptionMatcher<T>;
isCaseSensitive?: boolean;
isPreFiltered?: boolean;
showPrevSelected?: boolean;
Expand All @@ -21,7 +22,11 @@ interface GetMatchingOptions<T> {
interface CollectMatchingOption<T>
extends Pick<
GetMatchingOptions<T>,
'isCaseSensitive' | 'isPreFiltered' | 'showPrevSelected'
| 'isCaseSensitive'
| 'isPreFiltered'
| 'showPrevSelected'
| 'optionMatcher'
| 'searchValue'
> {
accumulator: Array<EuiComboBoxOptionOption<T>>;
option: EuiComboBoxOptionOption<T>;
Expand Down Expand Up @@ -86,10 +91,12 @@ const collectMatchingOption = <T>({
accumulator,
option,
selectedOptions,
searchValue,
normalizedSearchValue,
isCaseSensitive,
isPreFiltered,
showPrevSelected,
optionMatcher,
}: CollectMatchingOption<T>) => {
// Only show options which haven't yet been selected unless requested.
const selectedOption = getSelectedOptionForSearchValue({
Expand All @@ -113,11 +120,13 @@ const collectMatchingOption = <T>({
return;
}

const normalizedOption = transformForCaseSensitivity(
option.label.trim(),
isCaseSensitive
);
if (normalizedOption.includes(normalizedSearchValue)) {
const isMatching = optionMatcher({
option,
searchValue,
normalizedSearchValue,
isCaseSensitive: isCaseSensitive ?? true,
});
if (isMatching) {
accumulator.push(option);
}
};
Expand All @@ -126,6 +135,7 @@ export const getMatchingOptions = <T>({
options,
selectedOptions,
searchValue,
optionMatcher,
isCaseSensitive = false,
isPreFiltered = false,
showPrevSelected = false,
Expand All @@ -145,10 +155,12 @@ export const getMatchingOptions = <T>({
accumulator: matchingOptionsForGroup,
option: groupOption,
selectedOptions,
searchValue,
normalizedSearchValue,
isCaseSensitive,
isPreFiltered,
showPrevSelected,
optionMatcher,
});
});
if (matchingOptionsForGroup.length > 0) {
Expand All @@ -167,10 +179,12 @@ export const getMatchingOptions = <T>({
accumulator: matchingOptions,
option,
selectedOptions,
searchValue,
normalizedSearchValue,
isCaseSensitive,
isPreFiltered,
showPrevSelected,
optionMatcher,
});
}
});
Expand All @@ -197,3 +211,24 @@ export const getMatchingOptions = <T>({

return matchingOptions;
};

/**
* Partial string equality option matcher for EuiComboBox.
* It matches all options with labels including the searched string.
*/
export const createPartialStringEqualityOptionMatcher = <
Copy link
Member Author

Choose a reason for hiding this comment

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

This is a factory function to keep TOption generic instead of defaulting to string/unknown or the underlying T type that defaults to string | number | string[] | undefined. The value property of type TObject is not used in this function so it doesn't really make a difference here

TOption
>(): EuiComboBoxOptionMatcher<TOption> => {
return ({ option, isCaseSensitive, normalizedSearchValue }) => {
if (!normalizedSearchValue) {
return true;
}

const normalizedOption = transformForCaseSensitivity(
option.label.trim(),
isCaseSensitive
);

return normalizedOption.includes(normalizedSearchValue);
};
};
32 changes: 32 additions & 0 deletions src/components/combo_box/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,35 @@ export type RefInstance<T> = T | null;
export interface EuiComboBoxSingleSelectionShape {
asPlainText?: boolean;
}

export interface EuiComboBoxOptionMatcherArgs<TOption> {
/**
* Option being currently processed
*/
option: EuiComboBoxOptionOption<TOption>;
/**
* Raw search input value
*/
searchValue: string;
/**
* Search input value normalized for case-sensitivity
* and with leading and trailing whitespace characters trimmed
*/
normalizedSearchValue: string;
/**
* Whether to match the option with case-sensitivity
*/
isCaseSensitive: boolean;
}

/**
* Option matcher function for EuiComboBox component.
*
* @example
* const equalityMatcher: EuiComboBoxOptionMatcher = ({ option, searchValue }) => {
* return option.label === searchValue;
* }
*/
export type EuiComboBoxOptionMatcher<TOption> = (
args: EuiComboBoxOptionMatcherArgs<TOption>
) => boolean;