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

[EuiComboBox] Optional case sensitive option matching #6268

Merged
merged 11 commits into from
Oct 3, 2022
84 changes: 84 additions & 0 deletions src-docs/src/views/combo_box/case_sensitive.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import React, { useState } from 'react';
Copy link
Contributor

Choose a reason for hiding this comment

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

[extremely optional] it might be nice to potentially make this a .tsx file instead of JS to dogfood/catch any type friction points, feel free to skip though


import { EuiComboBox } from '../../../../src/components';

export default () => {
const [options, updateOptions] = useState([
{
label: 'Titan',
'data-test-subj': 'titanOption',
},
{
label: 'Enceladus is disabled',
disabled: true,
},
{
label: 'Mimas',
},
{
label: 'Dione',
},
{
label: 'Iapetus',
},
{
label: 'Phoebe',
},
{
label: 'Rhea',
},
{
label:
"Pandora is one of Saturn's moons, named for a Titaness of Greek mythology",
},
{
label: 'Tethys',
},
{
label: 'Hyperion',
},
]);

const [selectedOptions, setSelected] = useState([]);

const onChange = (selectedOptions) => {
setSelected(selectedOptions);
};

const onCreateOption = (searchValue, flattenedOptions) => {
const normalizedSearchValue = searchValue.trim().toLowerCase();

if (!normalizedSearchValue) {
return;
}

const newOption = {
label: searchValue,
};

// Create the option if it doesn't exist.
if (
flattenedOptions.findIndex(
(option) => option.label.trim().toLowerCase() === normalizedSearchValue
) === -1
) {
updateOptions([...options, newOption]);
}

// Select the option.
setSelected((prevSelected) => [...prevSelected, newOption]);
};

return (
<EuiComboBox
aria-label="Accessible screen reader label"
placeholder="Select or create options"
options={options}
selectedOptions={selectedOptions}
onChange={onChange}
onCreateOption={onCreateOption}
isClearable={true}
isCaseSensitive
/>
);
};
29 changes: 29 additions & 0 deletions src-docs/src/views/combo_box/combo_box_example.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,17 @@ const virtualizedSnippet = `<EuiComboBox
onChange={onChange}
/>`;

import CaseSensitive from './case_sensitive';
const caseSensitiveSource = require('!!raw-loader!./case_sensitive');
const caseSensitiveSnippet = `<EuiComboBox
aria-label="Accessible screen reader label"
placeholder="Select or create options"
options={options}
onChange={onChange}
onCreateOption={onCreateOption}
isCaseSensitive
/>`;

import Disabled from './disabled';
const disabledSource = require('!!raw-loader!./disabled');
const disabledSnippet = `<EuiComboBox
Expand Down Expand Up @@ -269,6 +280,24 @@ export const ComboBoxExample = {
snippet: disabledSnippet,
demo: <Disabled />,
},
{
title: 'Case-sensitive matching',
source: [
{
type: GuideSectionTypes.JS,
code: caseSensitiveSource,
},
],
text: (
<p>
Set the prop <EuiCode>isCaseSensitive</EuiCode> to make the combo box
option matching case sensitive.
</p>
),
props: { EuiComboBox, EuiComboBoxOptionOption },
snippet: caseSensitiveSnippet,
demo: <CaseSensitive />,
},
{
title: 'Virtualized',
source: [
Expand Down
50 changes: 50 additions & 0 deletions src/components/combo_box/combo_box.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,56 @@ describe('behavior', () => {
});
});

describe('isCaseSensitive', () => {
const isCaseSensitiveOptions = [
{
label: 'Case sensitivity',
},
];

test('options "false"', () => {
const component = mount<
EuiComboBox<TitanOption>,
EuiComboBoxProps<TitanOption>,
{ matchingOptions: TitanOption[] }
>(
<EuiComboBox options={isCaseSensitiveOptions} isCaseSensitive={false} />
);

findTestSubject(component, 'comboBoxSearchInput').simulate('change', {
target: { value: 'case' },
});

expect(component.state('matchingOptions')[0].label).toBe(
'Case sensitivity'
);
});

test('options "true"', () => {
const component = mount<
EuiComboBox<TitanOption>,
EuiComboBoxProps<TitanOption>,
{ matchingOptions: TitanOption[] }
>(
<EuiComboBox options={isCaseSensitiveOptions} isCaseSensitive={true} />
);

findTestSubject(component, 'comboBoxSearchInput').simulate('change', {
target: { value: 'case' },
});

expect(component.state('matchingOptions').length).toBe(0);

findTestSubject(component, 'comboBoxSearchInput').simulate('change', {
target: { value: 'Case' },
});

expect(component.state('matchingOptions')[0].label).toBe(
'Case sensitivity'
);
});
});

it('calls the inputRef prop with the input element', () => {
const inputRefCallback = jest.fn();

Expand Down
86 changes: 60 additions & 26 deletions src/components/combo_box/combo_box.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import {
getMatchingOptions,
flattenOptionGroups,
getSelectedOptionForSearchValue,
transformForCaseSensitivity,
SortMatchesBy,
} from './matching_options';
import {
EuiComboBoxInputProps,
Expand Down Expand Up @@ -122,7 +124,11 @@ export interface _EuiComboBoxProps<T>
* `startsWith`: moves items that start with search value to top of the list;
* `none`: don't change the sort order of initial object
*/
sortMatchesBy: 'none' | 'startsWith';
sortMatchesBy: SortMatchesBy;
Copy link
Contributor

Choose a reason for hiding this comment

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

Nice catch!

/**
* Whether to match options with case sensitivity.
*/
isCaseSensitive?: boolean;
/**
* 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 @@ -211,14 +217,15 @@ export class EuiComboBox<T> extends Component<
listElement: null,
listPosition: 'bottom',
listZIndex: undefined,
matchingOptions: getMatchingOptions<T>(
this.props.options,
this.props.selectedOptions,
initialSearchValue,
this.props.async,
Boolean(this.props.singleSelection),
this.props.sortMatchesBy
),
matchingOptions: getMatchingOptions<T>({
options: this.props.options,
selectedOptions: this.props.selectedOptions,
searchValue: initialSearchValue,
isCaseSensitive: this.props.isCaseSensitive,
isPreFiltered: this.props.async,
showPrevSelected: Boolean(this.props.singleSelection),
sortMatchesBy: this.props.sortMatchesBy,
}),
Comment on lines +220 to +228
Copy link
Contributor

Choose a reason for hiding this comment

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

🙏 thanks so much for this cleanup, objects are so much nicer than multiple args once you get over like 3

searchValue: initialSearchValue,
width: 0,
};
Expand Down Expand Up @@ -433,6 +440,7 @@ export class EuiComboBox<T> extends Component<

addCustomOption = (isContainerBlur: boolean, searchValue: string) => {
const {
isCaseSensitive,
onCreateOption,
options,
selectedOptions,
Expand All @@ -456,7 +464,13 @@ export class EuiComboBox<T> extends Component<
}

// Don't create the value if it's already been selected.
if (getSelectedOptionForSearchValue(searchValue, selectedOptions)) {
if (
getSelectedOptionForSearchValue({
isCaseSensitive,
searchValue,
selectedOptions,
})
) {
return;
}

cee-chen marked this conversation as resolved.
Show resolved Hide resolved
Expand Down Expand Up @@ -484,26 +498,40 @@ export class EuiComboBox<T> extends Component<
if (this.state.matchingOptions.length !== 1) {
return false;
}
return (
this.state.matchingOptions[0].label.toLowerCase() ===
searchValue.toLowerCase()
const normalizedSearchSubject = transformForCaseSensitivity(
this.state.matchingOptions[0].label,
this.props.isCaseSensitive
);
const normalizedSearchValue = transformForCaseSensitivity(
searchValue,
this.props.isCaseSensitive
);
return normalizedSearchSubject === normalizedSearchValue;
};

areAllOptionsSelected = () => {
const { options, selectedOptions, async } = this.props;
const { options, selectedOptions, async, isCaseSensitive } = this.props;
// Assume if this is async then there could be infinite options.
if (async) {
return false;
}

const flattenOptions = flattenOptionGroups(options).map((option) => {
return { ...option, label: option.label.trim().toLowerCase() };
return {
...option,
label: transformForCaseSensitivity(
option.label.trim(),
isCaseSensitive
),
};
});

let numberOfSelectedOptions = 0;
selectedOptions.forEach(({ label }) => {
const trimmedLabel = label.trim().toLowerCase();
const trimmedLabel = transformForCaseSensitivity(
label.trim(),
isCaseSensitive
);
if (
flattenOptions.findIndex((option) => option.label === trimmedLabel) !==
-1
Expand Down Expand Up @@ -788,6 +816,8 @@ export class EuiComboBox<T> extends Component<
prevState: EuiComboBoxState<T>
) {
const {
async,
isCaseSensitive,
options,
selectedOptions,
singleSelection,
Expand All @@ -797,14 +827,15 @@ export class EuiComboBox<T> extends Component<

// Calculate and cache the options which match the searchValue, because we use this information
// in multiple places and it would be expensive to calculate repeatedly.
const matchingOptions = getMatchingOptions(
const matchingOptions = getMatchingOptions({
options,
selectedOptions,
searchValue,
nextProps.async,
Boolean(singleSelection),
sortMatchesBy
);
isCaseSensitive,
isPreFiltered: async,
showPrevSelected: Boolean(singleSelection),
sortMatchesBy,
});

const stateUpdate: Partial<EuiComboBoxState<T>> = { matchingOptions };

Expand Down Expand Up @@ -873,14 +904,15 @@ export class EuiComboBox<T> extends Component<
// isn't called after a state change, and we track `searchValue` in state
// instead we need to react to a change in searchValue here
this.updateMatchingOptionsIfDifferent(
getMatchingOptions(
getMatchingOptions({
options,
selectedOptions,
searchValue,
this.props.async,
Boolean(singleSelection),
sortMatchesBy
)
isCaseSensitive: this.props.isCaseSensitive,
isPreFiltered: this.props.async,
showPrevSelected: Boolean(singleSelection),
sortMatchesBy,
})
);
}

Expand All @@ -898,6 +930,7 @@ export class EuiComboBox<T> extends Component<
fullWidth,
id,
inputRef,
isCaseSensitive,
isClearable,
isDisabled,
isInvalid,
Expand Down Expand Up @@ -977,6 +1010,7 @@ export class EuiComboBox<T> extends Component<
customOptionText={customOptionText}
data-test-subj={optionsListDataTestSubj}
fullWidth={fullWidth}
isCaseSensitive={isCaseSensitive}
isLoading={isLoading}
listRef={this.listRefCallback}
matchingOptions={matchingOptions}
Expand Down
Loading