From 600619721bd35e4a40499d8c22e3dfa51c374cdc Mon Sep 17 00:00:00 2001 From: Dominic R Date: Wed, 17 Jun 2026 20:59:39 -0400 Subject: [PATCH] web: centralize file picker custom values Agent-thread: https://sdko.org/internal/thr/ak/019ed837-6a7a-7492-bc76-93d55a130a27 A7k-product: product A7k-product-repo: 1 Co-authored-by: Agent --- web/src/components/ak-file-search-input.ts | 104 ++++++++++-------- .../forms/SearchSelect/SearchSelect.ts | 17 +-- .../forms/SearchSelect/ak-search-select-ez.ts | 3 + .../forms/SearchSelect/ak-search-select.ts | 4 + 4 files changed, 74 insertions(+), 54 deletions(-) diff --git a/web/src/components/ak-file-search-input.ts b/web/src/components/ak-file-search-input.ts index 330d6118b885..60b8aa5ce78e 100644 --- a/web/src/components/ak-file-search-input.ts +++ b/web/src/components/ak-file-search-input.ts @@ -25,9 +25,21 @@ interface FileItem { usage: string; } +const inFlightFetches = new Map>(); + const renderElement = (item: FileItem) => item.name; const renderValue = (item?: FileItem | null) => item?.name; +function createCustomFileItem(name: string, usage: UsageEnum): FileItem { + return { + name, + url: name, + mime_type: "", + size: 0, + usage, + }; +} + /** * File Search Input Component * @@ -69,58 +81,57 @@ export class AKFileSearchInput extends AKElement { return this.value === item.name; }; - public override firstUpdated() { - // If we have a value but it's not in the fetched results (like fa:// or custom URL), - // the search-select won't show it. We need to add it to the initial fetch. - if (this.value) { - // Search-select will call #fetch and then try to select using #selected - // And then if the value isn't found in results, creatable mode will handle it + async #fetchFiles(query?: string): Promise { + const cacheKey = `${this.usage}:${query ?? ""}`; + let fetchPromise = inFlightFetches.get(cacheKey); + if (!fetchPromise) { + const api = aki(AdminApi); + fetchPromise = api + .adminFileList({ + usage: this.usage as UsageEnum, + ...(query ? { search: query } : {}), + }) + .then((response) => { + // Cast necessary: API returns File objects but we only use name, url, mime_type, size, and usage properties + const fileResponse = response as unknown as FileItem[]; + + if (!fileResponse || !Array.isArray(fileResponse)) { + console.error("Invalid response format from files API", fileResponse); + return []; + } + + return fileResponse; + }) + .catch(async (error) => { + const parsedError = await parseAPIResponseError(error); + console.error(msg("Failed to fetch files"), pluckErrorDetail(parsedError)); + return []; + }) + .finally(() => { + inFlightFetches.delete(cacheKey); + }); + inFlightFetches.set(cacheKey, fetchPromise); } + + return fetchPromise; } async #fetch(query?: string): Promise { - const api = aki(AdminApi); - return api - .adminFileList({ - usage: this.usage as UsageEnum, - ...(query ? { search: query } : {}), - }) - .then((response) => { - // Cast necessary: API returns File objects but we only use name, url, mime_type, size, and usage properties - const fileResponse = response as unknown as FileItem[]; - - if (!fileResponse || !Array.isArray(fileResponse)) { - console.error("Invalid response format from files API", fileResponse); - return []; - } - - let results = fileResponse; - - // Only add synthetic item on initial load (no query), not during search. - // This prevents stale values from appearing in search results. - // The synthetic item is needed for fa:// URLs or custom URLs that aren't in the API. - if (!query && this.value && !results.find((item) => item.name === this.value)) { - results = [ - { - name: this.value, - url: this.value, - mime_type: "", - size: 0, - usage: this.usage, - }, - ...results, - ]; - } - - return results; - }) - .catch(async (error) => { - const parsedError = await parseAPIResponseError(error); - console.error(msg("Failed to fetch files"), pluckErrorDetail(parsedError)); - return []; - }); + return this.#fetchFiles(query).then((fileResponse) => { + let results = fileResponse; + + if (!query && this.value && !results.find((item) => item.name === this.value)) { + results = [createCustomFileItem(this.value, this.usage), ...results]; + } + + return results; + }); } + #createObject = (value: string): FileItem => { + return createCustomFileItem(value, this.usage); + }; + render() { return html` ${AKLabel( @@ -140,6 +151,7 @@ export class AKFileSearchInput extends AKElement { .renderElement=${renderElement} .value=${renderValue} .selected=${this.#selected} + .createObject=${this.#createObject} ?blankable=${this.blankable} creatable > diff --git a/web/src/elements/forms/SearchSelect/SearchSelect.ts b/web/src/elements/forms/SearchSelect/SearchSelect.ts index 11c4d8d5de97..ba5fbed4e3df 100644 --- a/web/src/elements/forms/SearchSelect/SearchSelect.ts +++ b/web/src/elements/forms/SearchSelect/SearchSelect.ts @@ -73,6 +73,11 @@ export abstract class SearchSelectBase */ public abstract selected?: (element: T, elements: T[]) => boolean; + /** + * Create an object for a custom value when creatable is enabled. + */ + public abstract createObject?: (value: string) => T; + /** * A function passed to this object (or using the default below) that groups objects in the * collection under search into categories. @@ -198,8 +203,7 @@ export abstract class SearchSelectBase const selectedValue = this.selectedObject ? this.value(this.selectedObject) : null; if (selectedValue !== currentValue) { - // Input has changed but hasn't been committed yet so create synthetic object - this.selectedObject = { name: currentValue } as T; + this.selectedObject = this.createObject?.(currentValue) ?? null; } } } @@ -279,13 +283,11 @@ export abstract class SearchSelectBase this.query = value; this.updateData()?.then(() => { - // If creatable, check if selectedObject's value matches the typed value exactly + // If creatable, check if selectedObject's value matches the typed value exactly. if (this.creatable) { const selectedValue = this.selectedObject ? this.value(this.selectedObject) : null; if (selectedValue !== value) { - // No exact match so create a synthetic object with the raw value - // "synthetic" isn't an official term or anything, it's just called like that here - this.selectedObject = { name: value } as T; + this.selectedObject = this.createObject?.(value) ?? null; } } this.dispatchChangeEvent(this.selectedObject); @@ -319,8 +321,7 @@ export abstract class SearchSelectBase if (!selected) { if (this.creatable) { - // Create a synthetic object with the user's custom value - this.selectedObject = { name: value } as T; + this.selectedObject = this.createObject?.(value) ?? null; this.dispatchChangeEvent(this.selectedObject); return; } diff --git a/web/src/elements/forms/SearchSelect/ak-search-select-ez.ts b/web/src/elements/forms/SearchSelect/ak-search-select-ez.ts index 9ed5b6730e7a..51af605d0aea 100644 --- a/web/src/elements/forms/SearchSelect/ak-search-select-ez.ts +++ b/web/src/elements/forms/SearchSelect/ak-search-select-ez.ts @@ -9,6 +9,7 @@ export interface ISearchSelectConfig { renderDescription?: (element: T) => string | TemplateResult; value: (element: T | null) => string; selected?: (element: T, elements: T[]) => boolean; + createObject?: (value: string) => T; groupBy?: (items: T[]) => [string, T[]][]; } @@ -50,6 +51,7 @@ export class SearchSelectEz extends SearchSelectBase { public renderDescription?: ((element: T) => string | TemplateResult) | undefined; public value!: (element: T | null) => string; public selected?: ((element: T, elements: T[]) => boolean) | undefined; + public createObject?: ((value: string) => T) | undefined; @property({ type: Object, attribute: false }) // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -61,6 +63,7 @@ export class SearchSelectEz extends SearchSelectBase { this.renderDescription = this.config.renderDescription; this.value = this.config.value; this.selected = this.config.selected; + this.createObject = this.config.createObject; if (this.config.groupBy) { this.groupBy = this.config.groupBy; diff --git a/web/src/elements/forms/SearchSelect/ak-search-select.ts b/web/src/elements/forms/SearchSelect/ak-search-select.ts index acc19f16258a..df83d0eccfd5 100644 --- a/web/src/elements/forms/SearchSelect/ak-search-select.ts +++ b/web/src/elements/forms/SearchSelect/ak-search-select.ts @@ -18,6 +18,7 @@ export interface ISearchSelect extends ISearchSelectBase { renderDescription?: (element: T) => SlottedTemplateResult; value: (element: T | null) => string | number; selected?: (element: T, elements: T[]) => boolean; + createObject?: (value: string) => T; groupBy: (items: T[]) => [string, T[]][]; } @@ -71,6 +72,9 @@ export class SearchSelect< @property({ attribute: false }) public selected?: (element: T, elements: T[]) => boolean; + @property({ attribute: false }) + public createObject?: (value: string) => T; + @property({ attribute: false }) public groupBy: (items: T[]) => [string, T[]][] = (items: T[]): [string, T[]][] => { return groupBy(items, () => "");