import { localized, msg, str } from "@lit/localize"; import { Task } from "@lit/task"; import type { SlInput, SlMenuItem } from "@shoelace-style/shoelace"; import { html } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; import { when } from "lit/directives/when.js"; import debounce from "lodash/fp/debounce"; import queryString from "query-string"; import { TailwindElement } from "@/classes/TailwindElement"; import type { Combobox } from "@/components/ui/combobox"; import { APIController } from "@/controllers/api"; import { NotifyController } from "@/controllers/notify"; import type { APIPaginatedList, APIPaginationQuery, APISortQuery, } from "@/types/api"; import type { Collection } from "@/types/collection"; import type { UnderlyingFunction } from "@/types/utils"; import type { AuthState } from "@/utils/AuthService"; const INITIAL_PAGE_SIZE = 10; const MIN_SEARCH_LENGTH = 2; export type CollectionsChangeEvent = CustomEvent<{ collections: string[]; }>; /** * Usage: * ```ts * * ``` * @events collections-change */ @localized() @customElement("btrix-collections-add") export class CollectionsAdd extends TailwindElement { @property({ type: Object }) authState!: AuthState; @property({ type: Array }) initialCollections?: string[]; @property({ type: String }) orgId!: string; @property({ type: String }) configId?: string; @property({ type: String }) label?: string; /* Text to show on collection empty state */ @property({ type: String }) emptyText?: string; @state() private collectionsData: { [id: string]: Collection } = {}; @state() private collectionIds: string[] = []; @query("sl-input") private readonly input?: SlInput | null; @query("btrix-combobox") private readonly combobox?: Combobox | null; private readonly api = new APIController(this); private readonly notify = new NotifyController(this); private get searchByValue() { return this.input ? this.input.value.trim() : ""; } private get hasSearchStr() { return this.searchByValue.length >= MIN_SEARCH_LENGTH; } private readonly searchResultsTask = new Task(this, { task: async ([searchByValue, hasSearchStr], { signal }) => { if (!hasSearchStr) return []; const data = await this.fetchCollectionsByPrefix(searchByValue, signal); let searchResults: Collection[] = []; if (data?.items.length) { searchResults = this.filterOutSelectedCollections(data.items); } return searchResults; }, args: () => [this.searchByValue, this.hasSearchStr] as const, }); connectedCallback() { if (this.initialCollections) { this.collectionIds = this.initialCollections; } super.connectedCallback(); void this.initializeCollectionsFromIds(); } disconnectedCallback() { super.disconnectedCallback(); } render() { return html`
${this.renderSearch()}
${when(this.collectionIds, () => this.collectionIds.length ? html`
` : this.emptyText ? html`

${this.emptyText}

` : "", )}
`; } private renderSearch() { return html` { this.combobox?.hide(); if (this.input) this.input.value = ""; }} @sl-select=${async (e: CustomEvent<{ item: SlMenuItem }>) => { this.combobox?.hide(); const item = e.detail.item; const collId = item.dataset["key"]; if (collId && this.collectionIds.indexOf(collId) === -1) { const coll = this.searchResultsTask.value?.find( (collection) => collection.id === collId, ); if (coll) { const { id } = coll; if (!(this.collectionsData[id] as Collection | undefined)) { this.collectionsData = { ...this.collectionsData, [id]: (await this.getCollection(id))!, }; } this.collectionIds = [...this.collectionIds, id]; void this.dispatchChange(); } } }} > { this.combobox?.hide(); }} @keyup=${() => { if (this.combobox && !this.combobox.open && this.hasSearchStr) { this.combobox.show(); } }} @sl-input=${this.onSearchInput as UnderlyingFunction< typeof this.onSearchInput >} > ${this.renderSearchResults()} `; } private renderSearchResults() { return this.searchResultsTask.render({ pending: () => html` `, complete: (searchResults) => { if (!this.hasSearchStr) { return html` ${msg("Start typing to search Collections.")} `; } // Filter out stale search results from last debounce invocation const results = searchResults.filter((res) => new RegExp(`^${this.searchByValue}`, "i").test(res.name), ); if (!results.length) { return html` ${msg("No matching Collections found.")} `; } return html` ${results.map((item: Collection) => { return html` ${item.name}
${msg(str`${item.crawlCount} items`)}
`; })} `; }, }); } private renderCollectionItem(id: string) { const collection = this.collectionsData[id] as Collection | undefined; return html`
  • ${collection?.name}
    ${msg(str`${collection?.crawlCount || 0} items`)}
  • `; } private removeCollection(event: Event) { const target = event.currentTarget as HTMLElement; const collectionId = target.getAttribute("data-key"); if (collectionId) { const collIdIndex = this.collectionIds.indexOf(collectionId); if (collIdIndex > -1) { this.collectionIds = [ ...this.collectionIds.slice(0, collIdIndex), ...this.collectionIds.slice(collIdIndex + 1), ]; void this.dispatchChange(); } } } private readonly onSearchInput = debounce(400)(() => { void this.searchResultsTask.run(); }); private filterOutSelectedCollections(results: Collection[]) { return results.filter((result) => { return !this.collectionIds.some((id) => id === result.id); }); } private async fetchCollectionsByPrefix( namePrefix: string, signal?: AbortSignal, ) { try { const results = await this.getCollections( { oid: this.orgId, namePrefix: namePrefix, sortBy: "name", pageSize: INITIAL_PAGE_SIZE, }, signal, ); return results; } catch (e) { if ((e as Error).name === "AbortError") { console.debug("Fetch aborted to throttle"); } else { this.notify.toast({ message: msg("Sorry, couldn't retrieve Collections at this time."), variant: "danger", icon: "exclamation-octagon", }); } } } private async getCollections( params?: Partial<{ oid?: string; namePrefix?: string; }> & APIPaginationQuery & APISortQuery, signal?: AbortSignal, ) { const query = queryString.stringify(params || {}, { arrayFormat: "comma", }); const data = await this.api.fetch>( `/orgs/${this.orgId}/collections?${query}`, this.authState!, { signal }, ); return data; } private async initializeCollectionsFromIds() { this.collectionIds.forEach(async (id) => { const data = await this.getCollection(id); if (data) { this.collectionsData = { ...this.collectionsData, [id]: data, }; } }); } private readonly getCollection = async ( collId: string, ): Promise => { return this.api.fetch( `/orgs/${this.orgId}/collections/${collId}`, this.authState!, ); }; private async dispatchChange() { await this.updateComplete; this.dispatchEvent( new CustomEvent("collections-change", { detail: { collections: this.collectionIds }, }) as CollectionsChangeEvent, ); } }