import { state, property } 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 { SlMenuItem } from "@shoelace-style/shoelace"; import type { PageChangeEvent } from "../../components/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 noCollectionsImg from "../../assets/images/no-collections-found.webp"; import type { SelectNewDialogEvent } from "./index"; type Collections = APIPaginatedList & { items: Collection[]; }; type SearchFields = "name"; type SearchResult = { item: { key: SearchFields; value: string; }; }; type SortField = "modified" | "name" | "totalSize"; type SortDirection = "asc" | "desc"; const INITIAL_PAGE_SIZE = 10; 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() 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: string = ""; @state() private searchResultsOpen = false; @state() private openDialogName?: "delete"; @state() private isDialogVisible: boolean = false; @state() private collectionToDelete?: Collection; @state() private fetchErrorStatusCode?: number; // For fuzzy search: private fuse = new Fuse([], { 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 numberFormatter = new Intl.NumberFormat(undefined, { notation: "compact", }); protected async willUpdate(changedProperties: Map) { if (changedProperties.has("orgId")) { this.collections = undefined; this.fetchSearchValues(); } if ( changedProperties.has("orgId") || changedProperties.has("filterBy") || changedProperties.has("orderBy") ) { this.fetchCollections(); } } render() { return html`

${msg("Collections")}

${when( this.isCrawler, () => html` { this.dispatchEvent( new CustomEvent("select-new-dialog", { detail: "collection", }) ); }} > ${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.collectionToDelete?.name}?` )}
(this.openDialogName = undefined)} >Cancel { await this.deleteCollection(this.collectionToDelete!); this.openDialogName = undefined; }} >Delete Collection
`; } private renderLoading = () => html`
`; private 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", }) ); }} > ${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} > ${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 renderList = () => { if (this.collections?.items.length) { return html`
${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", }) ); }} > ${msg("Create a New Collection")}
`, () => html`

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

` )}
`; }; private renderItem = (col: Collection) => html`
  • ${col?.isPublic ? html` ` : html` `}
    ${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 renderActions = (col: Collection) => { const authToken = this.authState!.headers.Authorization.split(" ")[1]; return html` this.navTo(`${this.orgBasePath}/collections/edit/${col.id}`)} > ${msg("Edit Collection")} ${!col?.isPublic ? html` this.onTogglePublic(col, true)} > ${msg("Make Shareable")} ` : html` Visit Shareable URL this.onTogglePublic(col, false)} > ${msg("Make Private")} `} { (e.target as HTMLAnchorElement).closest("sl-dropdown")?.hide(); }} > ${msg("Download Collection")} this.confirmDelete(col)} > ${msg("Delete Collection")} `; }; private renderFetchError = () => html`
    ${msg(`Something unexpected went wrong while retrieving Collections.`)}
    `; private onSearchInput = debounce(150)((e: any) => { this.searchByValue = e.target.value.trim(); if (this.searchResultsOpen === false && this.hasSearchStr) { this.searchResultsOpen = true; } if (!this.searchByValue) { const { name, ...otherFilters } = this.filterBy; this.filterBy = { ...otherFilters, }; } }) as any; private async onTogglePublic(coll: Collection, isPublic: boolean) { const res = await this.apiFetch( `/orgs/${this.orgId}/collections/${coll.id}`, this.authState!, { method: "PATCH", body: JSON.stringify({ isPublic }), } ); this.fetchCollections(); } private getPublicReplayURL(col: Collection) { return new URL( `/api/orgs/${this.orgId}/collections/${col.id}/public/replay.json`, window.location.href ).href; } private confirmDelete = (collection: Collection) => { this.collectionToDelete = collection; this.openDialogName = "delete"; }; 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.collectionToDelete = undefined; 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"))] as any); } catch (e) { console.debug(e); } } private async fetchCollections(params?: APIPaginationQuery) { this.fetchErrorStatusCode = undefined; try { this.collections = await this.getCollections(params); } catch (e: any) { if (e.isApiError) { 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 ): Promise { 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: APIPaginatedList = await this.apiFetch( `/orgs/${this.orgId}/collections?${query}`, this.authState! ); return data; } } customElements.define("btrix-collections-list", CollectionsList);