Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 58 additions & 46 deletions web/src/components/ak-file-search-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,21 @@ interface FileItem {
usage: string;
}

const inFlightFetches = new Map<string, Promise<FileItem[]>>();

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
*
Expand Down Expand Up @@ -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<FileItem[]> {
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<FileItem[]> {
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` <ak-form-element-horizontal name=${ifDefined(this.name ?? undefined)}>
${AKLabel(
Expand All @@ -140,6 +151,7 @@ export class AKFileSearchInput extends AKElement {
.renderElement=${renderElement}
.value=${renderValue}
.selected=${this.#selected}
.createObject=${this.#createObject}
?blankable=${this.blankable}
creatable
>
Expand Down
17 changes: 9 additions & 8 deletions web/src/elements/forms/SearchSelect/SearchSelect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ export abstract class SearchSelectBase<T>
*/
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.
Expand Down Expand Up @@ -198,8 +203,7 @@ export abstract class SearchSelectBase<T>
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;
}
}
}
Expand Down Expand Up @@ -279,13 +283,11 @@ export abstract class SearchSelectBase<T>

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);
Expand Down Expand Up @@ -319,8 +321,7 @@ export abstract class SearchSelectBase<T>

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;
}
Expand Down
3 changes: 3 additions & 0 deletions web/src/elements/forms/SearchSelect/ak-search-select-ez.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface ISearchSelectConfig<T = unknown> {
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[]][];
}

Expand Down Expand Up @@ -50,6 +51,7 @@ export class SearchSelectEz<T> extends SearchSelectBase<T> {
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
Expand All @@ -61,6 +63,7 @@ export class SearchSelectEz<T> extends SearchSelectBase<T> {
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;
Expand Down
4 changes: 4 additions & 0 deletions web/src/elements/forms/SearchSelect/ak-search-select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface ISearchSelect<T> extends ISearchSelectBase<T> {
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[]][];
}

Expand Down Expand Up @@ -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, () => "");
Expand Down
Loading