From 8c4edaf9d66c529886681e5686239d173eacd178 Mon Sep 17 00:00:00 2001 From: Kieran Nichols Date: Wed, 27 Sep 2023 15:30:01 -0400 Subject: [PATCH] feat(autocomplete): added new `forceFilter()` method to allow for dynamically updating the options of an autocomplete (#391) --- .../autocomplete/autocomplete-constants.ts | 4 + .../autocomplete/autocomplete-foundation.ts | 20 ++++- src/lib/autocomplete/autocomplete.ts | 11 ++- .../components/autocomplete/autocomplete.mdx | 16 ++++ .../spec/autocomplete/autocomplete.spec.ts | 75 ++++++++++++++++++- 5 files changed, 123 insertions(+), 3 deletions(-) diff --git a/src/lib/autocomplete/autocomplete-constants.ts b/src/lib/autocomplete/autocomplete-constants.ts index 6b809b677..d7ebe04f6 100644 --- a/src/lib/autocomplete/autocomplete-constants.ts +++ b/src/lib/autocomplete/autocomplete-constants.ts @@ -77,3 +77,7 @@ export interface IAutocompletePopupConfiguration { export interface IAutocompleteSelectEventData { value: T; } + +export interface IAutocompleteForceFilterOptions { + preserveValue: boolean; +} diff --git a/src/lib/autocomplete/autocomplete-foundation.ts b/src/lib/autocomplete/autocomplete-foundation.ts index db0fe47a5..1a37d369c 100644 --- a/src/lib/autocomplete/autocomplete-foundation.ts +++ b/src/lib/autocomplete/autocomplete-foundation.ts @@ -4,7 +4,7 @@ import { IListItemComponent } from '../list'; import { IListDropdownConfig, IListDropdownOption, ListDropdownAsyncStyle, ListDropdownFooterBuilder, ListDropdownHeaderBuilder, ListDropdownOptionBuilder } from '../list-dropdown'; import { IListDropdownAwareFoundation, ListDropdownAwareFoundation } from '../list-dropdown/list-dropdown-aware-foundation'; import { IAutocompleteAdapter } from './autocomplete-adapter'; -import { AutocompleteFilterCallback, AutocompleteMode, AutocompleteOptionBuilder, AutocompleteSelectedTextBuilder, AUTOCOMPLETE_CONSTANTS, IAutocompleteOption, IAutocompleteOptionGroup, IAutocompleteSelectEventData } from './autocomplete-constants'; +import { AutocompleteFilterCallback, AutocompleteMode, AutocompleteOptionBuilder, AutocompleteSelectedTextBuilder, AUTOCOMPLETE_CONSTANTS, IAutocompleteForceFilterOptions, IAutocompleteOption, IAutocompleteOptionGroup, IAutocompleteSelectEventData } from './autocomplete-constants'; import { getSelectedOption, isOptionType, optionEqualPredicate, OptionType } from './autocomplete-utils'; export interface IAutocompleteFoundation extends IListDropdownAwareFoundation { @@ -25,6 +25,7 @@ export interface IAutocompleteFoundation extends IListDropdownAwareFoundation { matchKey: string | null | undefined; appendOptions(options: IAutocompleteOption[] | IAutocompleteOptionGroup[]): void; beforeValueChange: (value: any) => boolean | Promise; + forceFilter(opts: IAutocompleteForceFilterOptions): void; } /** @@ -117,6 +118,23 @@ export class AutocompleteFoundation extends ListDropdownAwareFoundation implemen } } + public async forceFilter({ preserveValue }: IAutocompleteForceFilterOptions): Promise { + // Clear any existing options since they are expected to no longer be valid + this._options = []; + + // Execute the filter callback to fetch new options if the consumer has provided any. + // This allows us to update the current value(s) with new label(s) if there are any matches + await this._executeFilter(true, true); + + // Edge case, but if the consumer has a need to preserve the existing selection if it doesn't exist in the new options, this will support that + if (preserveValue) { + this._options.push(...this._selectedOptions as IAutocompleteOption[] & IAutocompleteOptionGroup[]); + } + + // This will update our current state, but it's expected that consumers will manage their own values so it's likely that this will be called again soon + this._applyValue(this._values); + } + private _attachListeners(): void { this._adapter.addInputListener('click', this._clickListener); this._adapter.addInputListener('focus', this._focusListener); diff --git a/src/lib/autocomplete/autocomplete.ts b/src/lib/autocomplete/autocomplete.ts index 8be1a9b52..c698fca77 100644 --- a/src/lib/autocomplete/autocomplete.ts +++ b/src/lib/autocomplete/autocomplete.ts @@ -9,7 +9,7 @@ import { PopupComponent } from '../popup'; import { SkeletonComponent } from '../skeleton'; import { TextFieldComponent } from '../text-field'; import { AutocompleteAdapter } from './autocomplete-adapter'; -import { AutocompleteFilterCallback, AutocompleteMode, AutocompleteOptionBuilder, AutocompleteSelectedTextBuilder, AUTOCOMPLETE_CONSTANTS, IAutocompleteOption, IAutocompleteOptionGroup, IAutocompleteSelectEventData } from './autocomplete-constants'; +import { AutocompleteFilterCallback, AutocompleteMode, AutocompleteOptionBuilder, AutocompleteSelectedTextBuilder, AUTOCOMPLETE_CONSTANTS, IAutocompleteForceFilterOptions, IAutocompleteOption, IAutocompleteOptionGroup, IAutocompleteSelectEventData } from './autocomplete-constants'; import { AutocompleteFoundation } from './autocomplete-foundation'; import template from './autocomplete.html'; @@ -36,6 +36,7 @@ export interface IAutocompleteComponent extends IListDropdownAware { appendOptions(options: IAutocompleteOption[] | IAutocompleteOptionGroup[]): void; openDropdown(): void; closeDropdown(): void; + forceFilter(opts?: IAutocompleteForceFilterOptions): void; } declare global { @@ -232,4 +233,12 @@ export class AutocompleteComponent extends ListDropdownAware implements IAutocom public closeDropdown(): void { this.open = false; } + + /** + * Forces the filter callback to be executed to update the current selection state with new options. + * @param opts + */ + public forceFilter(opts: IAutocompleteForceFilterOptions = { preserveValue: false }): void { + this._foundation.forceFilter(opts); + } } diff --git a/src/stories/src/components/autocomplete/autocomplete.mdx b/src/stories/src/components/autocomplete/autocomplete.mdx index b61c2266b..139775a6a 100644 --- a/src/stories/src/components/autocomplete/autocomplete.mdx +++ b/src/stories/src/components/autocomplete/autocomplete.mdx @@ -173,6 +173,14 @@ Closes the dropdown. + + +Forces the filter callback to be executed to update the current selection state with new options. + +Call this when you need to update the options of an autocomplete dynamically by forcing the filter callback to execute and clear any existing options. + + + --- @@ -331,4 +339,12 @@ interface IAutocompletePopupConfiguration { } ``` +### IAutocompleteForceFilterOptions + +```ts +interface IAutocompleteForceFilterOptions { + preserveValue: boolean; +} +``` + diff --git a/src/test/spec/autocomplete/autocomplete.spec.ts b/src/test/spec/autocomplete/autocomplete.spec.ts index 60c746e26..88f1bc4a6 100644 --- a/src/test/spec/autocomplete/autocomplete.spec.ts +++ b/src/test/spec/autocomplete/autocomplete.spec.ts @@ -1528,7 +1528,7 @@ describe('AutocompleteComponent', function(this: ITestContext) { expect(this.context.component.value).toBe(listItems[2].value); }); - it('should pass in to-be new value in beforeValueChange', async function(this: ITestContext) { + it('should pass in to-be new value in beforeValueChange', async function(this: ITestContext) { this.context = setupTestContext(true); let newValue = ''; this.context.component.filter = () => DEFAULT_FILTER_OPTIONS; @@ -1546,6 +1546,79 @@ describe('AutocompleteComponent', function(this: ITestContext) { expect(newValue).toBe(listItems[2].value); }); + + it('should force filter callback to execute and update selected value', async function(this: ITestContext) { + this.context = setupTestContext(true); + this.context.component.filter = () => DEFAULT_FILTER_OPTIONS; + + _triggerDropdownClick(this.context.input); + + await tick(); + const listItems = _getListItems(this.context.component.popupElement); + _clickListItem(2, this.context.component.popupElement); + await tick(); + + expect(this.context.component.value).toBe(listItems[2].value); + + this.context.component.filter = () => [ + { label: `${DEFAULT_FILTER_OPTIONS[2].label} UPDATED`, value: DEFAULT_FILTER_OPTIONS[2].value}, + { label: 'New', value: 'new' } + ]; + this.context.component.forceFilter(); + + await tick(); + + expect(this.context.input.value).toBe(`${DEFAULT_FILTER_OPTIONS[2].label} UPDATED`); + expect(this.context.component.value).toBe(DEFAULT_FILTER_OPTIONS[2].value); + }); + + it('should force filter callback to execute and remove selected value', async function(this: ITestContext) { + this.context = setupTestContext(true); + this.context.component.filter = () => DEFAULT_FILTER_OPTIONS; + + _triggerDropdownClick(this.context.input); + + await tick(); + const listItems = _getListItems(this.context.component.popupElement); + _clickListItem(2, this.context.component.popupElement); + await tick(); + + expect(this.context.component.value).toBe(listItems[2].value); + + this.context.component.filter = () => [ + { label: 'New', value: 'new' } + ]; + this.context.component.forceFilter(); + + await tick(); + + expect(this.context.input.value).toBe(''); + expect(this.context.component.value).toBe(DEFAULT_FILTER_OPTIONS[2].value); + }); + + it('should force filter callback to execute and preserve selected value if not present in new filtered options', async function(this: ITestContext) { + this.context = setupTestContext(true); + this.context.component.filter = () => DEFAULT_FILTER_OPTIONS; + + _triggerDropdownClick(this.context.input); + + await tick(); + const listItems = _getListItems(this.context.component.popupElement); + _clickListItem(2, this.context.component.popupElement); + await tick(); + + expect(this.context.component.value).toBe(listItems[2].value); + + this.context.component.filter = () => [ + { label: 'New', value: 'new' } + ]; + this.context.component.forceFilter({ preserveValue: true }); + + await tick(); + + expect(this.context.input.value).toBe(DEFAULT_FILTER_OPTIONS[2].label); + expect(this.context.component.value).toBe(DEFAULT_FILTER_OPTIONS[2].value); + }); }); });