import { state, property, customElement } from "lit/decorators.js"; import { msg, localized, str } from "@lit/localize"; import { when } from "lit/directives/when.js"; import debounce from "lodash/fp/debounce"; import type { SlMenuItem, SlIconButton } from "@shoelace-style/shoelace"; import queryString from "query-string"; import type { AuthState } from "../utils/AuthService"; import type { Collection, CollectionList } from "../types/collection"; import LiteElement, { html } from "../utils/LiteElement"; import type { APIPaginatedList, APIPaginationQuery, APISortQuery, } from "../types/api"; const INITIAL_PAGE_SIZE = 10; const MIN_SEARCH_LENGTH = 2; type CollectionSearchResults = APIPaginatedList & { items: CollectionList; }; export type CollectionsChangeEvent = CustomEvent<{ collections: string[]; }>; /** * Usage: * ```ts * * ``` * @events collections-change */ @localized() @customElement("btrix-collections-add") export class CollectionsAdd extends LiteElement { @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[] = []; @state() private searchByValue: string = ""; @state() private searchResults: CollectionList = []; private get hasSearchStr() { return this.searchByValue.length >= MIN_SEARCH_LENGTH; } @state() private searchResultsOpen = false; connectedCallback() { if (this.initialCollections) { this.collectionIds = this.initialCollections; } super.connectedCallback(); this.initializeCollectionsFromIds(); } disconnectedCallback() { this.onSearchInput.cancel(); super.disconnectedCallback(); } render() { return html`
${this.renderSearch()}
${when(this.collectionIds, () => this.collectionIds.length ? html`
` : this.emptyText ? html`

${this.emptyText}

` : "" )}
`; } private renderSearch() { return html` { this.searchResultsOpen = false; this.searchByValue = ""; }} @sl-select=${async (e: CustomEvent) => { this.searchResultsOpen = false; const item = e.detail.item as SlMenuItem; const collId = item.dataset["key"]; if (collId && this.collectionIds.indexOf(collId) === -1) { const coll = this.searchResults.find( (collection) => collection.id === collId ); if (coll) { const { id } = coll; if (!this.collectionsData[id]) { this.collectionsData = { ...this.collectionsData, [id]: await this.getCollection(id), }; } this.collectionIds = [...this.collectionIds, id]; this.dispatchChange(); } } }} > { this.searchResultsOpen = false; this.onSearchInput.cancel(); }} @sl-input=${this.onSearchInput} > ${this.renderSearchResults()} `; } private renderSearchResults() { if (!this.hasSearchStr) { return html` ${msg("Start typing to search Collections.")} `; } // Filter out stale search results from last debounce invocation const searchResults = this.searchResults.filter((res) => new RegExp(`^${this.searchByValue}`, "i").test(res.name) ); if (!searchResults.length) { return html` ${msg("No matching Collections found.")} `; } return html` ${searchResults.map((item: Collection) => { return html`
${item.name}
${msg(str`${item.crawlCount} items`)}
`; })} `; } private renderCollectionItem(id: string) { const collection = this.collectionsData[id]; 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), ]; this.dispatchChange(); } } } private onSearchInput = debounce(200)(async (e: any) => { this.searchByValue = e.target.value.trim(); if (this.searchResultsOpen === false && this.hasSearchStr) { this.searchResultsOpen = true; } const data: CollectionSearchResults | undefined = await this.fetchCollectionsByPrefix(this.searchByValue); let searchResults: CollectionList = []; if (data && data.items.length) { searchResults = this.filterOutSelectedCollections(data.items); } this.searchResults = searchResults; }) as any; private filterOutSelectedCollections(results: CollectionList) { return results.filter((result) => { return !this.collectionIds.some((id) => id === result.id); }); } private async fetchCollectionsByPrefix(namePrefix: string) { try { const results: CollectionSearchResults = await this.getCollections({ oid: this.orgId, namePrefix: namePrefix, sortBy: "name", pageSize: INITIAL_PAGE_SIZE, }); return results; } catch { this.notify({ 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 ): Promise { const query = queryString.stringify(params || {}, { arrayFormat: "comma", }); const data: APIPaginatedList = await this.apiFetch( `/orgs/${this.orgId}/collections?${query}`, this.authState! ); return data; } private async initializeCollectionsFromIds() { if (!this.collectionIds) return; this.collectionIds.forEach(async (id) => { const data = await this.getCollection(id); if (data) { this.collectionsData = { ...this.collectionsData, [id]: data, }; } }); } private getCollection = (collId: string): Promise => { return this.apiFetch( `/orgs/${this.orgId}/collections/${collId}`, this.authState! ); }; private async dispatchChange() { await this.updateComplete; this.dispatchEvent( new CustomEvent("collections-change", { detail: { collections: this.collectionIds }, }) ); } }