import { localized, msg } from "@lit/localize"; import { Task } from "@lit/task"; import type { SlChangeEvent, SlInput, SlSelect, } from "@shoelace-style/shoelace"; import clsx from "clsx"; import { html, type PropertyValues } 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 sortBy from "lodash/fp/sortBy"; import queryString from "query-string"; import { BtrixElement } from "@/classes/BtrixElement"; import type { Combobox } from "@/components/ui/combobox"; import type { APIPaginatedList, APIPaginationQuery } from "@/types/api"; import type { UnderlyingFunction } from "@/types/utils"; import { tw } from "@/utils/tailwind"; type Snapshot = { pageId: string; ts: string; status: number; }; type Page = { url: string; count: number; snapshots: Snapshot[]; }; type SnapshotItem = Snapshot & { url: string }; export type SelectSnapshotDetail = { item: SnapshotItem | null; }; const DEFAULT_PROTOCOL = "http"; const sortByTs = sortBy("ts"); /** * @fires btrix-select */ @localized() @customElement("btrix-select-collection-start-page") export class SelectCollectionStartPage extends BtrixElement { @property({ type: String }) collectionId?: string; @property({ type: String }) homeUrl?: string | null = null; @property({ type: String }) homeTs?: string | null = null; @state() private searchQuery = ""; @state() private selectedPage?: Page; @state() public selectedSnapshot?: Snapshot; @state() private pageUrlError?: string; @query("btrix-combobox") private readonly combobox?: Combobox | null; @query("#pageUrlInput") private readonly input?: SlInput | null; public get page() { return this.selectedPage; } public get snapshot() { return this.selectedSnapshot; } updated(changedProperties: PropertyValues) { if (changedProperties.has("homeUrl") && this.homeUrl) { if (this.input) { this.input.value = this.homeUrl; } this.searchQuery = this.homeUrl; void this.initSelection(); } if (changedProperties.has("selectedSnapshot")) { this.dispatchEvent( new CustomEvent("btrix-select", { detail: { item: this.selectedPage?.url ? ({ url: this.selectedPage.url, ...this.selectedSnapshot, } as SnapshotItem) : null, }, }), ); } } private async initSelection() { await this.updateComplete; await this.searchResults.taskComplete; if (this.homeUrl && this.searchResults.value) { this.selectedPage = this.searchResults.value.items.find( ({ url }) => url === this.homeUrl, ); if (this.selectedPage && this.homeTs) { this.selectedSnapshot = this.selectedPage.snapshots.find( ({ ts }) => ts === this.homeTs, ); } } } private readonly searchResults = new Task(this, { task: async ([searchValue], { signal }) => { const searchResults = await this.getPageUrls( { id: this.collectionId!, urlPrefix: searchValue, }, signal, ); return searchResults; }, args: () => [this.searchQuery] as const, }); render() { return html`
${this.renderPageSearch()} { const { value } = e.currentTarget as SlSelect; await this.updateComplete; this.selectedSnapshot = this.selectedPage?.snapshots.find( ({ pageId }) => pageId === value, ); }} > ${when( this.selectedSnapshot, (snapshot) => html` ${snapshot.status} `, )} ${when(this.selectedPage, (item) => item.snapshots.map( ({ pageId, ts, status }) => html` ${this.localize.date(ts)} ${status} `, ), )}
`; } private renderPageSearch() { let prefix: { icon: string; tooltip: string; className?: string; } = { icon: "search", tooltip: msg("Search for a page in this collection"), }; if (this.pageUrlError) { prefix = { icon: "exclamation-lg", tooltip: this.pageUrlError, className: tw`text-danger`, }; } else if (this.selectedPage) { prefix = { icon: "check-lg", tooltip: msg("Page exists in collection"), className: tw`text-success`, }; } return html` { this.combobox?.hide(); }} > { this.resetInputValidity(); this.combobox?.show(); }} @sl-clear=${async () => { this.resetInputValidity(); this.searchQuery = ""; this.selectedPage = undefined; this.selectedSnapshot = undefined; }} @sl-input=${this.onSearchInput as UnderlyingFunction< typeof this.onSearchInput >} @sl-blur=${this.pageUrlOnBlur} >
${this.renderSearchResults()}
`; } private resetInputValidity() { this.pageUrlError = undefined; this.input?.setCustomValidity(""); } private readonly pageUrlOnBlur = async () => { if (!this.searchQuery) return; if (this.selectedPage) { // Ensure input value matches the URL, e.g. if the user pressed // backspace on an existing URL if (this.searchQuery !== this.selectedPage.url && this.input) { this.input.value = this.selectedPage.url; } return; } await this.searchResults.taskComplete; const results = this.searchResults.value; if (!results) return; if (results.total === 0) { if (this.input) { this.pageUrlError = msg( "Page not found in collection. Please check the URL and try again", ); this.input.setCustomValidity(this.pageUrlError); } // Clear selection this.selectedPage = undefined; this.selectedSnapshot = undefined; } else if (results.total === 1) { // Choose only option, e.g. for copy-paste this.selectedPage = this.searchResults.value.items[0]; this.selectedSnapshot = this.selectedPage.snapshots[0]; } }; private renderSearchResults() { return this.searchResults.render({ pending: () => html` `, complete: ({ items }) => { if (!items.length) { return html` ${msg("No matching page found.")} `; } return html` ${items.map((item: Page) => { return html` { if (this.input) { this.input.value = item.url; } this.selectedPage = { ...item, // TODO check if backend can sort snapshots: sortByTs(item.snapshots).reverse(), }; this.combobox?.hide(); this.selectedSnapshot = this.selectedPage.snapshots[0]; }} >${item.url} `; })} `; }, }); } private readonly onSearchInput = debounce(400)(() => { const value = this.input?.value; if (!value) { return; } if (value.startsWith(DEFAULT_PROTOCOL)) { this.combobox?.show(); } else { if (value !== DEFAULT_PROTOCOL.slice(0, value.length)) { this.input.value = `https://${value}`; this.combobox?.show(); } } this.searchQuery = this.input.value; }); private async getPageUrls( { id, urlPrefix, page = 1, pageSize = 5, }: { id: string; urlPrefix?: string; } & APIPaginationQuery, signal?: AbortSignal, ) { const query = queryString.stringify({ page, pageSize, urlPrefix: urlPrefix ? window.encodeURIComponent(urlPrefix) : undefined, }); return this.api.fetch>( `/orgs/${this.orgId}/collections/${id}/urls?${query}`, { signal }, ); } }