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.collectionIds.map(this.renderCollectionItem, this)}
`
: this.emptyText
? html`
`
: "",
)}
`;
}
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,
);
}
}