diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ff71a4a097..9e04562daaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ **Bug fixes** - Fixed bug in `EuiComboBox` where the input was dropping to the next line when a `EuiBadge` had a very long text ([#3968](https://github.com/elastic/eui/pull/3968)) +- Fixed type mismatch between `EuiSelectable` options extended via `EuiSelectableOption` and internal option types ([#3983](https://github.com/elastic/eui/pull/3983)) ## [`28.3.0`](https://github.com/elastic/eui/tree/v28.3.0) diff --git a/src/components/selectable/matching_options.ts b/src/components/selectable/matching_options.ts index 202b69f7c65..aff5151183a 100644 --- a/src/components/selectable/matching_options.ts +++ b/src/components/selectable/matching_options.ts @@ -19,36 +19,36 @@ import { EuiSelectableOption } from './selectable_option'; -const getSearchableLabel = ( - option: EuiSelectableOption, +const getSearchableLabel = ( + option: EuiSelectableOption, normalize: boolean = true ): string => { const searchableLabel = option.searchableLabel || option.label; return normalize ? searchableLabel.trim().toLowerCase() : searchableLabel; }; -const getSelectedOptionForSearchValue = ( +const getSelectedOptionForSearchValue = ( searchValue: string, - selectedOptions: EuiSelectableOption[] + selectedOptions: Array> ) => { const normalizedSearchValue = searchValue.toLowerCase(); return selectedOptions.find( - option => getSearchableLabel(option) === normalizedSearchValue + option => getSearchableLabel(option) === normalizedSearchValue ); }; -const collectMatchingOption = ( - accumulator: EuiSelectableOption[], - option: EuiSelectableOption, +const collectMatchingOption = ( + accumulator: Array>, + option: EuiSelectableOption, normalizedSearchValue: string, isPreFiltered?: boolean, - selectedOptions?: EuiSelectableOption[] + selectedOptions?: Array> ) => { // Don't show options that have already been requested if // the selectedOptions list exists if (selectedOptions) { - const selectedOption = getSelectedOptionForSearchValue( - getSearchableLabel(option, false), + const selectedOption = getSelectedOptionForSearchValue( + getSearchableLabel(option, false), selectedOptions ); if (selectedOption) { @@ -68,17 +68,17 @@ const collectMatchingOption = ( return; } - const normalizedOption = getSearchableLabel(option); + const normalizedOption = getSearchableLabel(option); if (normalizedOption.includes(normalizedSearchValue)) { accumulator.push(option); } }; -export const getMatchingOptions = ( +export const getMatchingOptions = ( /** * All available options to match against */ - options: EuiSelectableOption[], + options: Array>, /** * String to match option.label || option.searchableLabel against */ @@ -91,13 +91,13 @@ export const getMatchingOptions = ( * To exclude selected options from the search list, * pass the array of selected options */ - selectedOptions?: EuiSelectableOption[] + selectedOptions?: Array> ) => { const normalizedSearchValue = searchValue.toLowerCase(); - const matchingOptions: EuiSelectableOption[] = []; + const matchingOptions: Array> = []; options.forEach(option => { - collectMatchingOption( + collectMatchingOption( matchingOptions, option, normalizedSearchValue, diff --git a/src/components/selectable/selectable.test.tsx b/src/components/selectable/selectable.test.tsx index 8b07afe3582..a5bc1658fdd 100644 --- a/src/components/selectable/selectable.test.tsx +++ b/src/components/selectable/selectable.test.tsx @@ -18,8 +18,8 @@ */ import React from 'react'; -import { render } from 'enzyme'; -import { requiredProps } from '../../test/required_props'; +import { mount, render } from 'enzyme'; +import { requiredProps } from '../../test'; import { EuiSelectable } from './selectable'; import { EuiSelectableOption } from './selectable_option'; @@ -129,4 +129,75 @@ describe('EuiSelectable', () => { expect(component).toMatchSnapshot(); }); }); + + describe('custom options', () => { + test('optional properties', () => { + type OptionalOption = EuiSelectableOption<{ value?: string }>; + const options: OptionalOption[] = [ + { + label: 'Titan', + 'data-test-subj': 'titanOption', + value: 'titan', + }, + { + label: 'Enceladus', + value: 'enceladus', + }, + { + label: + "Pandora is one of Saturn's moons, named for a Titaness of Greek mythology", + }, + ]; + + const onChange = (options: OptionalOption[]) => { + jest.fn(() => options); + }; + + const component = mount( + options={options} onChange={onChange}> + {list => list} + + ); + + expect( + (component.find('EuiSelectableList').props() as any).visibleOptions + ).toEqual(options); + }); + + test('required properties', () => { + type ExtendedOption = EuiSelectableOption<{ value: string }>; + const options: ExtendedOption[] = [ + { + label: 'Titan', + 'data-test-subj': 'titanOption', + value: 'titan', + }, + { + label: 'Enceladus', + value: 'enceladus', + }, + { + label: + "Pandora is one of Saturn's moons, named for a Titaness of Greek mythology", + value: 'pandora', + }, + ]; + + const onChange = (options: ExtendedOption[]) => { + jest.fn(() => options); + }; + + const component = mount( + options={options} onChange={onChange}> + {list => list} + + ); + + component.update(); + + expect( + (component.find('EuiSelectableList').props() as any).visibleOptions + ).toEqual(options); + }); + }); }); diff --git a/src/components/selectable/selectable.tsx b/src/components/selectable/selectable.tsx index 916eca33db1..caa3891fef4 100644 --- a/src/components/selectable/selectable.tsx +++ b/src/components/selectable/selectable.tsx @@ -51,7 +51,7 @@ type EuiSelectableOptionsListPropsWithDefaults = RequiredEuiSelectableOptionsLis Partial; // `searchProps` can only be specified when `searchable` is true -type EuiSelectableSearchableProps = ExclusiveUnion< +type EuiSelectableSearchableProps = ExclusiveUnion< { searchable: false; }, @@ -63,13 +63,13 @@ type EuiSelectableSearchableProps = ExclusiveUnion< /** * Passes props down to the `EuiFieldSearch` */ - searchProps?: Partial; + searchProps?: Partial>; } >; -export type EuiSelectableProps = CommonProps & +export type EuiSelectableProps = CommonProps & Omit, 'children' | 'onChange'> & - EuiSelectableSearchableProps & { + EuiSelectableSearchableProps & { /** * Function that takes the `list` node and then * the `search` node (if `searchable` is applied) @@ -78,16 +78,16 @@ export type EuiSelectableProps = CommonProps & list: ReactElement< typeof EuiSelectableMessage | typeof EuiSelectableList >, - search: ReactElement | undefined + search: ReactElement> | undefined ) => ReactNode; /** * Array of EuiSelectableOption objects. See #EuiSelectableOptionProps */ - options: Array>; + options: Array>; /** * Passes back the altered `options` array with selected options as */ - onChange?: (options: EuiSelectableOption[]) => void; + onChange?: (options: Array>) => void; /** * Sets the single selection policy of * `false`: allows multiple selection @@ -118,7 +118,7 @@ export type EuiSelectableProps = CommonProps & * Returns `(option, searchValue)` */ renderOption?: ( - option: EuiSelectableOption, + option: EuiSelectableOption, searchValue: string ) => ReactNode; /** @@ -138,16 +138,16 @@ export type EuiSelectableProps = CommonProps & emptyMessage?: ReactElement | string; }; -export interface EuiSelectableState { +export interface EuiSelectableState { activeOptionIndex?: number; searchValue: string; - visibleOptions: EuiSelectableOption[]; + visibleOptions: Array>; isFocused: boolean; } -export class EuiSelectable extends Component< - EuiSelectableProps, - EuiSelectableState +export class EuiSelectable extends Component< + EuiSelectableProps, + EuiSelectableState > { static defaultProps = { options: [], @@ -155,16 +155,16 @@ export class EuiSelectable extends Component< searchable: false, }; - private optionsListRef = createRef(); + private optionsListRef = createRef>(); rootId = htmlIdGenerator(); - constructor(props: EuiSelectableProps) { + constructor(props: EuiSelectableProps) { super(props); const { options, singleSelection } = props; const initialSearchValue = ''; - const visibleOptions = getMatchingOptions(options, initialSearchValue); + const visibleOptions = getMatchingOptions(options, initialSearchValue); // ensure that the currently selected single option is active if it is in the visibleOptions const selectedOptions = options.filter(option => option.checked); @@ -183,14 +183,14 @@ export class EuiSelectable extends Component< }; } - static getDerivedStateFromProps( - nextProps: EuiSelectableProps, - prevState: EuiSelectableState + static getDerivedStateFromProps( + nextProps: EuiSelectableProps, + prevState: EuiSelectableState ) { const { options } = nextProps; const { activeOptionIndex, searchValue } = prevState; - const matchingOptions = getMatchingOptions(options, searchValue); + const matchingOptions = getMatchingOptions(options, searchValue); const stateUpdate = { visibleOptions: matchingOptions, activeOptionIndex }; @@ -318,7 +318,7 @@ export class EuiSelectable extends Component< }; onSearchChange = ( - visibleOptions: EuiSelectableOption[], + visibleOptions: Array>, searchValue: string ) => { this.setState( @@ -342,9 +342,9 @@ export class EuiSelectable extends Component< }); }; - onOptionClick = (options: EuiSelectableOption[]) => { + onOptionClick = (options: Array>) => { this.setState(state => ({ - visibleOptions: getMatchingOptions(options, state.searchValue), + visibleOptions: getMatchingOptions(options, state.searchValue), activeOptionIndex: this.state.activeOptionIndex, })); if (this.props.onChange) { @@ -492,7 +492,7 @@ export class EuiSelectable extends Component< */ const getAccessibleName = ( props: - | Partial + | Partial> | EuiSelectableOptionsListPropsWithDefaults | undefined, messageContentId?: string @@ -534,7 +534,7 @@ export class EuiSelectable extends Component< const search = searchable ? ( {(placeholderName: string) => ( - key="listSearch" options={options} onChange={this.onSearchChange} @@ -565,7 +565,7 @@ export class EuiSelectable extends Component< ) : ( {(placeholderName: string) => ( - key="list" options={options} visibleOptions={visibleOptions} diff --git a/src/components/selectable/selectable_list/selectable_list.tsx b/src/components/selectable/selectable_list/selectable_list.tsx index f9908afd699..75408b49804 100644 --- a/src/components/selectable/selectable_list/selectable_list.tsx +++ b/src/components/selectable/selectable_list/selectable_list.tsx @@ -34,8 +34,9 @@ import { areEqual, } from 'react-window'; -interface ListChildComponentProps extends ReactWindowListChildComponentProps { - data: EuiSelectableOption[]; +interface ListChildComponentProps + extends ReactWindowListChildComponentProps { + data: Array>; } // Consumer Configurable Props via `EuiSelectable.listProps` @@ -73,15 +74,15 @@ export type EuiSelectableOptionsListProps = CommonProps & onFocusBadge?: EuiSelectableListItemProps['onFocusBadge']; }; -export type EuiSelectableListProps = EuiSelectableOptionsListProps & { +export type EuiSelectableListProps = EuiSelectableOptionsListProps & { /** * All possible options */ - options: EuiSelectableOption[]; + options: Array>; /** * Filtered options list (if applicable) */ - visibleOptions?: EuiSelectableOption[]; + visibleOptions?: Array>; /** * Search value to highlight on the option render */ @@ -89,13 +90,13 @@ export type EuiSelectableListProps = EuiSelectableOptionsListProps & { /** * Returns the array of options with altered checked state */ - onOptionClick: (options: EuiSelectableOption[]) => void; + onOptionClick: (options: Array>) => void; /** * Custom render for the label portion of the option; * Takes (option, searchValue), returns ReactNode */ renderOption?: ( - option: EuiSelectableOption, + option: EuiSelectableOption, searchValue: string ) => ReactNode; /** @@ -115,7 +116,7 @@ export type EuiSelectableListProps = EuiSelectableOptionsListProps & { setActiveOptionIndex: (index: number, cb?: () => void) => void; }; -export class EuiSelectableList extends Component { +export class EuiSelectableList extends Component> { static defaultProps = { rowHeight: 32, searchValue: '', @@ -190,11 +191,11 @@ export class EuiSelectableList extends Component { } } - constructor(props: EuiSelectableListProps) { + constructor(props: EuiSelectableListProps) { super(props); } - ListRow = memo(({ data, index, style }: ListChildComponentProps) => { + ListRow = memo(({ data, index, style }: ListChildComponentProps) => { const option = data[index]; const { label, @@ -215,6 +216,7 @@ export class EuiSelectableList extends Component { role="presentation" className="euiSelectableList__groupLabel" style={style} + // @ts-ignore complex {...(optionRest as HTMLAttributes)}> {prepend} {label} @@ -237,7 +239,6 @@ export class EuiSelectableList extends Component { ref={ref ? ref.bind(null, index) : undefined} isFocused={this.props.activeOptionIndex === index} title={searchableLabel || label} - showIcons={this.props.showIcons} checked={checked} disabled={disabled} prepend={prepend} @@ -246,6 +247,7 @@ export class EuiSelectableList extends Component { aria-setsize={data.length - labelCount} onFocusBadge={this.props.onFocusBadge} allowExclusions={this.props.allowExclusions} + // @ts-ignore complex {...(optionRest as EuiSelectableListItemProps)}> {this.props.renderOption ? ( this.props.renderOption(option, this.props.searchValue) @@ -340,7 +342,7 @@ export class EuiSelectableList extends Component { ); } - onAddOrRemoveOption = (option: EuiSelectableOption) => { + onAddOrRemoveOption = (option: EuiSelectableOption) => { if (option.disabled) { return; } @@ -361,7 +363,7 @@ export class EuiSelectableList extends Component { ); }; - private onAddOption = (addedOption: EuiSelectableOption) => { + private onAddOption = (addedOption: EuiSelectableOption) => { const { onOptionClick, options, singleSelection } = this.props; const updatedOptions = options.map(option => { @@ -382,7 +384,7 @@ export class EuiSelectableList extends Component { onOptionClick(updatedOptions); }; - private onRemoveOption = (removedOption: EuiSelectableOption) => { + private onRemoveOption = (removedOption: EuiSelectableOption) => { const { onOptionClick, singleSelection, options } = this.props; const updatedOptions = options.map(option => { @@ -398,7 +400,7 @@ export class EuiSelectableList extends Component { onOptionClick(updatedOptions); }; - private onExcludeOption = (excludedOption: EuiSelectableOption) => { + private onExcludeOption = (excludedOption: EuiSelectableOption) => { const { onOptionClick, options } = this.props; excludedOption.checked = 'off'; diff --git a/src/components/selectable/selectable_option.tsx b/src/components/selectable/selectable_option.tsx index 91c36400797..704a790eb3e 100644 --- a/src/components/selectable/selectable_option.tsx +++ b/src/components/selectable/selectable_option.tsx @@ -22,7 +22,7 @@ import { CommonProps, ExclusiveUnion } from '../common'; export type EuiSelectableOptionCheckedType = 'on' | 'off' | undefined; -export type EuiSelectableOptionBase = CommonProps & { +export type EuiSelectableOptionBase = CommonProps & { /** * Visible label of option. * Must be unique across items if `key` is not supplied @@ -59,18 +59,28 @@ export type EuiSelectableOptionBase = CommonProps & { */ append?: React.ReactNode; ref?: (optionIndex: number) => void; -} & T; + /** + * Disallow `id` from being set. + * Option item `id`s are coordinated at a higher level for a11y reasons. + */ + id?: never; +}; -export type EuiSelectableGroupLabelOption = Omit< - EuiSelectableOptionBase, +type _EuiSelectableGroupLabelOption = Omit< + EuiSelectableOptionBase, 'isGroupLabel' > & - HTMLAttributes & { + Exclude, 'id'> & { isGroupLabel: true; }; -export type EuiSelectableLIOption = EuiSelectableOptionBase & - HTMLAttributes; +export type EuiSelectableGroupLabelOption = _EuiSelectableGroupLabelOption & + T; + +type _EuiSelectableLIOption = EuiSelectableOptionBase & + Exclude, 'id'>; + +export type EuiSelectableLIOption = _EuiSelectableLIOption & T; export type EuiSelectableOption = ExclusiveUnion< EuiSelectableGroupLabelOption, diff --git a/src/components/selectable/selectable_search/selectable_search.tsx b/src/components/selectable/selectable_search/selectable_search.tsx index ea3dbfe81ea..fc19b4341f4 100644 --- a/src/components/selectable/selectable_search/selectable_search.tsx +++ b/src/components/selectable/selectable_search/selectable_search.tsx @@ -24,16 +24,19 @@ import { EuiFieldSearch, EuiFieldSearchProps } from '../../form'; import { getMatchingOptions } from '../matching_options'; import { EuiSelectableOption } from '../selectable_option'; -export type EuiSelectableSearchProps = Omit & +export type EuiSelectableSearchProps = Omit< + EuiFieldSearchProps, + 'onChange' +> & CommonProps & { /** * Passes back (matchingOptions, searchValue) */ onChange: ( - matchingOptions: EuiSelectableOption[], + matchingOptions: Array>, searchValue: string ) => void; - options: EuiSelectableOption[]; + options: Array>; defaultValue: string; /** * The id of the visible list to create the appropriate aria controls @@ -45,15 +48,15 @@ export interface EuiSelectableSearchState { searchValue: string; } -export class EuiSelectableSearch extends Component< - EuiSelectableSearchProps, +export class EuiSelectableSearch extends Component< + EuiSelectableSearchProps, EuiSelectableSearchState > { static defaultProps = { defaultValue: '', }; - constructor(props: EuiSelectableSearchProps) { + constructor(props: EuiSelectableSearchProps) { super(props); this.state = { @@ -63,14 +66,20 @@ export class EuiSelectableSearch extends Component< componentDidMount() { const { searchValue } = this.state; - const matchingOptions = getMatchingOptions(this.props.options, searchValue); + const matchingOptions = getMatchingOptions( + this.props.options, + searchValue + ); this.props.onChange(matchingOptions, searchValue); } onSearchChange = (value: string) => { if (value !== this.state.searchValue) { this.setState({ searchValue: value }, () => { - const matchingOptions = getMatchingOptions(this.props.options, value); + const matchingOptions = getMatchingOptions( + this.props.options, + value + ); this.props.onChange(matchingOptions, value); }); } diff --git a/src/components/selectable/selectable_templates/selectable_template_sitewide.tsx b/src/components/selectable/selectable_templates/selectable_template_sitewide.tsx index 779d6c1cc6b..02bfc07b221 100644 --- a/src/components/selectable/selectable_templates/selectable_template_sitewide.tsx +++ b/src/components/selectable/selectable_templates/selectable_template_sitewide.tsx @@ -38,7 +38,7 @@ import { } from './selectable_template_sitewide_option'; export type EuiSelectableTemplateSitewideProps = Partial< - Omit + Omit, 'options'> > & { /** * Extends the typical EuiSelectable #Options with the addition of pre-composed elements