import { state, property, customElement } from "lit/decorators.js"; import { msg, localized, str } from "@lit/localize"; import { when } from "lit/directives/when.js"; import { guard } from "lit/directives/guard.js"; import queryString from "query-string"; import Fuse from "fuse.js"; import debounce from "lodash/fp/debounce"; import type { SlInput, SlMenuItem } from "@shoelace-style/shoelace"; import type { PageChangeEvent } from "@/components/ui/pagination"; import type { AuthState } from "@/utils/AuthService"; import LiteElement, { html } from "@/utils/LiteElement"; import type { APIPaginatedList, APIPaginationQuery } from "@/types/api"; import type { Collection, CollectionSearchValues } from "@/types/collection"; import type { CollectionSavedEvent } from "@/features/collections/collection-metadata-dialog"; import noCollectionsImg from "~assets/images/no-collections-found.webp"; import type { SelectNewDialogEvent } from "./index"; import type { OverflowDropdown } from "@/components/ui/overflow-dropdown"; import { isApiError } from "@/utils/api"; import { type PropertyValues } from "lit"; import type { UnderlyingFunction } from "@/types/utils"; type Collections = APIPaginatedList; type SearchFields = "name"; type SearchResult = { item: { key: SearchFields; value: string; }; }; type SortField = "modified" | "name" | "totalSize"; type SortDirection = "asc" | "desc"; const INITIAL_PAGE_SIZE = 20; const sortableFields: Record< SortField, { label: string; defaultDirection?: SortDirection } > = { modified: { label: msg("Last Updated"), defaultDirection: "desc", }, name: { label: msg("Name"), defaultDirection: "asc", }, totalSize: { label: msg("Size"), defaultDirection: "desc", }, }; const MIN_SEARCH_LENGTH = 2; @localized() @customElement("btrix-collections-list") export class CollectionsList extends LiteElement { @property({ type: Object }) authState!: AuthState; @property({ type: String }) orgId!: string; @property({ type: Boolean }) isCrawler?: boolean; @state() private collections?: Collections; @state() private orderBy: { field: SortField; direction: SortDirection; } = { field: "modified", direction: sortableFields["modified"].defaultDirection!, }; @state() private filterBy: Partial> = {}; @state() private searchByValue = ""; @state() private searchResultsOpen = false; @state() private openDialogName?: "create" | "delete" | "editMetadata"; @state() private isDialogVisible = false; @state() private selectedCollection?: Collection; @state() private fetchErrorStatusCode?: number; // For fuzzy search: private readonly fuse = new Fuse<{ key: "name"; value: string }>([], { keys: ["value"], shouldSort: false, threshold: 0.2, // stricter; default is 0.6 }); private get hasSearchStr() { return this.searchByValue.length >= MIN_SEARCH_LENGTH; } // TODO localize private readonly numberFormatter = new Intl.NumberFormat(undefined, { notation: "compact", }); protected async willUpdate( changedProperties: PropertyValues & Map, ) { if (changedProperties.has("orgId")) { this.collections = undefined; void this.fetchSearchValues(); } if ( changedProperties.has("orgId") || changedProperties.has("filterBy") || changedProperties.has("orderBy") ) { void this.fetchCollections(); } } render() { return html`

${msg("Collections")}

${when( this.isCrawler, () => html` (this.openDialogName = "create")} > ${msg("New Collection")} `, )}
${when(this.fetchErrorStatusCode, this.renderFetchError, () => this.collections ? html`
${this.renderControls()}
${guard([this.collections], this.renderList)}
` : this.renderLoading(), )} (this.openDialogName = undefined)} @sl-after-hide=${() => (this.isDialogVisible = false)} > ${msg( html`Are you sure you want to delete ${this.selectedCollection?.name}?`, )}
(this.openDialogName = undefined)} >${msg("Cancel")} { await this.deleteCollection(this.selectedCollection!); this.openDialogName = undefined; }} >${msg("Delete Collection")}
(this.openDialogName = undefined)} @sl-after-hide=${() => (this.selectedCollection = undefined)} @btrix-collection-saved=${(e: CollectionSavedEvent) => { if (this.openDialogName === "create") { this.navTo( `${this.orgBasePath}/collections/view/${e.detail.id}/items`, ); } else { void this.fetchCollections(); } }} > `; } private readonly renderLoading = () => html`
`; private readonly renderEmpty = () => html`
${this.isCrawler ? msg("Start building your Collection.") : msg("No Collections Found")}
${when( this.isCrawler, () => html`

${msg( "Organize your crawls into a Collection to easily replay them together.", )}

{ this.dispatchEvent( new CustomEvent("select-new-dialog", { detail: "collection", }) as SelectNewDialogEvent, ); }} > ${msg("Create a New Collection")}
`, () => html`

${msg("Your organization doesn't have any Collections, yet.")}

`, )}
`; private renderControls() { return html`
${this.renderSearch()}
${msg("Sort by:")}
{ const field = (e.target as HTMLSelectElement) .value as SortField; this.orderBy = { field: field, direction: sortableFields[field].defaultDirection || this.orderBy.direction, }; }} > ${Object.entries(sortableFields).map( ([value, { label }]) => html` ${label} `, )} { this.orderBy = { ...this.orderBy, direction: this.orderBy.direction === "asc" ? "desc" : "asc", }; }} >
`; } 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 key = item.dataset["key"] as SearchFields; this.searchByValue = item.value; await this.updateComplete; this.filterBy = { ...this.filterBy, [key]: item.value, }; }} > { this.searchResultsOpen = false; this.onSearchInput.cancel(); const { name: _, ...otherFilters } = this.filterBy; this.filterBy = otherFilters; }} @sl-input=${this.onSearchInput as UnderlyingFunction< typeof this.onSearchInput >} > ${this.renderSearchResults()} `; } private renderSearchResults() { if (!this.hasSearchStr) { return html` ${msg("Start typing to view collection filters.")} `; } const searchResults = this.fuse.search(this.searchByValue).slice(0, 10); if (!searchResults.length) { return html` ${msg("No matching collections found.")} `; } return html` ${searchResults.map( ({ item }: SearchResult) => html` ${item.value} `, )} `; } private readonly renderList = () => { if (this.collections?.items.length) { return html` ${msg("Collection Access")} ${msg("Name")} ${msg("Archived Items")} ${msg("Total Size")} ${msg("Total Pages")} ${msg("Last Updated")} ${msg("Row Actions")} ${this.collections.items.map(this.renderItem)} ${when( this.collections.total > this.collections.pageSize || this.collections.page > 1, () => html`
{ await this.fetchCollections({ page: e.detail.page, }); // Scroll to top of list // TODO once deep-linking is implemented, scroll to top of pushstate this.scrollIntoView({ behavior: "smooth" }); }} >
`, )} `; } return html`

${msg("No Collections Yet.")}

${when( this.isCrawler, () => html`

${msg( "Organize your crawls into a Collection to easily replay them together.", )}

{ this.dispatchEvent( new CustomEvent("select-new-dialog", { detail: "collection", }) as SelectNewDialogEvent, ); }} > ${msg("Create a New Collection")}
`, () => html`

${msg("Your organization doesn't have any Collections, yet.")}

`, )}
`; }; private readonly renderItem = (col: Collection) => html` ${col.isPublic ? html` ` : html` `} ${col.name} ${col.crawlCount === 1 ? msg("1 item") : msg(str`${this.numberFormatter.format(col.crawlCount)} items`)} ${col.pageCount === 1 ? msg("1 page") : msg(str`${this.numberFormatter.format(col.pageCount)} pages`)} ${this.isCrawler ? this.renderActions(col) : ""} `; private readonly renderActions = (col: Collection) => { const authToken = this.authState!.headers.Authorization.split(" ")[1]; return html` void this.manageCollection(col, "editMetadata")} > ${msg("Edit Metadata")} ${!col.isPublic ? html` void this.onTogglePublic(col, true)} > ${msg("Make Shareable")} ` : html` Visit Shareable URL void this.onTogglePublic(col, false)} > ${msg("Make Private")} `} { (e.target as HTMLAnchorElement) .closest("btrix-overflow-dropdown") ?.hide(); }} > ${msg("Download Collection")} void this.manageCollection(col, "delete")} > ${msg("Delete Collection")} `; }; private readonly renderFetchError = () => html`
${msg(`Something unexpected went wrong while retrieving Collections.`)}
`; private readonly onSearchInput = debounce(150)((e: Event) => { this.searchByValue = (e.target as SlInput).value.trim(); if (!this.searchResultsOpen && this.hasSearchStr) { this.searchResultsOpen = true; } if (!this.searchByValue) { const { name: _, ...otherFilters } = this.filterBy; this.filterBy = { ...otherFilters, }; } }); private async onTogglePublic(coll: Collection, isPublic: boolean) { await this.apiFetch( `/orgs/${this.orgId}/collections/${coll.id}`, this.authState!, { method: "PATCH", body: JSON.stringify({ isPublic }), }, ); void this.fetchCollections(); } private getPublicReplayURL(col: Collection) { return new URL( `/api/orgs/${this.orgId}/collections/${col.id}/public/replay.json`, window.location.href, ).href; } private readonly manageCollection = async ( collection: Collection, dialogName: CollectionsList["openDialogName"], ) => { this.selectedCollection = collection; await this.updateComplete; this.openDialogName = dialogName; }; private async deleteCollection(collection: Collection): Promise { try { const name = collection.name; await this.apiFetch( `/orgs/${this.orgId}/collections/${collection.id}`, this.authState!, // FIXME API method is GET right now { method: "DELETE", }, ); this.selectedCollection = undefined; void this.fetchCollections(); this.notify({ message: msg(html`Deleted ${name} Collection.`), variant: "success", icon: "check2-circle", }); } catch { this.notify({ message: msg("Sorry, couldn't delete Collection at this time."), variant: "danger", icon: "exclamation-octagon", }); } } private async fetchSearchValues() { try { const searchValues: CollectionSearchValues = await this.apiFetch( `/orgs/${this.orgId}/collections/search-values`, this.authState!, ); const names = searchValues.names; // Update search/filter collection const toSearchItem = (key: SearchFields) => (value: string): SearchResult["item"] => ({ key, value, }); this.fuse.setCollection([...names.map(toSearchItem("name"))]); } catch (e) { console.debug(e); } } private async fetchCollections(params?: APIPaginationQuery) { this.fetchErrorStatusCode = undefined; try { this.collections = await this.getCollections(params); } catch (e) { if (isApiError(e)) { this.fetchErrorStatusCode = e.statusCode; } else { this.notify({ message: msg("Sorry, couldn't retrieve Collections at this time."), variant: "danger", icon: "exclamation-octagon", }); } } } private async getCollections(queryParams?: APIPaginationQuery) { const query = queryString.stringify( { ...this.filterBy, page: queryParams?.page || this.collections?.page || 1, pageSize: queryParams?.pageSize || this.collections?.pageSize || INITIAL_PAGE_SIZE, sortBy: this.orderBy.field, sortDirection: this.orderBy.direction === "desc" ? -1 : 1, }, { arrayFormat: "comma", }, ); const data = await this.apiFetch>( `/orgs/${this.orgId}/collections?${query}`, this.authState!, ); return data; } }