diff --git a/CHANGELOG.md b/CHANGELOG.md index acd69727cc9..65b9ed5ad32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## [`master`](https://github.com/elastic/eui/tree/master) - Use `cache-loader` to speed up development docs site build ([#1841](https://github.com/elastic/eui/pull/1841) +- Converted `matching_options` to TS ([#1828](https://github.com/elastic/eui/pull/1828)) **Bug fixes** @@ -8,7 +9,7 @@ ## [`10.0.1`](https://github.com/elastic/eui/tree/v10.0.1) -- Convert `EuiText`, `EuiTextColor` and `EuiTextAlign` to TS ([#1791](https://github.com/elastic/eui/pull/1791)) +- Converted `EuiText`, `EuiTextColor` and `EuiTextAlign` to TS ([#1791](https://github.com/elastic/eui/pull/1791)) - Updated `IconColor` type to better distinguish between accepted types ([#1842](https://github.com/elastic/eui/pull/1842)) ## [`10.0.0`](https://github.com/elastic/eui/tree/v10.0.0) diff --git a/src/components/combo_box/index.d.ts b/src/components/combo_box/index.d.ts index 25dd0b601df..76aa5733580 100644 --- a/src/components/combo_box/index.d.ts +++ b/src/components/combo_box/index.d.ts @@ -6,73 +6,84 @@ import { EuiComboBoxOptionsListPosition, EuiComboBoxOptionsListProps, } from '@elastic/eui'; -import { RefCallback } from '../common'; +import { RefCallback, CommonProps } from '../common'; declare module '@elastic/eui' { - export type EuiComboBoxOptionProps = ButtonHTMLAttributes & { - label: string, - isGroupLabelOption?: boolean, - } + export type EuiComboBoxOptionProps = CommonProps & + ButtonHTMLAttributes & { + label: string; + isGroupLabelOption?: boolean; + options?: EuiComboBoxOptionProps[]; + }; - export type EuiComboBoxOptionsListPosition = 'top' | 'bottom' + export type EuiComboBoxOptionsListPosition = 'top' | 'bottom'; export interface EuiComboBoxOption { - option: EuiComboBoxOptionProps, - children?: ReactNode, - className?: string, - optionRef?: RefCallback, - onClick: (option: EuiComboBoxOptionProps) => any, - onEnterKey: (option: EuiComboBoxOptionProps) => any, - disabled?: boolean, + option: EuiComboBoxOptionProps; + children?: ReactNode; + className?: string; + optionRef?: RefCallback; + onClick: (option: EuiComboBoxOptionProps) => any; + onEnterKey: (option: EuiComboBoxOptionProps) => any; + disabled?: boolean; } export interface EuiComboBoxOptionsListProps { - options?: Array, - isLoading?: boolean, - selectedOptions?: Array, - onCreateOption?: any, - searchValue?: string, - matchingOptions?: Array, - optionRef?: EuiComboBoxOption['optionRef'], - onOptionClick?: EuiComboBoxOption['onClick'], - onOptionEnterKey?: EuiComboBoxOption['onEnterKey'], - areAllOptionsSelected?: boolean, - getSelectedOptionForSearchValue?: (searchValue: string, selectedOptions: Array) => EuiComboBoxOptionProps, - updatePosition: (parameter?: UIEvent | EuiPanelProps['panelRef']) => any, - position?: EuiComboBoxOptionsListPosition, - listRef: EuiPanelProps['panelRef'], - renderOption?: (option: EuiComboBoxOptionProps, searchValue: string, OPTION_CONTENT_CLASSNAME: string) => ReactNode, - width?: number, - scrollToIndex?: number, - onScroll?: ListProps['onScroll'], - rowHeight?: number, - fullWidth?: boolean, + options?: EuiComboBoxOptionProps[]; + isLoading?: boolean; + selectedOptions?: any[]; + onCreateOption?: any; + searchValue?: string; + matchingOptions?: EuiComboBoxOptionProps[]; + optionRef?: EuiComboBoxOption['optionRef']; + onOptionClick?: EuiComboBoxOption['onClick']; + onOptionEnterKey?: EuiComboBoxOption['onEnterKey']; + areAllOptionsSelected?: boolean; + getSelectedOptionForSearchValue?: ( + searchValue: string, + selectedOptions: any[] + ) => EuiComboBoxOptionProps; + updatePosition: (parameter?: UIEvent | EuiPanelProps['panelRef']) => any; + position?: EuiComboBoxOptionsListPosition; + listRef: EuiPanelProps['panelRef']; + renderOption?: ( + option: EuiComboBoxOptionProps, + searchValue: string, + OPTION_CONTENT_CLASSNAME: string + ) => ReactNode; + width?: number; + scrollToIndex?: number; + onScroll?: ListProps['onScroll']; + rowHeight?: number; + fullWidth?: boolean; } - export const EuiComboBoxOptionsList: FunctionComponent; + export const EuiComboBoxOptionsList: FunctionComponent< + EuiComboBoxOptionsListProps + >; export type EuiComboBoxSingleSelectionShape = { asPlainText?: boolean; }; export interface EuiComboBoxProps { - id?: string, - isDisabled?: boolean, - className?: string, - placeholder?: string, - isLoading?: boolean, - async?: boolean, - singleSelection?: EuiComboBoxSingleSelectionShape | boolean, - noSuggestions?: boolean, - options?: EuiComboBoxOptionsListProps['options'], - selectedOptions?: EuiComboBoxOptionsListProps['selectedOptions'], - onBlur?: FocusEventHandler, - onChange?: (options: Array) => any, - onFocus?: FocusEventHandler, - onSearchChange?: (searchValue: string) => any, - onCreateOption?: EuiComboBoxOptionsListProps['onCreateOption'], - renderOption?: EuiComboBoxOptionsListProps['renderOption'], - isInvalid?: boolean, - rowHeight?: number, - isClearable?: boolean, - fullWidth?: boolean, + id?: string; + isDisabled?: boolean; + className?: string; + placeholder?: string; + isLoading?: boolean; + async?: boolean; + singleSelection?: EuiComboBoxSingleSelectionShape | boolean; + noSuggestions?: boolean; + options?: EuiComboBoxOptionsListProps['options']; + selectedOptions?: EuiComboBoxOptionsListProps['selectedOptions']; + onBlur?: FocusEventHandler; + onChange?: (options: Array) => any; + onFocus?: FocusEventHandler; + onSearchChange?: (searchValue: string) => any; + onCreateOption?: EuiComboBoxOptionsListProps['onCreateOption']; + renderOption?: EuiComboBoxOptionsListProps['renderOption']; + isInvalid?: boolean; + rowHeight?: number; + isClearable?: boolean; + fullWidth?: boolean; inputRef?: (element: HTMLInputElement) => void; } export const EuiComboBox: FunctionComponent; diff --git a/src/components/combo_box/matching_options.js b/src/components/combo_box/matching_options.js deleted file mode 100644 index 34a8241128e..00000000000 --- a/src/components/combo_box/matching_options.js +++ /dev/null @@ -1,69 +0,0 @@ -export const flattenOptionGroups = optionsOrGroups => { - return optionsOrGroups.reduce((options, optionOrGroup) => { - if (optionOrGroup.options) { - options.push(...optionOrGroup.options); - } else { - options.push(optionOrGroup); - } - return options; - }, []); -}; - -export const getSelectedOptionForSearchValue = (searchValue, selectedOptions) => { - const normalizedSearchValue = searchValue.toLowerCase(); - return selectedOptions.find(option => option.label.toLowerCase() === normalizedSearchValue); -}; - -const collectMatchingOption = (accumulator, option, selectedOptions, normalizedSearchValue, isPreFiltered, showPrevSelected) => { - // Only show options which haven't yet been selected unless requested. - const selectedOption = getSelectedOptionForSearchValue(option.label, selectedOptions); - if (selectedOption && !showPrevSelected) { - return false; - } - - // If the options have already been prefiltered then we can skip filtering against the search value. - if (isPreFiltered) { - accumulator.push(option); - return; - } - - if (!normalizedSearchValue) { - accumulator.push(option); - return; - } - - const normalizedOption = option.label.trim().toLowerCase(); - if (normalizedOption.includes(normalizedSearchValue)) { - accumulator.push(option); - } -}; - -export const getMatchingOptions = (options, selectedOptions, searchValue, isPreFiltered, showPrevSelected) => { - const normalizedSearchValue = searchValue.trim().toLowerCase(); - const matchingOptions = []; - - options.forEach(option => { - if (option.options) { - const matchingOptionsForGroup = []; - option.options.forEach(groupOption => { - collectMatchingOption( - matchingOptionsForGroup, - groupOption, - selectedOptions, - normalizedSearchValue, - isPreFiltered, - showPrevSelected - ); - }); - if (matchingOptionsForGroup.length > 0) { - // Add option for group label - matchingOptions.push({ label: option.label, isGroupLabelOption: true }); - // Add matching options for group - matchingOptions.push(...matchingOptionsForGroup); - } - } else { - collectMatchingOption(matchingOptions, option, selectedOptions, normalizedSearchValue, isPreFiltered, showPrevSelected); - } - }); - return matchingOptions; -}; diff --git a/src/components/combo_box/matching_options.test.ts b/src/components/combo_box/matching_options.test.ts new file mode 100644 index 00000000000..09ff2c77e7e --- /dev/null +++ b/src/components/combo_box/matching_options.test.ts @@ -0,0 +1,171 @@ +import { EuiComboBoxOptionProps } from '@elastic/eui'; +import { + flattenOptionGroups, + getSelectedOptionForSearchValue, + getMatchingOptions, +} from './matching_options'; + +const options: EuiComboBoxOptionProps[] = [ + { + label: 'Titan', + 'data-test-subj': 'titanOption', + }, + { + label: 'Saturn', + 'data-test-subj': 'saturnOption', + }, + { + label: 'Mimas', + }, +]; + +describe('flattenOptionGroups', () => { + test('it flattens one level of options', () => { + // Assemble + const input = [ + { + label: 'Titan', + 'data-test-subj': 'titanOption', + }, + { + label: 'Enceladus', + options: [ + { + label: 'Saturn', + 'data-test-subj': 'saturnOption', + }, + ], + }, + { + label: 'Mimas', + }, + ]; + const expected = options; + // Act + const got = flattenOptionGroups(input); + // Assert + expect(got).toMatchObject(expected); + }); +}); + +describe('getSelectedOptionForSearchValue', () => { + test('gets the first matching selected option for search value', () => { + // Assemble + const expected: EuiComboBoxOptionProps = { + label: 'Saturn', + 'data-test-subj': 'saturnOption', + }; + // Act + const got = getSelectedOptionForSearchValue('saturn', options); + // Assert + expect(got).toMatchObject(expected); + }); +}); + +describe('getSelectedOptionForSearchValue', () => { + test('returns undefined when no matching option found for search value', () => { + // Act + const got = getSelectedOptionForSearchValue('Pluto', options); + // Assert + expect(got).toBeUndefined(); + }); + test('gets the first matching selected option for search value', () => { + // Assemble + const expected: EuiComboBoxOptionProps = { + label: 'Saturn', + 'data-test-subj': 'saturnOption', + }; + // Act + const got = getSelectedOptionForSearchValue('saturn', options); + // Assert + expect(got).toMatchObject(expected); + }); +}); + +interface GetMatchingOptionsTestCase { + options: EuiComboBoxOptionProps[]; + selectedOptions: EuiComboBoxOptionProps[]; + searchValue: string; + isPreFiltered: boolean; + showPrevSelected: boolean; + expected: EuiComboBoxOptionProps[]; +} + +const testCases: GetMatchingOptionsTestCase[] = [ + { + options, + selectedOptions: [ + { + label: 'Saturn', + 'data-test-subj': 'saturnOption', + }, + ], + searchValue: 'saturn', + isPreFiltered: false, + showPrevSelected: false, + expected: [], + }, + { + options, + selectedOptions: [ + { + label: 'Saturn', + 'data-test-subj': 'saturnOption', + }, + ], + searchValue: 'saturn', + isPreFiltered: true, + showPrevSelected: false, + expected: [ + { 'data-test-subj': 'titanOption', label: 'Titan' }, + { label: 'Mimas' }, + ], + }, + { + options, + selectedOptions: [ + { + label: 'Saturn', + 'data-test-subj': 'saturnOption', + }, + ], + searchValue: 'saturn', + isPreFiltered: false, + showPrevSelected: true, + expected: [{ 'data-test-subj': 'saturnOption', label: 'Saturn' }], + }, + { + options, + selectedOptions: [ + { + label: 'Saturn', + 'data-test-subj': 'saturnOption', + }, + ], + searchValue: 'saturn', + isPreFiltered: true, + showPrevSelected: true, + expected: [ + { 'data-test-subj': 'titanOption', label: 'Titan' }, + { 'data-test-subj': 'saturnOption', label: 'Saturn' }, + { label: 'Mimas' }, + ], + }, +]; + +describe('getMatchingOptions', () => { + test.each(testCases)( + '.getMatchingOptions(%o)', + (testCase: GetMatchingOptionsTestCase) => { + expect( + getMatchingOptions( + testCase.options, + testCase.selectedOptions, + testCase.searchValue, + testCase.isPreFiltered, + testCase.showPrevSelected + ) + ).toMatchObject(testCase.expected); + } + ); +}); diff --git a/src/components/combo_box/matching_options.ts b/src/components/combo_box/matching_options.ts new file mode 100644 index 00000000000..5dbd44b5f31 --- /dev/null +++ b/src/components/combo_box/matching_options.ts @@ -0,0 +1,107 @@ +import { EuiComboBoxOptionProps } from '@elastic/eui'; + +export const flattenOptionGroups = ( + optionsOrGroups: EuiComboBoxOptionProps[] +) => { + return optionsOrGroups.reduce( + ( + options: EuiComboBoxOptionProps[], + optionOrGroup: EuiComboBoxOptionProps + ) => { + if (optionOrGroup.options) { + options.push(...optionOrGroup.options); + } else { + options.push(optionOrGroup); + } + return options; + }, + [] + ); +}; + +export const getSelectedOptionForSearchValue = ( + searchValue: string, + selectedOptions: EuiComboBoxOptionProps[] +) => { + const normalizedSearchValue = searchValue.toLowerCase(); + return selectedOptions.find( + option => option.label.toLowerCase() === normalizedSearchValue + ); +}; + +const collectMatchingOption = ( + accumulator: EuiComboBoxOptionProps[], + option: EuiComboBoxOptionProps, + selectedOptions: EuiComboBoxOptionProps[], + normalizedSearchValue: string, + isPreFiltered: boolean, + showPrevSelected: boolean +) => { + // Only show options which haven't yet been selected unless requested. + const selectedOption = getSelectedOptionForSearchValue( + option.label, + selectedOptions + ); + if (selectedOption && !showPrevSelected) { + return false; + } + + // If the options have already been pre-filtered then we can skip filtering against the search value. + if (isPreFiltered) { + accumulator.push(option); + return; + } + + if (!normalizedSearchValue) { + accumulator.push(option); + return; + } + + const normalizedOption = option.label.trim().toLowerCase(); + if (normalizedOption.includes(normalizedSearchValue)) { + accumulator.push(option); + } +}; + +export const getMatchingOptions = ( + options: EuiComboBoxOptionProps[], + selectedOptions: EuiComboBoxOptionProps[], + searchValue: string, + isPreFiltered: boolean, + showPrevSelected: boolean +) => { + const normalizedSearchValue = searchValue.trim().toLowerCase(); + const matchingOptions: EuiComboBoxOptionProps[] = []; + + options.forEach(option => { + if (option.options) { + const matchingOptionsForGroup: EuiComboBoxOptionProps[] = []; + option.options.forEach((groupOption: EuiComboBoxOptionProps) => { + collectMatchingOption( + matchingOptionsForGroup, + groupOption, + selectedOptions, + normalizedSearchValue, + isPreFiltered, + showPrevSelected + ); + }); + if (matchingOptionsForGroup.length > 0) { + // Add option for group label + matchingOptions.push({ label: option.label, isGroupLabelOption: true }); + // Add matching options for group + matchingOptions.push(...matchingOptionsForGroup); + } + } else { + collectMatchingOption( + matchingOptions, + option, + selectedOptions, + normalizedSearchValue, + isPreFiltered, + showPrevSelected + ); + } + }); + return matchingOptions; +};