From 9c8681c31e4de01eaed492a0510a7d9685f3c121 Mon Sep 17 00:00:00 2001 From: Greg Thompson Date: Mon, 3 Oct 2022 10:41:48 -0500 Subject: [PATCH] [EuiComboBox] Optional case sensitive option matching (#6268) * refactor API and introduce isCaseSensitive * refactor utils; add isCaseSensitiveProp * CL * docs * enforce highlight case sensitivity * account for more toLowerCase; new transform util --- .../src/views/combo_box/case_sensitive.js | 84 +++++++++++ .../src/views/combo_box/combo_box_example.js | 29 ++++ src/components/combo_box/combo_box.test.tsx | 50 +++++++ src/components/combo_box/combo_box.tsx | 86 +++++++---- .../combo_box_options_list.tsx | 24 +-- .../combo_box/matching_options.test.ts | 78 ++++++++-- src/components/combo_box/matching_options.ts | 141 ++++++++++++------ upcoming_changelogs/6268.md | 2 + 8 files changed, 403 insertions(+), 91 deletions(-) create mode 100644 src-docs/src/views/combo_box/case_sensitive.js create mode 100644 upcoming_changelogs/6268.md diff --git a/src-docs/src/views/combo_box/case_sensitive.js b/src-docs/src/views/combo_box/case_sensitive.js new file mode 100644 index 00000000000..06d48a5a338 --- /dev/null +++ b/src-docs/src/views/combo_box/case_sensitive.js @@ -0,0 +1,84 @@ +import React, { useState } from 'react'; + +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 ( + + ); +}; diff --git a/src-docs/src/views/combo_box/combo_box_example.js b/src-docs/src/views/combo_box/combo_box_example.js index 9bf03d5f9ff..71b72e196f0 100644 --- a/src-docs/src/views/combo_box/combo_box_example.js +++ b/src-docs/src/views/combo_box/combo_box_example.js @@ -150,6 +150,17 @@ const virtualizedSnippet = ``; +import CaseSensitive from './case_sensitive'; +const caseSensitiveSource = require('!!raw-loader!./case_sensitive'); +const caseSensitiveSnippet = ``; + import Disabled from './disabled'; const disabledSource = require('!!raw-loader!./disabled'); const disabledSnippet = `, }, + { + title: 'Case-sensitive matching', + source: [ + { + type: GuideSectionTypes.JS, + code: caseSensitiveSource, + }, + ], + text: ( +

+ Set the prop isCaseSensitive to make the combo box + option matching case sensitive. +

+ ), + props: { EuiComboBox, EuiComboBoxOptionOption }, + snippet: caseSensitiveSnippet, + demo: , + }, { title: 'Virtualized', source: [ diff --git a/src/components/combo_box/combo_box.test.tsx b/src/components/combo_box/combo_box.test.tsx index c0c30df8bb4..3c702beb65c 100644 --- a/src/components/combo_box/combo_box.test.tsx +++ b/src/components/combo_box/combo_box.test.tsx @@ -461,6 +461,56 @@ describe('behavior', () => { }); }); + describe('isCaseSensitive', () => { + const isCaseSensitiveOptions = [ + { + label: 'Case sensitivity', + }, + ]; + + test('options "false"', () => { + const component = mount< + EuiComboBox, + EuiComboBoxProps, + { matchingOptions: TitanOption[] } + >( + + ); + + findTestSubject(component, 'comboBoxSearchInput').simulate('change', { + target: { value: 'case' }, + }); + + expect(component.state('matchingOptions')[0].label).toBe( + 'Case sensitivity' + ); + }); + + test('options "true"', () => { + const component = mount< + EuiComboBox, + EuiComboBoxProps, + { matchingOptions: TitanOption[] } + >( + + ); + + 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(); diff --git a/src/components/combo_box/combo_box.tsx b/src/components/combo_box/combo_box.tsx index 99d13d10855..4b6803b7321 100644 --- a/src/components/combo_box/combo_box.tsx +++ b/src/components/combo_box/combo_box.tsx @@ -28,6 +28,8 @@ import { getMatchingOptions, flattenOptionGroups, getSelectedOptionForSearchValue, + transformForCaseSensitivity, + SortMatchesBy, } from './matching_options'; import { EuiComboBoxInputProps, @@ -122,7 +124,11 @@ export interface _EuiComboBoxProps * `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; + /** + * 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 @@ -211,14 +217,15 @@ export class EuiComboBox extends Component< listElement: null, listPosition: 'bottom', listZIndex: undefined, - matchingOptions: getMatchingOptions( - this.props.options, - this.props.selectedOptions, - initialSearchValue, - this.props.async, - Boolean(this.props.singleSelection), - this.props.sortMatchesBy - ), + matchingOptions: getMatchingOptions({ + 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, + }), searchValue: initialSearchValue, width: 0, }; @@ -433,6 +440,7 @@ export class EuiComboBox extends Component< addCustomOption = (isContainerBlur: boolean, searchValue: string) => { const { + isCaseSensitive, onCreateOption, options, selectedOptions, @@ -456,7 +464,13 @@ export class EuiComboBox extends Component< } // Don't create the value if it's already been selected. - if (getSelectedOptionForSearchValue(searchValue, selectedOptions)) { + if ( + getSelectedOptionForSearchValue({ + isCaseSensitive, + searchValue, + selectedOptions, + }) + ) { return; } @@ -484,26 +498,40 @@ export class EuiComboBox 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 @@ -788,6 +816,8 @@ export class EuiComboBox extends Component< prevState: EuiComboBoxState ) { const { + async, + isCaseSensitive, options, selectedOptions, singleSelection, @@ -797,14 +827,15 @@ export class EuiComboBox 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> = { matchingOptions }; @@ -873,14 +904,15 @@ export class EuiComboBox 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, + }) ); } @@ -898,6 +930,7 @@ export class EuiComboBox extends Component< fullWidth, id, inputRef, + isCaseSensitive, isClearable, isDisabled, isInvalid, @@ -977,6 +1010,7 @@ export class EuiComboBox extends Component< customOptionText={customOptionText} data-test-subj={optionsListDataTestSubj} fullWidth={fullWidth} + isCaseSensitive={isCaseSensitive} isLoading={isLoading} listRef={this.listRefCallback} matchingOptions={matchingOptions} diff --git a/src/components/combo_box/combo_box_options_list/combo_box_options_list.tsx b/src/components/combo_box/combo_box_options_list/combo_box_options_list.tsx index 50e4c6f960d..d187538d50d 100644 --- a/src/components/combo_box/combo_box_options_list/combo_box_options_list.tsx +++ b/src/components/combo_box/combo_box_options_list/combo_box_options_list.tsx @@ -56,10 +56,12 @@ export type EuiComboBoxOptionsListProps = CommonProps & */ customOptionText?: string; fullWidth?: boolean; - getSelectedOptionForSearchValue?: ( - searchValue: string, - selectedOptions: any[] - ) => EuiComboBoxOptionOption | undefined; + getSelectedOptionForSearchValue?: (params: { + isCaseSensitive?: boolean; + searchValue: string; + selectedOptions: any[]; + }) => EuiComboBoxOptionOption | undefined; + isCaseSensitive?: boolean; isLoading?: boolean; listRef: RefCallback; matchingOptions: Array>; @@ -113,6 +115,7 @@ export class EuiComboBoxOptionsList extends Component< static defaultProps = { 'data-test-subj': '', rowHeight: 29, // row height of default option renderer + isCaseSensitive: false, }; updatePosition = () => { @@ -218,7 +221,7 @@ export class EuiComboBoxOptionsList extends Component< if (isGroupLabelOption) { return ( -
+
{label}
); @@ -241,7 +244,7 @@ export class EuiComboBoxOptionsList extends Component< return ( { if (onOptionClick) { onOptionClick(option); @@ -267,6 +270,7 @@ export class EuiComboBoxOptionsList extends Component< ) : ( {label} @@ -286,6 +290,7 @@ export class EuiComboBoxOptionsList extends Component< customOptionText, fullWidth, getSelectedOptionForSearchValue, + isCaseSensitive, isLoading, listRef, matchingOptions, @@ -345,10 +350,11 @@ export class EuiComboBoxOptionsList extends Component<
); } else { - const selectedOptionForValue = getSelectedOptionForSearchValue( + const selectedOptionForValue = getSelectedOptionForSearchValue({ + isCaseSensitive, searchValue, - selectedOptions - ); + selectedOptions, + }); if (selectedOptionForValue) { // Disallow duplicate custom options. emptyStateContent = ( diff --git a/src/components/combo_box/matching_options.test.ts b/src/components/combo_box/matching_options.test.ts index 77f077792b0..29d2dad2367 100644 --- a/src/components/combo_box/matching_options.test.ts +++ b/src/components/combo_box/matching_options.test.ts @@ -8,6 +8,7 @@ import { EuiComboBoxOptionOption } from './types'; import { + SortMatchesBy, flattenOptionGroups, getMatchingOptions, getSelectedOptionForSearchValue, @@ -64,7 +65,10 @@ describe('getSelectedOptionForSearchValue', () => { 'data-test-subj': 'saturnOption', }; // Act - const got = getSelectedOptionForSearchValue('saturn', options); + const got = getSelectedOptionForSearchValue({ + searchValue: 'saturn', + selectedOptions: options, + }); // Assert expect(got).toMatchObject(expected); }); @@ -73,7 +77,10 @@ describe('getSelectedOptionForSearchValue', () => { describe('getSelectedOptionForSearchValue', () => { test('returns undefined when no matching option found for search value', () => { // Act - const got = getSelectedOptionForSearchValue('Pluto', options); + const got = getSelectedOptionForSearchValue({ + searchValue: 'Pluto', + selectedOptions: options, + }); // Assert expect(got).toBeUndefined(); }); @@ -84,7 +91,10 @@ describe('getSelectedOptionForSearchValue', () => { 'data-test-subj': 'saturnOption', }; // Act - const got = getSelectedOptionForSearchValue('saturn', options); + const got = getSelectedOptionForSearchValue({ + searchValue: 'saturn', + selectedOptions: options, + }); // Assert expect(got).toMatchObject(expected); }); @@ -92,12 +102,13 @@ describe('getSelectedOptionForSearchValue', () => { interface GetMatchingOptionsTestCase { expected: EuiComboBoxOptionOption[]; + isCaseSensitive: boolean; isPreFiltered: boolean; options: EuiComboBoxOptionOption[]; searchValue: string; selectedOptions: EuiComboBoxOptionOption[]; showPrevSelected: boolean; - sortMatchesBy: string; + sortMatchesBy: SortMatchesBy; } const testCases: GetMatchingOptionsTestCase[] = [ @@ -110,6 +121,7 @@ const testCases: GetMatchingOptionsTestCase[] = [ }, ], searchValue: 'saturn', + isCaseSensitive: false, isPreFiltered: false, showPrevSelected: false, expected: [], @@ -124,6 +136,7 @@ const testCases: GetMatchingOptionsTestCase[] = [ }, ], searchValue: 'saturn', + isCaseSensitive: false, isPreFiltered: true, showPrevSelected: false, expected: [ @@ -141,6 +154,7 @@ const testCases: GetMatchingOptionsTestCase[] = [ }, ], searchValue: 'saturn', + isCaseSensitive: false, isPreFiltered: false, showPrevSelected: true, expected: [{ 'data-test-subj': 'saturnOption', label: 'Saturn' }], @@ -155,6 +169,7 @@ const testCases: GetMatchingOptionsTestCase[] = [ }, ], searchValue: 'saturn', + isCaseSensitive: false, isPreFiltered: true, showPrevSelected: true, expected: [ @@ -172,6 +187,7 @@ const testCases: GetMatchingOptionsTestCase[] = [ }, ], searchValue: 'titan', + isCaseSensitive: false, isPreFiltered: true, showPrevSelected: false, expected: [ @@ -191,6 +207,7 @@ const testCases: GetMatchingOptionsTestCase[] = [ }, ], searchValue: 'titan', + isCaseSensitive: false, isPreFiltered: true, showPrevSelected: false, expected: [ @@ -199,22 +216,55 @@ const testCases: GetMatchingOptionsTestCase[] = [ ], sortMatchesBy: 'none', }, + // Case sensitivity + { + options, + selectedOptions: [], + searchValue: 'saturn', + isCaseSensitive: false, + isPreFiltered: false, + showPrevSelected: false, + expected: [ + { + label: 'Saturn', + 'data-test-subj': 'saturnOption', + }, + ], + sortMatchesBy: 'none', + }, + { + options, + selectedOptions: [], + searchValue: 'saturn', + isCaseSensitive: true, + isPreFiltered: false, + showPrevSelected: false, + expected: [], + sortMatchesBy: 'none', + }, + { + options, + selectedOptions: [], + searchValue: 'Saturn', + isCaseSensitive: true, + isPreFiltered: false, + showPrevSelected: false, + expected: [ + { + label: 'Saturn', + 'data-test-subj': 'saturnOption', + }, + ], + sortMatchesBy: 'none', + }, ]; describe('getMatchingOptions', () => { test.each(testCases)( '.getMatchingOptions(%o)', (testCase: typeof testCases[number]) => { - expect( - getMatchingOptions( - testCase.options, - testCase.selectedOptions, - testCase.searchValue, - testCase.isPreFiltered, - testCase.showPrevSelected, - testCase.sortMatchesBy - ) - ).toMatchObject(testCase.expected); + const { expected, ...rest } = testCase; + expect(getMatchingOptions(rest)).toMatchObject(expected); } ); }); diff --git a/src/components/combo_box/matching_options.ts b/src/components/combo_box/matching_options.ts index 38c690f7d7e..2ef3095994a 100644 --- a/src/components/combo_box/matching_options.ts +++ b/src/components/combo_box/matching_options.ts @@ -8,6 +8,39 @@ import { EuiComboBoxOptionOption } from './types'; +export type SortMatchesBy = 'none' | 'startsWith'; +interface GetMatchingOptions { + options: Array>; + selectedOptions: Array>; + searchValue: string; + isCaseSensitive?: boolean; + isPreFiltered?: boolean; + showPrevSelected?: boolean; + sortMatchesBy?: SortMatchesBy; +} +interface CollectMatchingOption + extends Pick< + GetMatchingOptions, + 'isCaseSensitive' | 'isPreFiltered' | 'showPrevSelected' + > { + accumulator: Array>; + option: EuiComboBoxOptionOption; + selectedOptions: Array>; + normalizedSearchValue: string; +} +interface GetSelectedOptionForSearchValue + extends Pick< + GetMatchingOptions, + 'isCaseSensitive' | 'searchValue' | 'selectedOptions' + > { + optionKey?: string; +} + +export const transformForCaseSensitivity = ( + string: string, + isCaseSensitive?: boolean +) => (isCaseSensitive ? string : string.toLowerCase()); + export const flattenOptionGroups = ( optionsOrGroups: Array> ) => { @@ -27,33 +60,44 @@ export const flattenOptionGroups = ( ); }; -export const getSelectedOptionForSearchValue = ( - searchValue: string, - selectedOptions: Array>, - optionKey?: string -) => { - const normalizedSearchValue = searchValue.toLowerCase(); - return selectedOptions.find( - (option) => - option.label.toLowerCase() === normalizedSearchValue && - (!optionKey || option.key === optionKey) +export const getSelectedOptionForSearchValue = ({ + isCaseSensitive, + searchValue, + selectedOptions, + optionKey, +}: GetSelectedOptionForSearchValue) => { + const normalizedSearchValue = transformForCaseSensitivity( + searchValue, + isCaseSensitive ); + return selectedOptions.find((option) => { + const normalizedOption = transformForCaseSensitivity( + option.label, + isCaseSensitive + ); + return ( + normalizedOption === normalizedSearchValue && + (!optionKey || option.key === optionKey) + ); + }); }; -const collectMatchingOption = ( - accumulator: Array>, - option: EuiComboBoxOptionOption, - selectedOptions: Array>, - normalizedSearchValue: string, - isPreFiltered: boolean, - showPrevSelected: boolean -) => { +const collectMatchingOption = ({ + accumulator, + option, + selectedOptions, + normalizedSearchValue, + isCaseSensitive, + isPreFiltered, + showPrevSelected, +}: CollectMatchingOption) => { // Only show options which haven't yet been selected unless requested. - const selectedOption = getSelectedOptionForSearchValue( - option.label, + const selectedOption = getSelectedOptionForSearchValue({ + isCaseSensitive, + searchValue: option.label, selectedOptions, - option.key - ); + optionKey: option.key, + }); if (selectedOption && !showPrevSelected) { return false; } @@ -69,35 +113,43 @@ const collectMatchingOption = ( return; } - const normalizedOption = option.label.trim().toLowerCase(); + const normalizedOption = transformForCaseSensitivity( + option.label.trim(), + isCaseSensitive + ); if (normalizedOption.includes(normalizedSearchValue)) { accumulator.push(option); } }; -export const getMatchingOptions = ( - options: Array>, - selectedOptions: Array>, - searchValue: string, - isPreFiltered: boolean, - showPrevSelected: boolean, - sortMatchesBy: string -) => { - const normalizedSearchValue = searchValue.trim().toLowerCase(); +export const getMatchingOptions = ({ + options, + selectedOptions, + searchValue, + isCaseSensitive = false, + isPreFiltered = false, + showPrevSelected = false, + sortMatchesBy = 'none', +}: GetMatchingOptions) => { + const normalizedSearchValue = transformForCaseSensitivity( + searchValue.trim(), + isCaseSensitive + ); let matchingOptions: Array> = []; options.forEach((option) => { if (option.options) { const matchingOptionsForGroup: Array> = []; option.options.forEach((groupOption: EuiComboBoxOptionOption) => { - collectMatchingOption( - matchingOptionsForGroup, - groupOption, + collectMatchingOption({ + accumulator: matchingOptionsForGroup, + option: groupOption, selectedOptions, normalizedSearchValue, + isCaseSensitive, isPreFiltered, - showPrevSelected - ); + showPrevSelected, + }); }); if (matchingOptionsForGroup.length > 0) { // Add option for group label @@ -111,14 +163,15 @@ export const getMatchingOptions = ( matchingOptions = matchingOptions.concat(matchingOptionsForGroup); } } else { - collectMatchingOption( - matchingOptions, + collectMatchingOption({ + accumulator: matchingOptions, option, selectedOptions, normalizedSearchValue, + isCaseSensitive, isPreFiltered, - showPrevSelected - ); + showPrevSelected, + }); } }); @@ -129,7 +182,11 @@ export const getMatchingOptions = ( } = { startWith: [], others: [] }; matchingOptions.forEach((object) => { - if (object.label.toLowerCase().startsWith(normalizedSearchValue)) { + const normalizedLabel = transformForCaseSensitivity( + object.label, + isCaseSensitive + ); + if (normalizedLabel.startsWith(normalizedSearchValue)) { refObj.startWith.push(object); } else { refObj.others.push(object); diff --git a/upcoming_changelogs/6268.md b/upcoming_changelogs/6268.md new file mode 100644 index 00000000000..a3041b03747 --- /dev/null +++ b/upcoming_changelogs/6268.md @@ -0,0 +1,2 @@ +- Added optional case sensitive option matching to `EuiComboBox` with the `isCaseSensitive` prop +