From 4b49c454aeecf9f32f970395c43a913603e96c6a Mon Sep 17 00:00:00 2001 From: "Frank Pigeon Jr." <4629398+fpigeonjr@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:22:17 -0500 Subject: [PATCH 1/4] gh-728: replace unsafe/legacy types in autocomplete family Replaces `any`/legacy-typed provider consts/`Function`-shaped callbacks across the autocomplete component family in src/ui-kit/form-controls, following the same approach used for the non-autocomplete slice (#732) and the parent issue #586. - autocomplete/autocomplete.component.ts: introduced AutocompleteItem (Record) for key/value option objects; typed options/categories/enterEvent/httpRequest/cache/keyEvents/ propogateChange/onKeydown/setSelected/writeValue/registerOnChange against string | AutocompleteItem instead of any; NG_VALUE_ACCESSOR provider const typed as Angular's Provider - autocomplete/autocomplete.service.ts: setFetchMethod/fetch return types narrowed from any to void/unknown[] - autocomplete-multiselect/autocomplete-cache.ts: Cached/AutocompleteCache made generic () instead of any[]/any-typed members - autocomplete-multiselect/autocomplete-multiselect.component.ts: introduced MultiselectItem and CategorizedList (typed replacement for the associative-array-like `list` structure sortByCategory/ filterOptions build); typed options/categories/serviceOptions/ itemTemplate/writeValue/registerOnChange and the NodeList-based selection helpers - sam-sds-autocomplete/{autocomplete,autocomplete-search,selected-result}: NG_VALUE_ACCESSOR provider consts typed as Provider; writeValue/registerOnChange/registerOnTouched/propogateChange narrowed to unknown/typed callbacks; TemplateRef -> TemplateRef; getFlatElements/checkItemSelected typed - sds-selected-item-model-helper.ts: clearItems(model: any) -> clearItems(model: SAMSDSSelectedItemModel) - autocomplete-seach-test-service.spec.ts: introduced HierarchicalDataItem interface for the sample fixture shape, replacing implicit any on loadedData/itemsListOutofObservable - autocomplete-search.component.spec.ts: removed any from local test fixture types (propagated/previous/item locals) - formly/components/autocomplete/test.service.ts: TestAutocompleteService updated to match AutocompleteService's Observable contract Lowers the root ESLint warning baseline 143 -> 44 (0 errors). All previously-passing specs still pass (1936/1936); test-app build and coverage-floor gate both pass with no regression. --- eslint-baseline.json | 2 +- .../components/autocomplete/test.service.ts | 2 +- .../autocomplete-cache.ts | 43 ++-- .../autocomplete-multiselect.component.ts | 161 ++++++++------ .../autocomplete/autocomplete.component.ts | 200 ++++++++++-------- .../autocomplete/autocomplete.service.ts | 4 +- .../autocomplete-seach-test-service.spec.ts | 22 +- .../autocomplete-search.component.spec.ts | 11 +- .../autocomplete-search.component.ts | 17 +- .../autocomplete/autocomplete.component.ts | 21 +- .../models/sds-selected-item-model-helper.ts | 2 +- .../selected-result.component.ts | 20 +- 12 files changed, 292 insertions(+), 213 deletions(-) diff --git a/eslint-baseline.json b/eslint-baseline.json index 985a18a3a..973917d46 100644 --- a/eslint-baseline.json +++ b/eslint-baseline.json @@ -1,4 +1,4 @@ { - "root": 143, + "root": 44, "test-app": 0 } diff --git a/src/formly/components/autocomplete/test.service.ts b/src/formly/components/autocomplete/test.service.ts index d1ae84e63..24e46e23c 100755 --- a/src/formly/components/autocomplete/test.service.ts +++ b/src/formly/components/autocomplete/test.service.ts @@ -7,7 +7,7 @@ import { map } from "rxjs/operators"; export class TestAutocompleteService implements AutocompleteService { public setFetchMethod() {} - public fetch(val: string): Observable { + public fetch(val: string): Observable { void val; return of([ { key: "MD", value: "Maryland", category: "Places" }, diff --git a/src/ui-kit/form-controls/autocomplete-multiselect/autocomplete-cache.ts b/src/ui-kit/form-controls/autocomplete-multiselect/autocomplete-cache.ts index 5553c5fa1..0010a736c 100755 --- a/src/ui-kit/form-controls/autocomplete-multiselect/autocomplete-cache.ts +++ b/src/ui-kit/form-controls/autocomplete-multiselect/autocomplete-cache.ts @@ -1,15 +1,15 @@ import { areEqual } from "../../utilities/are-equal/are-equal"; import { isEqual } from "lodash"; -export class Cached { - private contents: any[] = []; - private _lastValue: any[] = []; +export class Cached { + private contents: T[] = []; + private _lastValue: T[] = []; - public get value() { + public get value(): T[] { return this.contents; } - public get lastValue(): any[] { + public get lastValue(): T[] { return this._lastValue; } @@ -21,7 +21,7 @@ export class Cached { return Cached.countBytes(this.value); } - public static countBytes(s: any): number { + public static countBytes(s: unknown): number { return ( encodeURI(JSON.stringify(s)).split(/%(?:u[0-9A-F]{2})?[0-9A-F]{2}|./) .length - 1 @@ -30,12 +30,12 @@ export class Cached { constructor( public readonly name: string, - initialValue: any[] = [] + initialValue: T[] = [] ) { this.insert(initialValue); } - public insert(val: any[]): any[] { + public insert(val: T[]): T[] { const deduped = this.dedupe(val); this._lastValue = deduped; this.contents = [...this.contents, ...deduped]; @@ -47,8 +47,8 @@ export class Cached { this._lastValue = []; } - private dedupe(newContents): any[] { - return newContents.filter((item: any) => { + private dedupe(newContents: T[]): T[] { + return newContents.filter((item: T) => { let foundDupe = false; for (let i = 0; i < this.value.length; i++) { if (areEqual(item, this.value[i])) { @@ -62,11 +62,14 @@ export class Cached { } } -export class AutocompleteCache { - private cached: { [index: string]: Cached } = {}; - private default: Cached = new Cached("default"); +export class AutocompleteCache { + private cached: { [index: string]: Cached } = {}; + private default: Cached = new Cached("default"); private history: string[] = []; - private historyTuple: Array = [this.default, this.history]; + private historyTuple: Array> = [ + this.default, + this.history, + ]; private byteSize: number = 0; public get totalBytes(): number { @@ -82,7 +85,7 @@ export class AutocompleteCache { } } - public get lastAdded(): any[] { + public get lastAdded(): T[] { switch (this.lastSearched) { case "default": return this.default.lastValue; @@ -91,7 +94,7 @@ export class AutocompleteCache { } } - public static arraysEqual(arr1, arr2) { + public static arraysEqual(arr1: unknown, arr2: unknown): boolean { return isEqual(arr1, arr2); } @@ -99,7 +102,7 @@ export class AutocompleteCache { this.default = new Cached("default"); } - public get(key?: string): any[] { + public get(key?: string): T[] { if (key) { if (this.cached[key]) { return this.cached[key].value; @@ -111,7 +114,7 @@ export class AutocompleteCache { } } - public insert(value: any[], key?: string): any[] { + public insert(value: T[], key?: string): T[] { if (key) { return this.insertIntoCache(value, key); } else { @@ -136,7 +139,7 @@ export class AutocompleteCache { this.default.clear(); } - private insertIntoCache(value: any, key: string): any[] { + private insertIntoCache(value: T[], key: string): T[] { if ( this.cached[key] && AutocompleteCache.arraysEqual(value, this.cached[key].lastValue) @@ -156,7 +159,7 @@ export class AutocompleteCache { return this.cached[key].value; } - private updateDefault(value: any): any[] { + private updateDefault(value: T[]): T[] { if (AutocompleteCache.arraysEqual(value, this.default.lastValue)) { return this.default.value; } diff --git a/src/ui-kit/form-controls/autocomplete-multiselect/autocomplete-multiselect.component.ts b/src/ui-kit/form-controls/autocomplete-multiselect/autocomplete-multiselect.component.ts index 09917600b..148478646 100755 --- a/src/ui-kit/form-controls/autocomplete-multiselect/autocomplete-multiselect.component.ts +++ b/src/ui-kit/form-controls/autocomplete-multiselect/autocomplete-multiselect.component.ts @@ -27,10 +27,31 @@ import { LabelWrapper } from "../../wrappers/label-wrapper"; import { AutocompleteService } from "../autocomplete/autocomplete.service"; import { AutocompleteCache } from "./autocomplete-cache"; -import { SamFormService } from "../../form-service"; +import { SamFormService, SamFormEvent } from "../../form-service"; import { KeyHelper } from "../../utilities/key-helper/key-helper"; import { SamCache } from "../autocomplete/autocomplete.component"; +/** + * A key/value option object rendered by the multiselect results list. + * Property names come from `KeyValueConfig` + * (`keyProperty`/`valueProperty`/`subheadProperty`/`categoryProperty`), so + * this is indexed by string rather than a fixed shape. + */ +export type MultiselectItem = Record; + +/** + * Associative array-like structure `sortByCategory`/`filterOptions` build to + * group `MultiselectItem`s by category for the template's category/sublist + * rendering. `[index: number]` holds each category's items (with a + * `category` label stamped onto the array itself); `categories` and + * `totalItems()` are the bookkeeping the template and component logic read. + */ +export interface CategorizedList { + categories: string[]; + totalItems(): number; + [index: number]: T[] & { category?: string }; +} + @Component({ selector: "sam-autocomplete-multiselect", templateUrl: "autocomplete-multiselect.template.html", @@ -128,7 +149,7 @@ export class SamAutocompleteMultiselectComponent * Options should be an array of objects that contain the key value pairs * to be used to select in the component. */ - @Input() public options: Array = []; + @Input() public options: Array = []; /** * Key Value Config is an object that sets which property on the options * objects should be used to display the key, value, and subhead properties @@ -142,7 +163,7 @@ export class SamAutocompleteMultiselectComponent /** * Used when a service is used to get autocomplete options */ - @Input() public serviceOptions: any; + @Input() public serviceOptions: unknown; /** * Used by labelWrapper. Makes field required and displays required on label. * See labelWrapper for more detail. @@ -185,7 +206,7 @@ export class SamAutocompleteMultiselectComponent * The array should be the object for the category * to be selected. */ - @Input() public categories: Array = []; + @Input() public categories: Array = []; /** * Provides the option to allow categories to be selected */ @@ -228,7 +249,7 @@ export class SamAutocompleteMultiselectComponent /** * Allow to insert a customized template for suggestions to use */ - @Input() public itemTemplate: TemplateRef; + @Input() public itemTemplate: TemplateRef; /** * Allow to control whether display the category option in the result list or @@ -245,18 +266,18 @@ export class SamAutocompleteMultiselectComponent public searchText: string = ""; - public innerValue: Array = []; + public innerValue: Array = []; public isDisabled: boolean = false; - private list: any = []; - private inputTimer: any; + private list: MultiselectItem[] | CategorizedList = []; + private inputTimer: number; public displaySpinner: boolean = false; private textAreaMinHeight = 22; private debounceTime = 250; - private cache: AutocompleteCache = new AutocompleteCache(); + private cache: AutocompleteCache = new AutocompleteCache(); private endOfList = true; private selectedEl; - set value(val: any) { + set value(val: Array) { this.innerValue = val; this.onChangeCallback(this.innerValue); } @@ -266,8 +287,9 @@ export class SamAutocompleteMultiselectComponent } public ngOnInit() { - if (this.list.length > 0) { - this.list = this.sortByCategory(this.list); + const list = this.list as MultiselectItem[]; + if (list.length > 0) { + this.list = this.sortByCategory(list); } } @@ -281,7 +303,7 @@ export class SamAutocompleteMultiselectComponent }); this.wrapper.formatErrors(this.control); } else { - this.samFormService.formEventsUpdated$.subscribe((evt: any) => { + this.samFormService.formEventsUpdated$.subscribe((evt: SamFormEvent) => { if ( (!evt.root || evt.root === this.control.root) && evt.eventType && @@ -411,7 +433,7 @@ export class SamAutocompleteMultiselectComponent return obj; } - public getItem(): any { + public getItem(): MultiselectItem { const results = this.getResults(); const selectedChildIndex = this.getSelectedChildIndex(results); const selectedResultIndex: number = @@ -440,7 +462,9 @@ export class SamAutocompleteMultiselectComponent } public getItemFromListByIndices(categoryIndex, itemIndex) { - return this.list[categoryIndex][itemIndex]; + return (this.list as CategorizedList)[categoryIndex][ + itemIndex + ]; } public handleDownArrow(event) { @@ -504,7 +528,7 @@ export class SamAutocompleteMultiselectComponent let foundItem = false; if (Array.isArray(this.list)) { for (let i = 0; i < this.list.length; i++) { - const item = this.list[i]; + const item = this.list[i] as MultiselectItem & MultiselectItem[]; if (item) { if (item[this.keyValueConfig.valueProperty] === this.searchText) { foundItem = true; @@ -520,7 +544,9 @@ export class SamAutocompleteMultiselectComponent } } } else { - foundItem = this.findItemExistInList(this.list[0]); + foundItem = this.findItemExistInList( + (this.list as CategorizedList)[0] + ); } if (this.value) { @@ -544,7 +570,7 @@ export class SamAutocompleteMultiselectComponent } } - private findItemExistInList(item: any) { + private findItemExistInList(item: MultiselectItem[]) { let foundItem = false; for (let j = 0; j < item.length; j++) { const subitem = item[j]; @@ -571,7 +597,7 @@ export class SamAutocompleteMultiselectComponent } } - public getSelectedChildIndex(elements: any): number { + public getSelectedChildIndex(elements: NodeListOf): number { let selectedIndex = -1; if (elements.length === 0) { @@ -590,7 +616,7 @@ export class SamAutocompleteMultiselectComponent public setSelectedChild( currentSelectedIndex: number, direction: string, - elements: any + elements: NodeListOf ): number { if (currentSelectedIndex !== -1) { elements[currentSelectedIndex].classList.remove("selected"); @@ -614,7 +640,7 @@ export class SamAutocompleteMultiselectComponent return indexToSelect; } - public addSelectedClass(elements: any, index: number): void { + public addSelectedClass(elements: NodeListOf, index: number): void { elements[index].classList.add("selected"); this.selectedEl = elements[index]; } @@ -757,12 +783,16 @@ export class SamAutocompleteMultiselectComponent /*************************************************************** * Logic for filtering options * ***************************************************************/ - public fetchFromService(searchString: string, options: any, context: this) { + public fetchFromService( + searchString: string, + options: unknown, + context: this + ) { context.displaySpinner = true; return context.service .fetch(searchString, context.endOfList, options) .subscribe( - (data) => { + (data: MultiselectItem[]) => { context.cache.insert(data, searchString); context.displaySpinner = false; context.endOfList = false; @@ -772,7 +802,7 @@ export class SamAutocompleteMultiselectComponent }, () => { context.displaySpinner = false; - const errorObject = { + const errorObject: MultiselectItem = { cannotBeSelected: true, }; errorObject[context.keyValueConfig.valueProperty] = @@ -824,36 +854,32 @@ export class SamAutocompleteMultiselectComponent } else { this.list = this.options.filter((option) => { if (this.categoryIsSelectable) { + const optionCategory = option[ + this.keyValueConfig.categoryProperty + ] as string; if ( - option[this.keyValueConfig.categoryProperty] && - option[this.keyValueConfig.categoryProperty] - .toLowerCase() - .includes(searchString) && - availableCategories.indexOf( - option[this.keyValueConfig.categoryProperty] - ) === -1 + optionCategory && + optionCategory.toLowerCase().includes(searchString) && + availableCategories.indexOf(optionCategory) === -1 ) { - availableCategories.push( - option[this.keyValueConfig.categoryProperty] - ); + availableCategories.push(optionCategory); } } + const optionKey = option[this.keyValueConfig.keyProperty] as string; + const optionValue = option[this.keyValueConfig.valueProperty] as string; if ( - option[this.keyValueConfig.keyProperty] - .toLowerCase() - .includes(searchString) || - option[this.keyValueConfig.valueProperty] - .toLowerCase() - .includes(searchString) + optionKey.toLowerCase().includes(searchString) || + optionValue.toLowerCase().includes(searchString) ) { return option; } }); - this.list = this.sortByCategory(this.list); + this.list = this.sortByCategory(this.list as MultiselectItem[]); if (this.categoryIsSelectable) { + const categorizedList = this.list as CategorizedList; availableCategories.forEach((category) => { - if (this.list.categories.indexOf(category) === -1) { - this.list.categories.push(category); + if (categorizedList.categories.indexOf(category) === -1) { + categorizedList.categories.push(category); } }); } @@ -866,7 +892,9 @@ export class SamAutocompleteMultiselectComponent * Procedure to check this.list for categories * and sort data by category */ - public sortByCategory(results: Array): Array { + public sortByCategory( + results: Array + ): CategorizedList { /** * Initializes a data structure to sort data by categories. * Object works like an associative array with additional @@ -880,10 +908,10 @@ export class SamAutocompleteMultiselectComponent * items in each category in lieu of a length property * for the entire data structure. */ - const initialObject = { + const initialObject: CategorizedList = { 0: [], categories: ["uncategorized"], - totalItems: function (this) { + totalItems(this: CategorizedList) { let totalItems = 0; this.categories.forEach((category, index) => { if (this[index]) { @@ -896,14 +924,15 @@ export class SamAutocompleteMultiselectComponent return results.reduce((prev, curr) => { const category = this.keyValueConfig.categoryProperty; - if (curr[category]) { - const categoryIndex = prev.categories.indexOf(curr[category]); + const categoryValue = curr[category] as string; + if (categoryValue) { + const categoryIndex = prev.categories.indexOf(categoryValue); if (categoryIndex !== -1) { prev[categoryIndex].push(curr); } else { - const newLength = prev.categories.push(curr[category]); + const newLength = prev.categories.push(categoryValue); prev[newLength - 1] = [curr]; - prev[newLength - 1].category = curr[category]; + prev[newLength - 1].category = categoryValue; } } else { prev[0].push(curr); @@ -916,13 +945,15 @@ export class SamAutocompleteMultiselectComponent * Checks if array is empty. If so, returns an array with no key * and value 'No results found'. */ - public handleEmptyList(object: any): any[] { + public handleEmptyList( + object: CategorizedList + ): CategorizedList { if ( object.categories.length === 1 && object[0].length === 0 && !this.showResultsFreeText() ) { - const noResultsObject = { + const noResultsObject: MultiselectItem = { cannotBeSelected: true, }; noResultsObject[this.keyValueConfig.keyProperty] = null; @@ -942,13 +973,14 @@ export class SamAutocompleteMultiselectComponent } public displayList(): boolean { - if (this.list && this.list.categories) { - if (this.list.categories.length > 1) { + const list = this.list as CategorizedList; + if (list && list.categories) { + if (list.categories.length > 1) { return true; } else if ( - this.list.categories.length === 1 && - this.list[0] && - this.list[0].length > 0 + list.categories.length === 1 && + list[0] && + list[0].length > 0 ) { return true; } else { @@ -963,7 +995,10 @@ export class SamAutocompleteMultiselectComponent if (this.categoryIsSelectable) { return true; } else { - return this.list[categoryIndex].length > 0; + return ( + (this.list as CategorizedList)[categoryIndex].length > + 0 + ); } } @@ -1104,7 +1139,7 @@ export class SamAutocompleteMultiselectComponent // Converts category list index to getResults() list index for (let i = 0; i < category; i++) { - listIndex += this.list[i].length; + listIndex += (this.list as CategorizedList)[i].length; } if (this.selectedEl) { @@ -1126,7 +1161,7 @@ export class SamAutocompleteMultiselectComponent * Implementation of ControlValueAccessor Methods * ***************************************************************/ - public writeValue(value: any) { + public writeValue(value: Array) { let val = value; if (!val) { val = []; @@ -1135,11 +1170,11 @@ export class SamAutocompleteMultiselectComponent this.updateMarked(); } - public registerOnChange(fn: any) { + public registerOnChange(fn: (val: Array) => void) { this.onChangeCallback = fn; } - public registerOnTouched(fn: any) { + public registerOnTouched(fn: () => void) { this.onTouchedCallback = fn; } @@ -1147,7 +1182,7 @@ export class SamAutocompleteMultiselectComponent this.isDisabled = isDisabled; } - private onChangeCallback: (_: any) => void = () => null; + private onChangeCallback: (_: Array) => void = () => null; private onTouchedCallback: () => void = () => null; } diff --git a/src/ui-kit/form-controls/autocomplete/autocomplete.component.ts b/src/ui-kit/form-controls/autocomplete/autocomplete.component.ts index 839edbf40..768fee724 100644 --- a/src/ui-kit/form-controls/autocomplete/autocomplete.component.ts +++ b/src/ui-kit/form-controls/autocomplete/autocomplete.component.ts @@ -13,6 +13,7 @@ import { OnInit, AfterViewInit, inject, + Provider, } from "@angular/core"; import { NG_VALUE_ACCESSOR, @@ -22,18 +23,26 @@ import { import { Observable, Subject } from "rxjs"; import { AutocompleteConfig } from "../../types"; import { AutocompleteService } from "./autocomplete.service"; -import { SamFormService } from "../../form-service"; +import { SamFormService, SamFormEvent } from "../../form-service"; -import { KeyHelper } from "../../utilities/key-helper/key-helper"; +import { KeyHelper, KeyEventLike } from "../../utilities/key-helper/key-helper"; import { areEqual } from "../../utilities/are-equal/are-equal"; import { AutocompleteCache } from "../autocomplete-multiselect/autocomplete-cache"; -const AUTOCOMPLETE_VALUE_ACCESSOR: any = { +const AUTOCOMPLETE_VALUE_ACCESSOR: Provider = { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => SamAutocompleteComponent), multi: true, }; +/** + * A key/value option object rendered by the key-value results list. + * Property names come from `AutocompleteKeyValueConfig` + * (`keyProperty`/`valueProperty`/`subheadProperty`), so this is indexed by + * string rather than a fixed shape. + */ +export type AutocompleteItem = Record; + /** * Methods we're externally exposing */ @@ -93,7 +102,7 @@ export class SamAutocompleteComponent /** * Define autocomplete options */ - @Input() public options: Array; + @Input() public options: Array; /** * Allows for a configuration object */ @@ -111,7 +120,7 @@ export class SamAutocompleteComponent /** * Array of categories. Applies category class if labels match values. */ - @Input() public categories: any = []; + @Input() public categories: AutocompleteItem[] = []; /** * Sets the form control */ @@ -130,15 +139,16 @@ export class SamAutocompleteComponent * the user clicks enter and the mode is allowAny. This is useful if you do * not want to respond to onChange events when the input is blurred. */ - @Output() public enterEvent: EventEmitter = new EventEmitter(); + @Output() public enterEvent: EventEmitter = + new EventEmitter(); /** * Emitted only when add on icon is clicked */ - @Output() public addOnIconEvent: EventEmitter = new EventEmitter(); + @Output() public addOnIconEvent: EventEmitter = new EventEmitter(); /** * Allow to insert a customized template for suggestions to use */ - @Input() itemTemplate: TemplateRef; + @Input() itemTemplate: TemplateRef; /** * Set timer that keyboard input should poll to trigger service calls */ @@ -201,7 +211,7 @@ export class SamAutocompleteComponent /** * Passes in a observable for handling when keyEvents subject triggers updates */ - @Input() public httpRequest: Observable; + @Input() public httpRequest: Observable>; // Defines how many items to initially display as well as // increment amount of new items as user scrolls through suggesstions @@ -211,9 +221,9 @@ export class SamAutocompleteComponent public results: Array = []; public maxNumResultsToDisplay = this.STARTING_MAX_ITEMS; - public innerValue: any = ""; - public inputValue: any = ""; - public selectedInputValue: any; + public innerValue: string | AutocompleteItem = ""; + public inputValue: string = ""; + public selectedInputValue: string; public selectedChild: HTMLElement; public hasFocus: boolean = false; public hasServiceError: boolean = false; @@ -221,22 +231,22 @@ export class SamAutocompleteComponent public endOfList: boolean = true; public lastSearchedValue: string; - public lastReturnedResults: Array; + public lastReturnedResults: Array; - public keyValuePairs: any; - public filteredKeyValuePairs: any[] = []; - public inputTimer; - public cache: AutocompleteCache = new AutocompleteCache(); + public filteredKeyValuePairs: AutocompleteItem[] = []; + public inputTimer: number; + public cache: AutocompleteCache = + new AutocompleteCache(); public resultsAvailable: string = " results available. Use up and down arrows\ to scroll through results. Hit enter to select."; - public get value(): any { + public get value(): string | AutocompleteItem { return this.innerValue; } - public set value(val: any) { + public set value(val: string | AutocompleteItem) { if (val !== this.innerValue) { this.innerValue = val; this.propogateChange(val); @@ -261,12 +271,13 @@ export class SamAutocompleteComponent return arrayToCheck.slice(0, sliceIndex); } - public keyEvents: Subject = new Subject(); + public keyEvents: Subject = new Subject(); public onTouchedCallback: () => void = () => null; - public propogateChange: (_val: any) => void = () => null; + public propogateChange: (_val: string | AutocompleteItem) => void = () => + null; - ngOnChanges(changes) { + ngOnChanges(changes: { httpRequest?: unknown }) { if (changes.httpRequest) { this.httpRequest.subscribe( (res) => this.requestSuccess(res), @@ -275,17 +286,17 @@ export class SamAutocompleteComponent } } - requestSuccess(data) { + requestSuccess(data: Array) { this.hasServiceError = false; if (this.isKeyValuePair(data)) { if (this.filteredKeyValuePairs) { if (!areEqual(data, this.lastReturnedResults)) { - data.forEach((item) => { + (data as AutocompleteItem[]).forEach((item) => { this.filteredKeyValuePairs.push(item); }); } } else { - this.filteredKeyValuePairs = data; + this.filteredKeyValuePairs = data as AutocompleteItem[]; } const len = !!this.filteredKeyValuePairs ? this.filteredKeyValuePairs.length @@ -295,12 +306,12 @@ export class SamAutocompleteComponent } else { if (this.results) { if (!areEqual(data, this.lastReturnedResults)) { - data.forEach((item) => { + (data as string[]).forEach((item) => { this.results.push(item); }); } } else { - this.results = data; + this.results = data as string[]; } const len = !!this.results ? this.results.length : 0; this.pushSROnlyMessage(len + this.resultsAvailable); @@ -312,7 +323,7 @@ export class SamAutocompleteComponent requestError(err?: unknown) { void err; this.results = ["An error occurred. Try a different value."]; - const errorobj = {}; + const errorobj: AutocompleteItem = {}; errorobj[this.config.keyValueConfig.keyProperty] = "Error"; errorobj[this.config.keyValueConfig.valueProperty] = "An error occurred. Try a different value."; @@ -332,7 +343,7 @@ export class SamAutocompleteComponent }); }); } else { - this.samFormService.formEventsUpdated$.subscribe((evt: any) => { + this.samFormService.formEventsUpdated$.subscribe((evt: SamFormEvent) => { if ( (!evt.root || evt.root === this.control.root) && evt.eventType && @@ -372,7 +383,7 @@ export class SamAutocompleteComponent } } - isKeyValuePair(arr: Array): boolean { + isKeyValuePair(arr: Array): boolean { if (arr && arr[0] && typeof arr[0] !== "string") { return true; } else { @@ -414,7 +425,7 @@ export class SamAutocompleteComponent } } - onKeydown(event: any) { + onKeydown(event: KeyEventLike) { if (KeyHelper.is("tab", event)) { return; } @@ -474,19 +485,19 @@ export class SamAutocompleteComponent if (this.isKeyValuePair(this.options)) { this.filteredKeyValuePairs = this.filterKeyValuePairs( searchString, - this.options + this.options as AutocompleteItem[] ); this.pushSROnlyMessage( this.filteredKeyValuePairs.length + this.resultsAvailable ); } else { - this.results = this.filterResults(searchString, this.options); + this.results = this.filterResults(searchString, this.options as string[]); this.pushSROnlyMessage(this.results.length + this.resultsAvailable); } } onKeyUpUsingService(searchString: string) { - let options = null; + let options: unknown = null; if (this.config) { options = this.config.serviceOptions || null; } @@ -496,17 +507,19 @@ export class SamAutocompleteComponent this.autocompleteService .fetch(searchString, this.endOfList, options) .subscribe( - (res) => { + (res: (string | AutocompleteItem)[]) => { let len; this.hasServiceError = false; this.cache.insert(res, searchString); if (this.config && this.config.keyValueConfig) { - this.filteredKeyValuePairs = this.cache.get(searchString); + this.filteredKeyValuePairs = this.cache.get( + searchString + ) as AutocompleteItem[]; len = !!this.filteredKeyValuePairs ? this.filteredKeyValuePairs.length : 0; } else { - this.results = this.cache.get(searchString); + this.results = this.cache.get(searchString) as string[]; len = !!this.results ? this.results.length : 0; } this.pushSROnlyMessage(len + this.resultsAvailable); @@ -630,10 +643,9 @@ export class SamAutocompleteComponent if (isFirstItemFreeText) { index--; } - message = - this.filteredKeyValuePairs[index][ - this.config.keyValueConfig.valueProperty - ]; + message = this.filteredKeyValuePairs[index][ + this.config.keyValueConfig.valueProperty + ] as string; } return message; @@ -766,7 +778,7 @@ export class SamAutocompleteComponent } } - getSelectedChildIndex(children: any): number { + getSelectedChildIndex(children: HTMLCollection): number { let selectedChild: number = -1; for (let child = 0; child < children.length; child++) { if (children[child].classList.contains("isSelected")) { @@ -802,7 +814,7 @@ export class SamAutocompleteComponent this.srOnly.nativeElement.innerHTML = null; } - setSelected(value: any) { + setSelected(value: string | AutocompleteItem) { if ( this.config && this.config.categoryProperty && @@ -811,16 +823,17 @@ export class SamAutocompleteComponent ) { return; } - let displayValue = value ? value : ""; + let displayValue: string | unknown = value ? value : ""; if ( this.config && this.config.keyValueConfig && value && + typeof value !== "string" && value[this.config.keyValueConfig.valueProperty] ) { displayValue = value[this.config.keyValueConfig.valueProperty]; } - const message = displayValue; + const message = displayValue as string; this.innerValue = value ? value : ""; this.hasFocus = false; this.inputValue = message; @@ -845,52 +858,55 @@ export class SamAutocompleteComponent return reducedArr; } - filterKeyValuePairs(subStr: string, keyValuePairs: any): any { + filterKeyValuePairs( + subStr: string, + keyValuePairs: AutocompleteItem[] + ): AutocompleteItem[] { const lowerSubStr = subStr.toLowerCase(); let currentCategory = ""; - const reducedArr = keyValuePairs.reduce((prev, curr) => { - if ( - curr[this.config.keyValueConfig.keyProperty] - .toLowerCase() - .includes(lowerSubStr) || - curr[this.config.keyValueConfig.valueProperty] - .toLowerCase() - .includes(lowerSubStr) - ) { - /** - * Check if the current item in the array contains the substring value - * in either the key or value property provided on the config input - */ + const reducedArr = keyValuePairs.reduce( + (prev: AutocompleteItem[], curr) => { + const keyValue = curr[this.config.keyValueConfig.keyProperty] as string; + const valueValue = curr[ + this.config.keyValueConfig.valueProperty + ] as string; if ( - curr[this.config.categoryProperty] && - currentCategory !== curr[this.config.categoryProperty] + keyValue.toLowerCase().includes(lowerSubStr) || + valueValue.toLowerCase().includes(lowerSubStr) ) { /** - * Checks if the current item in the array has a category. If so, - * checks to see if this category is the current category. If not, it - * will push it to the returned array. If it is the current category, - * it skips. + * Check if the current item in the array contains the substring value + * in either the key or value property provided on the config input */ - currentCategory = curr[this.config.categoryProperty]; - const filteredCategories = this.categories.filter((category) => { + const curCategory = curr[this.config.categoryProperty] as string; + if (curCategory && currentCategory !== curCategory) { /** - * Filters the category input array property for a matching - * category property. + * Checks if the current item in the array has a category. If so, + * checks to see if this category is the current category. If not, it + * will push it to the returned array. If it is the current category, + * it skips. */ - if ( - category[this.config.keyValueConfig.keyProperty] === - curr[this.config.categoryProperty] - ) { - category.isCategory = true; - return category; - } - }); - prev.push(filteredCategories[0]); + currentCategory = curCategory; + const filteredCategories = this.categories.filter((category) => { + /** + * Filters the category input array property for a matching + * category property. + */ + if ( + category[this.config.keyValueConfig.keyProperty] === curCategory + ) { + category.isCategory = true; + return category; + } + }); + prev.push(filteredCategories[0]); + } + prev.push(curr); } - prev.push(curr); - } - return prev; - }, []); + return prev; + }, + [] + ); return reducedArr; } @@ -921,12 +937,8 @@ export class SamAutocompleteComponent this.clearDropdown(); } - isCategory(object: any): boolean { - if (this.categories.indexOf(object) !== -1) { - return true; - } else { - return false; - } + isCategory(object: string | AutocompleteItem): boolean { + return this.categories.indexOf(object as AutocompleteItem) !== -1; } dropdownClick(obj) { @@ -937,12 +949,14 @@ export class SamAutocompleteComponent this.hasFocus = false; } - writeValue(value: any): void { + writeValue(value: string | AutocompleteItem): void { if (value !== this.innerValue) { this.inputValue = value && this.config && this.config.keyValueConfig - ? (this.inputValue = value[this.config.keyValueConfig.valueProperty]) - : (this.inputValue = value); + ? (this.inputValue = value[ + this.config.keyValueConfig.valueProperty + ] as string) + : (this.inputValue = value as string); this.selectedInputValue = this.inputValue; this.innerValue = value; // angular isn't populating this @@ -954,11 +968,11 @@ export class SamAutocompleteComponent } } - registerOnChange(fn: any): void { + registerOnChange(fn: (val: string | AutocompleteItem) => void): void { this.propogateChange = fn; } - registerOnTouched(fn: any): void { + registerOnTouched(fn: () => void): void { this.onTouchedCallback = fn; } diff --git a/src/ui-kit/form-controls/autocomplete/autocomplete.service.ts b/src/ui-kit/form-controls/autocomplete/autocomplete.service.ts index e1d520d2e..77fed679f 100755 --- a/src/ui-kit/form-controls/autocomplete/autocomplete.service.ts +++ b/src/ui-kit/form-controls/autocomplete/autocomplete.service.ts @@ -4,7 +4,7 @@ import { map } from "rxjs/operators"; @Injectable() export class AutocompleteService { - setFetchMethod(fn?: (...args: unknown[]) => unknown): any { + setFetchMethod(fn?: (...args: unknown[]) => unknown): void { void fn; return; } @@ -13,7 +13,7 @@ export class AutocompleteService { val?: string, pageEnd?: boolean, serviceOptions?: unknown - ): Observable { + ): Observable { void val; void pageEnd; void serviceOptions; diff --git a/src/ui-kit/form-controls/sam-sds-autocomplete/autocomplete-search/autocomplete-seach-test-service.spec.ts b/src/ui-kit/form-controls/sam-sds-autocomplete/autocomplete-search/autocomplete-seach-test-service.spec.ts index bfd353afe..95daac6e2 100755 --- a/src/ui-kit/form-controls/sam-sds-autocomplete/autocomplete-search/autocomplete-seach-test-service.spec.ts +++ b/src/ui-kit/form-controls/sam-sds-autocomplete/autocomplete-search/autocomplete-seach-test-service.spec.ts @@ -6,8 +6,17 @@ import { } from "./models/SAMSDSAutocompleteServiceInterface"; import { map } from "rxjs/operators"; +export interface HierarchicalDataItem { + id: string; + parentId: string | null; + name: string; + subtext: string; + type: string; + childCount?: number; +} + export class AutoCompleteSampleDataService implements SAMSDSAutocompleteServiceInterface { - private loadedData; + private loadedData: HierarchicalDataItem[]; constructor() { const data = SampleAutoCompleteData; for (let i = 0; i < data.length; i++) { @@ -24,7 +33,7 @@ export class AutoCompleteSampleDataService implements SAMSDSAutocompleteServiceI ): Observable { const itemIncrease = 25; const data = of(this.loadedData); - let itemsOb: Observable; + let itemsOb: Observable; if (searchValue) { itemsOb = data.pipe( map((items) => @@ -38,7 +47,8 @@ export class AutoCompleteSampleDataService implements SAMSDSAutocompleteServiceI } else { itemsOb = data; } - const items: object[] = this.itemsListOutofObservable(itemsOb); + const items: HierarchicalDataItem[] = + this.itemsListOutofObservable(itemsOb); const totalItemCount = items.length; const maxSectionPosition = this.getMaxSectionPosition( @@ -55,8 +65,10 @@ export class AutoCompleteSampleDataService implements SAMSDSAutocompleteServiceI return of(returnItem); } - private itemsListOutofObservable(itemsOb: any) { - let items: object[]; + private itemsListOutofObservable( + itemsOb: Observable + ): HierarchicalDataItem[] { + let items: HierarchicalDataItem[]; itemsOb.subscribe((result) => { items = result; }); diff --git a/src/ui-kit/form-controls/sam-sds-autocomplete/autocomplete-search/autocomplete-search.component.spec.ts b/src/ui-kit/form-controls/sam-sds-autocomplete/autocomplete-search/autocomplete-search.component.spec.ts index 9f86e62ee..bfdc77585 100755 --- a/src/ui-kit/form-controls/sam-sds-autocomplete/autocomplete-search/autocomplete-search.component.spec.ts +++ b/src/ui-kit/form-controls/sam-sds-autocomplete/autocomplete-search/autocomplete-search.component.spec.ts @@ -598,7 +598,7 @@ describe("SamAutocompleteComponent", () => { it("should clear the model and propagate the change on updateSingleModeFocusOutModel in single mode", () => { component.model.items = [{ id: "1", name: "Level 1" }]; - let propagated: any; + let propagated: SAMSDSSelectedItemModel; component.registerOnChange((val) => (propagated = val)); component.updateSingleModeFocusOutModel(); expect(component.model.items.length).toBe(0); @@ -1221,7 +1221,7 @@ describe("SamAutocompleteComponent", () => { component.inputFocusHandler(); tick(); fixture.detectChanges(); - const previous: any = { name: "prev" }; + const previous: { name: string; highlighted?: boolean } = { name: "prev" }; component["highlightedItem"] = previous; component["setHighlightedItem"](undefined); expect(previous.highlighted).toBe(false); @@ -1231,7 +1231,10 @@ describe("SamAutocompleteComponent", () => { component.inputFocusHandler(); tick(); fixture.detectChanges(); - const item: any = { name: "Level X", subtext: "Extra info" }; + const item: { name: string; subtext: string } = { + name: "Level X", + subtext: "Extra info", + }; component["setHighlightedItem"](item); expect(component.srOnlyText).toContain("Extra info"); })); @@ -1240,7 +1243,7 @@ describe("SamAutocompleteComponent", () => { component.inputFocusHandler(); tick(); fixture.detectChanges(); - const item: any = { name: "Level X" }; + const item: { name: string } = { name: "Level X" }; component["setHighlightedItem"](item); expect(component.srOnlyText).toBe("Level X"); })); diff --git a/src/ui-kit/form-controls/sam-sds-autocomplete/autocomplete-search/autocomplete-search.component.ts b/src/ui-kit/form-controls/sam-sds-autocomplete/autocomplete-search/autocomplete-search.component.ts index ba56ff778..ea0bb14d0 100755 --- a/src/ui-kit/form-controls/sam-sds-autocomplete/autocomplete-search/autocomplete-search.component.ts +++ b/src/ui-kit/form-controls/sam-sds-autocomplete/autocomplete-search/autocomplete-search.component.ts @@ -7,6 +7,7 @@ import { forwardRef, ChangeDetectorRef, inject, + Provider, } from "@angular/core"; import { NG_VALUE_ACCESSOR, ControlValueAccessor } from "@angular/forms"; import { SAMSDSAutocompleteServiceInterface } from "./models/SAMSDSAutocompleteServiceInterface"; @@ -19,7 +20,7 @@ import { import { SAMSDSAutocompleteSearchConfiguration } from "./models/SAMSDSAutocompleteConfiguration"; import { faSquare, faCircle, faTimes } from "@fortawesome/free-solid-svg-icons"; -const Autocomplete_Autocomplete_VALUE_ACCESSOR: any = { +const Autocomplete_Autocomplete_VALUE_ACCESSOR: Provider = { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => SAMSDSAutocompleteSearchComponent), multi: true, @@ -52,7 +53,7 @@ export class SAMSDSAutocompleteSearchComponent implements ControlValueAccessor { /** * Allow to insert a customized template for suggestions to use */ - @Input() itemTemplate: TemplateRef; + @Input() itemTemplate: TemplateRef; /** * The data model that has the selected item @@ -145,7 +146,7 @@ export class SAMSDSAutocompleteSearchComponent implements ControlValueAccessor { /** * Stored Event for ControlValueAccessor */ - public propogateChange: (_val: any) => void = () => null; + public propogateChange: (_val: unknown) => void = () => null; @Input() public disabled: boolean; @@ -450,7 +451,7 @@ export class SAMSDSAutocompleteSearchComponent implements ControlValueAccessor { public getFlatElements() { const results = this.results; const flat = []; - const flatten = (array: any) => { + const flatten = (array: object[]) => { for (const i in array) { const item = array[i]; flat.push(item); @@ -551,7 +552,7 @@ export class SAMSDSAutocompleteSearchComponent implements ControlValueAccessor { * return Item is already selected or not * @param result */ - checkItemSelected(result: any) { + checkItemSelected(result: object) { const selectedItem = this.model.items.filter( (item) => item[this.configuration.primaryKeyField] === @@ -681,7 +682,7 @@ export class SAMSDSAutocompleteSearchComponent implements ControlValueAccessor { this.srOnlyText = message; } - writeValue(obj: any): void { + writeValue(obj: unknown): void { if (obj instanceof SAMSDSSelectedItemModel) { this.model = obj as SAMSDSSelectedItemModel; this._changeDetectorRef.markForCheck(); @@ -704,11 +705,11 @@ export class SAMSDSAutocompleteSearchComponent implements ControlValueAccessor { : ""; } - registerOnChange(fn: any): void { + registerOnChange(fn: (_val: unknown) => void): void { this.propogateChange = fn; } - registerOnTouched(fn: any): void { + registerOnTouched(fn: () => void): void { this.onTouchedCallback = fn; } diff --git a/src/ui-kit/form-controls/sam-sds-autocomplete/autocomplete/autocomplete.component.ts b/src/ui-kit/form-controls/sam-sds-autocomplete/autocomplete/autocomplete.component.ts index 9c9213f99..7c5bc78b1 100755 --- a/src/ui-kit/form-controls/sam-sds-autocomplete/autocomplete/autocomplete.component.ts +++ b/src/ui-kit/form-controls/sam-sds-autocomplete/autocomplete/autocomplete.component.ts @@ -5,6 +5,7 @@ import { TemplateRef, forwardRef, inject, + Provider, } from "@angular/core"; import { NG_VALUE_ACCESSOR, ControlValueAccessor } from "@angular/forms"; import { SAMSDSSelectedItemModel } from "../selected-result/models/sds-selectedItem.model"; @@ -19,7 +20,7 @@ import { } from "@angular/core"; import { SAMSDSAutocompleteSearchComponent } from "../autocomplete-search/autocomplete-search.component"; -const Autocomplete_VALUE_ACCESSOR: any = { +const Autocomplete_VALUE_ACCESSOR: Provider = { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => SAMSDSAutocompleteComponent), multi: true, @@ -39,12 +40,12 @@ export class SAMSDSAutocompleteComponent implements ControlValueAccessor { /** * Allow to insert a customized template for suggestions results */ - @Input() suggestionTemplate: TemplateRef; + @Input() suggestionTemplate: TemplateRef; /** * Allow to insert a customized template for selected items */ - @Input() selectedItemTemplate: TemplateRef; + @Input() selectedItemTemplate: TemplateRef; /** * The data model that has the selected item @@ -90,7 +91,7 @@ export class SAMSDSAutocompleteComponent implements ControlValueAccessor { // ControlValueAccessor (and Formly) is trying to update the value of the FormControl (our custom component) programatically // If there is a value we will just overwrite items // If there is no value we reset the items array to be empty - writeValue(value: any) { + writeValue(value: unknown) { if ( value instanceof SAMSDSSelectedItemModel && value.items && @@ -99,11 +100,15 @@ export class SAMSDSAutocompleteComponent implements ControlValueAccessor { ) { this.model.items = [...value.items]; this.cd.markForCheck(); - } else if (value && value.length && this.model.items !== value) { + } else if ( + Array.isArray(value) && + value.length && + this.model.items !== value + ) { this.model.items = value; this.cd.markForCheck(); } else { - const items = value && value.items ? value.items : []; + const items = value instanceof SAMSDSSelectedItemModel ? value.items : []; this.model = new SAMSDSSelectedItemModel(items); this.cd.markForCheck(); } @@ -127,12 +132,12 @@ export class SAMSDSAutocompleteComponent implements ControlValueAccessor { } // ControlValueAccessor hook that lets us call this._onChange(var) to let the form know our variable has changed (in this case model) - registerOnChange(fn: any): void { + registerOnChange(fn: (val: unknown) => void): void { this.onChange = fn; } // ControlValueAccessor hook (not used) - registerOnTouched(fn: any) { + registerOnTouched(fn: () => void) { this.onTouched = fn; } diff --git a/src/ui-kit/form-controls/sam-sds-autocomplete/selected-result/models/sds-selected-item-model-helper.ts b/src/ui-kit/form-controls/sam-sds-autocomplete/selected-result/models/sds-selected-item-model-helper.ts index 19dd4fdc0..c988d510f 100755 --- a/src/ui-kit/form-controls/sam-sds-autocomplete/selected-result/models/sds-selected-item-model-helper.ts +++ b/src/ui-kit/form-controls/sam-sds-autocomplete/selected-result/models/sds-selected-item-model-helper.ts @@ -98,7 +98,7 @@ export class SAMSDSSelectedItemModelHelper { * Clears the list of items * @param items */ - public static clearItems(model: any) { + public static clearItems(model: SAMSDSSelectedItemModel) { while (model.items.length > 0) { model.items.pop(); } diff --git a/src/ui-kit/form-controls/sam-sds-autocomplete/selected-result/selected-result.component.ts b/src/ui-kit/form-controls/sam-sds-autocomplete/selected-result/selected-result.component.ts index 0d133b57f..11af0cff7 100755 --- a/src/ui-kit/form-controls/sam-sds-autocomplete/selected-result/selected-result.component.ts +++ b/src/ui-kit/form-controls/sam-sds-autocomplete/selected-result/selected-result.component.ts @@ -1,11 +1,17 @@ -import { Component, Input, TemplateRef, forwardRef } from "@angular/core"; +import { + Component, + Input, + TemplateRef, + forwardRef, + Provider, +} from "@angular/core"; import { SAMSDSSelectedItemModel } from "./models/sds-selectedItem.model"; import { SDSSelectedResultConfiguration } from "./models/SDSSelectedResultConfiguration"; import { NG_VALUE_ACCESSOR, ControlValueAccessor } from "@angular/forms"; import { SAMSDSSelectedItemModelHelper } from "./models/sds-selected-item-model-helper"; import { faSquare, faCircle, faTimes } from "@fortawesome/free-solid-svg-icons"; -const SDS_SelectedResult_VALUE_ACCESSOR: any = { +const SDS_SelectedResult_VALUE_ACCESSOR: Provider = { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => SAMSDSSelectedResultComponent), multi: true, @@ -27,7 +33,7 @@ export class SAMSDSSelectedResultComponent implements ControlValueAccessor { /** * Allow to insert a customized template for suggestions to use */ - @Input() itemTemplate: TemplateRef; + @Input() itemTemplate: TemplateRef; /** * The data model that has the selected item @@ -48,7 +54,7 @@ export class SAMSDSSelectedResultComponent implements ControlValueAccessor { /** * Stored Event for ControlValueAccessor */ - public propogateChange: (_val: any) => void = () => null; + public propogateChange: (_val: unknown) => void = () => null; @Input() public disabled: boolean; @@ -69,17 +75,17 @@ export class SAMSDSSelectedResultComponent implements ControlValueAccessor { } } - writeValue(obj: any): void { + writeValue(obj: unknown): void { if (obj instanceof SAMSDSSelectedItemModel) { this.model = obj as SAMSDSSelectedItemModel; } } - registerOnChange(fn: any): void { + registerOnChange(fn: (_val: unknown) => void): void { this.propogateChange = fn; } - registerOnTouched(fn: any): void { + registerOnTouched(fn: () => void): void { this.onTouchedCallback = fn; } From 31cb9d3eb32e1493f803c7e909c4748ea8eefe09 Mon Sep 17 00:00:00 2001 From: "Frank Pigeon Jr." <4629398+fpigeonjr@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:20:57 -0500 Subject: [PATCH 2/4] Address Copilot review feedback on PR #755 - MultiselectItem/AutocompleteItem: use `object` instead of `Record` so consumers' named option interfaces (which lack a string index signature) remain assignable, matching the old `any[]` API's compatibility. - CategorizedList.totalItems(): make optional so structural inputs (e.g. `{ 0: [], categories: [...] }`) built by callers/tests without the bookkeeping method still type-check. - SamAutocompleteComponent.categories: keep `Array` instead of narrowing to `AutocompleteItem[]`, so plain string category labels are still accepted. - AutocompleteService: make generic (`AutocompleteService`) so `fetch()` returns `Observable`; the standalone autocomplete component parameterizes it with `string | AutocompleteItem`, and existing untyped consumers keep compiling via the `unknown` default. - Updated registerOnChange/registerOnTouched spec stubs in autocomplete-search, sam-sds-autocomplete, and selected-result specs to pass no-op functions instead of `{}`, matching the CVA callback signatures. --- .../autocomplete-multiselect.component.ts | 15 ++++++++++----- .../autocomplete/autocomplete.component.ts | 17 ++++++++++++----- .../autocomplete/autocomplete.service.ts | 11 +++++++++-- .../autocomplete-search.component.spec.ts | 8 +++++--- .../autocomplete/autocomplete.component.spec.ts | 4 ++-- .../selected-result.component.spec.ts | 4 ++-- 6 files changed, 40 insertions(+), 19 deletions(-) diff --git a/src/ui-kit/form-controls/autocomplete-multiselect/autocomplete-multiselect.component.ts b/src/ui-kit/form-controls/autocomplete-multiselect/autocomplete-multiselect.component.ts index 148478646..60183a725 100755 --- a/src/ui-kit/form-controls/autocomplete-multiselect/autocomplete-multiselect.component.ts +++ b/src/ui-kit/form-controls/autocomplete-multiselect/autocomplete-multiselect.component.ts @@ -35,20 +35,25 @@ import { SamCache } from "../autocomplete/autocomplete.component"; * A key/value option object rendered by the multiselect results list. * Property names come from `KeyValueConfig` * (`keyProperty`/`valueProperty`/`subheadProperty`/`categoryProperty`), so - * this is indexed by string rather than a fixed shape. + * consumers may pass any object shape (including named interfaces without a + * string index signature); configured keys are read dynamically at runtime. */ -export type MultiselectItem = Record; +export type MultiselectItem = object; /** * Associative array-like structure `sortByCategory`/`filterOptions` build to * group `MultiselectItem`s by category for the template's category/sublist * rendering. `[index: number]` holds each category's items (with a - * `category` label stamped onto the array itself); `categories` and - * `totalItems()` are the bookkeeping the template and component logic read. + * `category` label stamped onto the array itself); `categories` is the + * bookkeeping the template and component logic read. `totalItems()` is an + * optional convenience method `sortByCategory` attaches to the structures it + * builds internally -- it is not required of callers who construct or pass + * in a `CategorizedList`-shaped value directly (see + * autocomplete-multiselect.spec.ts). */ export interface CategorizedList { categories: string[]; - totalItems(): number; + totalItems?(): number; [index: number]: T[] & { category?: string }; } diff --git a/src/ui-kit/form-controls/autocomplete/autocomplete.component.ts b/src/ui-kit/form-controls/autocomplete/autocomplete.component.ts index 768fee724..88a09e68a 100644 --- a/src/ui-kit/form-controls/autocomplete/autocomplete.component.ts +++ b/src/ui-kit/form-controls/autocomplete/autocomplete.component.ts @@ -38,10 +38,11 @@ const AUTOCOMPLETE_VALUE_ACCESSOR: Provider = { /** * A key/value option object rendered by the key-value results list. * Property names come from `AutocompleteKeyValueConfig` - * (`keyProperty`/`valueProperty`/`subheadProperty`), so this is indexed by - * string rather than a fixed shape. + * (`keyProperty`/`valueProperty`/`subheadProperty`), so consumers may pass + * any object shape (including named interfaces without a string index + * signature); configured keys are read dynamically at runtime. */ -export type AutocompleteItem = Record; +export type AutocompleteItem = object; /** * Methods we're externally exposing @@ -65,7 +66,10 @@ export class SamAutocompleteComponent OnInit, AfterViewInit { - autocompleteService = inject(AutocompleteService, { optional: true }); + autocompleteService: AutocompleteService = inject( + AutocompleteService, + { optional: true } + ); private samFormService = inject(SamFormService); private cdr = inject(ChangeDetectorRef); @@ -119,8 +123,11 @@ export class SamAutocompleteComponent @Input() useFormService: boolean; /** * Array of categories. Applies category class if labels match values. + * + * Accepts either plain category labels (strings) or key/value objects -- + * `isCategory()` checks incoming values against this array as-is. */ - @Input() public categories: AutocompleteItem[] = []; + @Input() public categories: Array = []; /** * Sets the form control */ diff --git a/src/ui-kit/form-controls/autocomplete/autocomplete.service.ts b/src/ui-kit/form-controls/autocomplete/autocomplete.service.ts index 77fed679f..0e3f00afe 100755 --- a/src/ui-kit/form-controls/autocomplete/autocomplete.service.ts +++ b/src/ui-kit/form-controls/autocomplete/autocomplete.service.ts @@ -2,8 +2,15 @@ import { Injectable } from "@angular/core"; import { Observable, of } from "rxjs"; import { map } from "rxjs/operators"; +/** + * `T` is the shape of a single autocomplete result item. Consumers of this + * public service (and the component's own `autocompleteService` input) may + * subclass/provide it with a concrete result type; `fetch()` then returns + * `Observable` instead of forcing every caller to narrow an `unknown[]`. + * Defaults to `unknown` so untyped existing consumers keep compiling. + */ @Injectable() -export class AutocompleteService { +export class AutocompleteService { setFetchMethod(fn?: (...args: unknown[]) => unknown): void { void fn; return; @@ -13,7 +20,7 @@ export class AutocompleteService { val?: string, pageEnd?: boolean, serviceOptions?: unknown - ): Observable { + ): Observable { void val; void pageEnd; void serviceOptions; diff --git a/src/ui-kit/form-controls/sam-sds-autocomplete/autocomplete-search/autocomplete-search.component.spec.ts b/src/ui-kit/form-controls/sam-sds-autocomplete/autocomplete-search/autocomplete-search.component.spec.ts index bfdc77585..41d4756b6 100755 --- a/src/ui-kit/form-controls/sam-sds-autocomplete/autocomplete-search/autocomplete-search.component.spec.ts +++ b/src/ui-kit/form-controls/sam-sds-autocomplete/autocomplete-search/autocomplete-search.component.spec.ts @@ -468,13 +468,13 @@ describe("SamAutocompleteComponent", () => { }); it("should handle registerOnChange", () => { - const item = {}; + const item = () => {}; component.registerOnChange(item); expect(component.propogateChange).toBe(item); }); it("should handle registerOnTouched", () => { - const item = {}; + const item = () => {}; component.registerOnTouched(item); expect(component.onTouchedCallback).toBe(item); }); @@ -599,7 +599,9 @@ describe("SamAutocompleteComponent", () => { it("should clear the model and propagate the change on updateSingleModeFocusOutModel in single mode", () => { component.model.items = [{ id: "1", name: "Level 1" }]; let propagated: SAMSDSSelectedItemModel; - component.registerOnChange((val) => (propagated = val)); + component.registerOnChange( + (val) => (propagated = val as SAMSDSSelectedItemModel) + ); component.updateSingleModeFocusOutModel(); expect(component.model.items.length).toBe(0); expect(propagated).toBe(component.model); diff --git a/src/ui-kit/form-controls/sam-sds-autocomplete/autocomplete/autocomplete.component.spec.ts b/src/ui-kit/form-controls/sam-sds-autocomplete/autocomplete/autocomplete.component.spec.ts index b4c6875a4..1055387fe 100755 --- a/src/ui-kit/form-controls/sam-sds-autocomplete/autocomplete/autocomplete.component.spec.ts +++ b/src/ui-kit/form-controls/sam-sds-autocomplete/autocomplete/autocomplete.component.spec.ts @@ -90,13 +90,13 @@ describe("SAMSDSAutocompleteComponent", () => { }); it("should handle registerOnChange", () => { - const item = {}; + const item = () => {}; component.registerOnChange(item); expect(component.onChange).toBe(item); }); it("should handle registerOnTouched", () => { - const item = {}; + const item = () => {}; component.registerOnTouched(item); expect(component.onTouched).toBe(item); }); diff --git a/src/ui-kit/form-controls/sam-sds-autocomplete/selected-result/selected-result.component.spec.ts b/src/ui-kit/form-controls/sam-sds-autocomplete/selected-result/selected-result.component.spec.ts index 8cc424b7b..14c6d9117 100755 --- a/src/ui-kit/form-controls/sam-sds-autocomplete/selected-result/selected-result.component.spec.ts +++ b/src/ui-kit/form-controls/sam-sds-autocomplete/selected-result/selected-result.component.spec.ts @@ -187,13 +187,13 @@ describe("SDSSelectedResultComponent", () => { }); it("should handle registerOnChange", () => { - const item = {}; + const item = () => {}; component.registerOnChange(item); expect(component.propogateChange).toBe(item); }); it("should handle registerOnTouched", () => { - const item = {}; + const item = () => {}; component.registerOnTouched(item); expect(component.onTouchedCallback).toBe(item); }); From ec00ed28adaaa0624c60f40972d5a6f73361bf95 Mon Sep 17 00:00:00 2001 From: "Frank Pigeon Jr." <4629398+fpigeonjr@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:08:24 -0500 Subject: [PATCH 3/4] Fix dynamic property accesses on AutocompleteItem and MultiselectItem --- .../autocomplete-multiselect.component.ts | 7 ++++--- .../form-controls/autocomplete/autocomplete.component.ts | 6 +++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/ui-kit/form-controls/autocomplete-multiselect/autocomplete-multiselect.component.ts b/src/ui-kit/form-controls/autocomplete-multiselect/autocomplete-multiselect.component.ts index 60183a725..ccc19abc3 100755 --- a/src/ui-kit/form-controls/autocomplete-multiselect/autocomplete-multiselect.component.ts +++ b/src/ui-kit/form-controls/autocomplete-multiselect/autocomplete-multiselect.component.ts @@ -420,7 +420,8 @@ export class SamAutocompleteMultiselectComponent const listItem = this.getItem(); if ( - (!listItem || listItem.value === "No results found") && + (!listItem || + (listItem as { value?: unknown }).value === "No results found") && highlighted === -1 ) { returnValue = this.createReturnObject(event); @@ -927,7 +928,7 @@ export class SamAutocompleteMultiselectComponent }, }; - return results.reduce((prev, curr) => { + return results.reduce>((prev, curr) => { const category = this.keyValueConfig.categoryProperty; const categoryValue = curr[category] as string; if (categoryValue) { @@ -1135,7 +1136,7 @@ export class SamAutocompleteMultiselectComponent this.options[key][this.keyValueConfig.keyProperty] ); }); - this.options[key]._marked = !!x ? true : false; + (this.options[key] as { _marked?: boolean })._marked = !!x ? true : false; } } diff --git a/src/ui-kit/form-controls/autocomplete/autocomplete.component.ts b/src/ui-kit/form-controls/autocomplete/autocomplete.component.ts index 88a09e68a..c1cb215ec 100644 --- a/src/ui-kit/form-controls/autocomplete/autocomplete.component.ts +++ b/src/ui-kit/form-controls/autocomplete/autocomplete.component.ts @@ -871,7 +871,7 @@ export class SamAutocompleteComponent ): AutocompleteItem[] { const lowerSubStr = subStr.toLowerCase(); let currentCategory = ""; - const reducedArr = keyValuePairs.reduce( + const reducedArr = keyValuePairs.reduce( (prev: AutocompleteItem[], curr) => { const keyValue = curr[this.config.keyValueConfig.keyProperty] as string; const valueValue = curr[ @@ -902,11 +902,11 @@ export class SamAutocompleteComponent if ( category[this.config.keyValueConfig.keyProperty] === curCategory ) { - category.isCategory = true; + (category as { isCategory?: boolean }).isCategory = true; return category; } }); - prev.push(filteredCategories[0]); + prev.push(filteredCategories[0] as AutocompleteItem); } prev.push(curr); } From c2653a0965104996dac689b57062627ffd9015c0 Mon Sep 17 00:00:00 2001 From: "Frank Pigeon Jr." <4629398+fpigeonjr@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:08:53 -0500 Subject: [PATCH 4/4] Fix spec type errors following autocomplete signature updates Update autocomplete.spec.ts and autocomplete-multiselect.spec.ts to match component type signatures introduced in #728: - Remove unused target property from onKeydown test calls in autocomplete.spec.ts - Type AutocompleteService and Subject test fixtures with AutocompleteItem - Cast options and category filter results to match narrowed parameter types - Cast MultiselectItem dynamic property reads (.value, .type) - Supply empty categories array on test fixture for CategorizedList --- .../autocomplete-multiselect.spec.ts | 14 +-- .../autocomplete/autocomplete.spec.ts | 86 +++++++------------ 2 files changed, 39 insertions(+), 61 deletions(-) diff --git a/src/ui-kit/form-controls/autocomplete-multiselect/autocomplete-multiselect.spec.ts b/src/ui-kit/form-controls/autocomplete-multiselect/autocomplete-multiselect.spec.ts index 2947b37e5..83b31cb35 100755 --- a/src/ui-kit/form-controls/autocomplete-multiselect/autocomplete-multiselect.spec.ts +++ b/src/ui-kit/form-controls/autocomplete-multiselect/autocomplete-multiselect.spec.ts @@ -96,7 +96,7 @@ describe("The Sam Autocomplete Multiselect Component", () => { component.ngOnInit(); const test = component.filterOptions("bbb"); expect(test[0].length === 1).toBe(true); - expect(test[0][0].value).toBe("bbb"); + expect((test[0][0] as { value?: string }).value).toBe("bbb"); }); it("should support categories", () => { @@ -275,7 +275,7 @@ describe("The Sam Autocomplete Multiselect Component", () => { nativeElement: { focus: () => undefined }, } as unknown as ElementRef; component.selectItem("Freeform"); - expect(component.value[0].type).toBe("custom"); + expect((component.value[0] as { type?: string }).type).toBe("custom"); expect(component.value[0][component.keyValueConfig.valueProperty]).toBe( "Freeform" ); @@ -542,7 +542,7 @@ describe("The Sam Autocomplete Multiselect Component", () => { component.isFreeTextEnabled = true; component.searchText = "aaa"; const nested = [{ key: "a", value: "aaa" }]; - component["list"] = { 0: nested }; + component["list"] = { 0: nested, categories: [] }; component.value = []; expect(component.showResultsFreeText()).toBe(false); }); @@ -828,7 +828,9 @@ describe("The Sam Autocomplete Multiselect Component", () => { const results = fixture.nativeElement.querySelectorAll( "li.category-item, li.category-name" ); - expect(results[0].innerText).toContain(component.options[0].value); + expect((results[0] as HTMLElement).innerText).toContain( + (component.options[0] as { value?: string }).value + ); }); }); @@ -878,7 +880,7 @@ describe("The Sam Autocomplete Multiselect Component", () => { it("Should select free text", () => { const text = "TEST ITEM"; component.selectItem(text); - expect(component.value[0].type).toBe("custom"); + expect((component.value[0] as { type?: string }).type).toBe("custom"); expect(component.value[0][component.keyValueConfig.valueProperty]).toBe( text ); @@ -1065,7 +1067,7 @@ describe("The Sam Autocomplete Multiselect Component", () => { preventDefault: () => undefined, }); - expect(component.value[0].type).toBe("custom"); + expect((component.value[0] as { type?: string }).type).toBe("custom"); expect(component.value[0][component.keyValueConfig.valueProperty]).toBe( "Nowhere" ); diff --git a/src/ui-kit/form-controls/autocomplete/autocomplete.spec.ts b/src/ui-kit/form-controls/autocomplete/autocomplete.spec.ts index d56db16a9..e9d57a7cb 100755 --- a/src/ui-kit/form-controls/autocomplete/autocomplete.spec.ts +++ b/src/ui-kit/form-controls/autocomplete/autocomplete.spec.ts @@ -8,7 +8,10 @@ import { ChangeDetectorRef } from "@angular/core"; import { SamFormService } from "../../form-service"; import { SamWrapperModule } from "../../wrappers"; -import { SamAutocompleteComponent } from "./autocomplete.component"; +import { + SamAutocompleteComponent, + AutocompleteItem, +} from "./autocomplete.component"; import { AutocompleteService } from "../autocomplete/autocomplete.service"; import { AutocompleteConfig } from "../../types"; @@ -52,11 +55,7 @@ describe("The Sam Autocomplete Component", () => { nativeElement: { innerHTML: "" }, }; - component.inputFocusHandler({ - target: { - value: "", - }, - }); + component.inputFocusHandler({}); expect(component.hasFocus).toBe(true); }); @@ -568,14 +567,20 @@ describe("The Sam Autocomplete Component", () => { it("Should display only given value in list", () => { component.hasFocus = true; fixture.detectChanges(); - component.results = component.filterResults("Alaska", component.options); + component.results = component.filterResults( + "Alaska", + component.options as string[] + ); expect(component.results).toEqual(["Alaska"]); }); it("Should display no results message", () => { component.hasFocus = true; fixture.detectChanges(); - component.results = component.filterResults("zzzzzz", component.options); + component.results = component.filterResults( + "zzzzzz", + component.options as string[] + ); expect(component.results).toEqual([]); }); @@ -586,8 +591,8 @@ describe("The Sam Autocomplete Component", () => { fixture.detectChanges(); component.results = component.filterKeyValuePairs( "Alaska", - component.options - ); + component.options as AutocompleteItem[] + ) as unknown as string[]; expect((component.results[0] as { name: string }).name).toEqual("AK"); }); @@ -598,8 +603,8 @@ describe("The Sam Autocomplete Component", () => { fixture.detectChanges(); component.results = component.filterKeyValuePairs( "zzzzzz", - component.options - ); + component.options as AutocompleteItem[] + ) as unknown as string[]; expect(component.results).toEqual([]); }); @@ -795,45 +800,29 @@ describe("The Sam Autocomplete Component", () => { it("Should handle keyup", () => { component.hasFocus = true; - component.inputFocusHandler({ - target: { - value: "", - }, - }); + component.inputFocusHandler({}); component.results = ["aaa", "bbb"]; fixture.detectChanges(); //index -1 to 0 component.onKeydown({ key: "Down", code: "Down", - target: { - value: "", - }, }); //index 0 to 1 component.onKeydown({ key: "Down", code: "Down", - target: { - value: "", - }, }); fixture.detectChanges(); //index 1 to 0 component.onKeydown({ key: "Up", code: "Up", - target: { - value: "", - }, }); fixture.detectChanges(); component.onKeydown({ key: "Enter", code: "Enter", - target: { - value: "", - }, }); expect(component.value).toBe("aaa"); fixture.detectChanges(); @@ -843,9 +832,6 @@ describe("The Sam Autocomplete Component", () => { component.onKeydown({ key: "Escape", code: "Escape", - target: { - value: "", - }, }); fixture.detectChanges(); component.allowAny = true; @@ -853,9 +839,6 @@ describe("The Sam Autocomplete Component", () => { component.onKeydown({ key: "Enter", code: "Enter", - target: { - value: "", - }, }); fixture.detectChanges(); expect(component.value).toBe("ccc"); @@ -873,17 +856,16 @@ describe("The Sam Autocomplete Component", () => { fixture.detectChanges(); // Move down through the key/value list - component.onKeydown({ key: "Down", code: "Down", target: { value: "" } }); + component.onKeydown({ key: "Down", code: "Down" }); fixture.detectChanges(); - component.onKeydown({ key: "Down", code: "Down", target: { value: "" } }); + component.onKeydown({ key: "Down", code: "Down" }); fixture.detectChanges(); // Move back up - component.onKeydown({ key: "Up", code: "Up", target: { value: "" } }); + component.onKeydown({ key: "Up", code: "Up" }); fixture.detectChanges(); component.onKeydown({ key: "Enter", code: "Enter", - target: { value: "" }, }); fixture.detectChanges(); @@ -894,14 +876,13 @@ describe("The Sam Autocomplete Component", () => { component.hasFocus = true; component.results = ["aaa", "bbb"]; fixture.detectChanges(); - component.onKeydown({ key: "Down", code: "Down", target: { value: "" } }); + component.onKeydown({ key: "Down", code: "Down" }); fixture.detectChanges(); const escSpy = vi.spyOn(component, "clearDropdown"); component.onKeydown({ key: "Escape", code: "Escape", - target: { value: "" }, }); expect(escSpy).toHaveBeenCalled(); @@ -918,7 +899,6 @@ describe("The Sam Autocomplete Component", () => { component.onKeydown({ key: "Backspace", code: "Backspace", - target: { value: "" }, }); expect(component.results).toBe(null); @@ -933,7 +913,6 @@ describe("The Sam Autocomplete Component", () => { const result = component.onKeydown({ key: "Tab", code: "Tab", - target: { value: "" }, }); expect(result).toBe(undefined); @@ -942,7 +921,7 @@ describe("The Sam Autocomplete Component", () => { it("Should populate results via a service-backed search after debounce", () => { vi.useFakeTimers(); - const service = new AutocompleteService(); + const service = new AutocompleteService(); vi.spyOn(service, "fetch").mockReturnValue(of(["Alabama", "Alaska"])); fixture = TestBed.createComponent(SamAutocompleteComponent); @@ -967,7 +946,7 @@ describe("The Sam Autocomplete Component", () => { it("Should surface a service error via requestError when the service-backed search fails", () => { vi.useFakeTimers(); - const service = new AutocompleteService(); + const service = new AutocompleteService(); vi.spyOn(service, "fetch").mockReturnValue( throwError(() => new Error("boom")) ); @@ -996,7 +975,7 @@ describe("The Sam Autocomplete Component", () => { it("Should populate filteredKeyValuePairs via a service-backed search when a keyValueConfig is set", () => { vi.useFakeTimers(); - const service = new AutocompleteService(); + const service = new AutocompleteService(); vi.spyOn(service, "fetch").mockReturnValue( of([{ name: "AL", value: "Alabama" }]) ); @@ -1024,7 +1003,7 @@ describe("The Sam Autocomplete Component", () => { }); it("Should populate results from an httpRequest observable (plain array)", () => { - const subject = new Subject(); + const subject = new Subject>(); component.options = undefined; component.httpRequest = subject; component.ngOnChanges({ httpRequest: true }); @@ -1035,7 +1014,7 @@ describe("The Sam Autocomplete Component", () => { }); it("Should emit onto keyEvents when driven by an httpRequest with no autocompleteService", () => { - const subject = new Subject(); + const subject = new Subject>(); component.autocompleteService = null; component.options = undefined; component.httpRequest = subject; @@ -1052,7 +1031,7 @@ describe("The Sam Autocomplete Component", () => { }); it("Should populate filteredKeyValuePairs from an httpRequest observable (key/value array)", () => { - const subject = new Subject(); + const subject = new Subject>(); component.options = undefined; component.httpRequest = subject; component.ngOnChanges({ httpRequest: true }); @@ -1065,7 +1044,7 @@ describe("The Sam Autocomplete Component", () => { }); it("Should route httpRequest errors to requestError", () => { - const subject = new Subject(); + const subject = new Subject>(); component.options = undefined; component.httpRequest = subject as unknown as typeof component.httpRequest; @@ -1205,7 +1184,6 @@ describe("The Sam Autocomplete Component", () => { component.onKeydown({ key: "Enter", code: "Enter", - target: { value: "" }, }); expect(component.innerValue).toBe("custom value"); @@ -1252,7 +1230,6 @@ describe("The Sam Autocomplete Component", () => { component.onKeydown({ key: "Down", code: "Down", - target: { value: "" }, }); fixture.detectChanges(); } @@ -1260,7 +1237,7 @@ describe("The Sam Autocomplete Component", () => { // Now walk up past a category header boundary. for (let i = 0; i < 3; i++) { - component.onKeydown({ key: "Up", code: "Up", target: { value: "" } }); + component.onKeydown({ key: "Up", code: "Up" }); fixture.detectChanges(); } @@ -1275,12 +1252,11 @@ describe("The Sam Autocomplete Component", () => { component.results = ["Alabama", "Alaska"]; fixture.detectChanges(); - component.onKeydown({ key: "Down", code: "Down", target: { value: "" } }); + component.onKeydown({ key: "Down", code: "Down" }); fixture.detectChanges(); component.onKeydown({ key: "Enter", code: "Enter", - target: { value: "" }, }); fixture.detectChanges();