import { type TemplateResult, type PropertyValues, html, css } from "lit"; import { customElement, property, queryAll } from "lit/decorators.js"; import { until } from "lit/directives/until.js"; import { repeat } from "lit/directives/repeat.js"; import { msg, localized, str } from "@lit/localize"; import type { SlSwitch, SlTreeItem } from "@shoelace-style/shoelace"; import queryString from "query-string"; import isEqual from "lodash/fp/isEqual"; import type { APIPaginatedList, APIPaginationQuery, APISortQuery, } from "@/types/api"; import { APIController } from "@/controllers/api"; import type { Workflow, Crawl } from "@/types/crawler"; import { type AuthState } from "@/utils/AuthService"; import { TailwindElement } from "@/classes/TailwindElement"; import { finishedCrawlStates } from "@/utils/crawler"; export type SelectionChangeDetail = { selection: Record; }; export type AutoAddChangeDetail = { id: string; checked: boolean; }; const CRAWLS_PAGE_SIZE = 50; /** * @fires btrix-selection-change * @fires btrix-auto-add-change */ @localized() @customElement("btrix-collection-workflow-list") export class CollectionWorkflowList extends TailwindElement { static styles = css` :host { --border: 1px solid var(--sl-panel-border-color); } sl-tree-item::part(expand-button) { /* Move expand button to end */ order: 2; /* Increase size */ font-size: 1rem; padding: var(--sl-spacing-small); flex: none; } /* Increase size of label */ sl-tree-item::part(label) { flex: 1 1 0%; overflow: hidden; } /* Hide default indentation marker */ sl-tree-item::part(item--selected) { background-color: transparent; border-inline-start-color: transparent; } /* Remove indentation spacing */ sl-tree-item::part(indentation) { display: none; } sl-tree-item::part(checkbox) { padding: var(--sl-spacing-small); } /* Add disabled styles only to checkbox */ sl-tree-item::part(item--disabled) { opacity: 1; } sl-tree-item.workflow:not(.selectable)::part(checkbox) { opacity: 0; } sl-tree > sl-tree-item:not([expanded])::part(item) { box-shadow: var(--sl-shadow-small); } sl-tree > sl-tree-item::part(item) { border: var(--border); border-radius: var(--sl-border-radius-medium); } sl-tree > sl-tree-item:nth-of-type(n + 2) { margin-top: var(--sl-spacing-x-small); } sl-tree-item::part(children) { border-left: var(--border); border-right: var(--border); border-bottom: var(--border); border-bottom-left-radius: var(--sl-border-radius-medium); border-bottom-right-radius: var(--sl-border-radius-medium); /* Offset child checkboxes */ margin: 0 var(--sl-spacing-x-small); } sl-tree-item > sl-tree-item:nth-of-type(n + 2) { border-top: var(--border); } `; @property({ type: Object }) authState?: AuthState; @property({ type: String }) orgId?: string; @property({ type: String }) collectionId?: string; @property({ type: Array, hasChanged(newVal: Workflow[], oldVal: Workflow[]) { // Customize change detection to only re-render // when workflow IDs change if (Array.isArray(newVal) && Array.isArray(oldVal)) { return ( newVal.length !== oldVal.length || !isEqual(newVal.map(({ id }) => id))(oldVal.map(({ id }) => id)) ); } return newVal !== oldVal; }, }) workflows: Workflow[] = []; /** * Whether item is selected or not, keyed by ID */ @property({ type: Object }) selection: { [itemID: string]: boolean } = {}; @queryAll(".crawl") private readonly crawlItems?: NodeListOf; private readonly crawlsMap = new Map< /* workflow ID: */ string, Promise >(); private readonly api = new APIController(this); protected willUpdate(changedProperties: PropertyValues): void { if (changedProperties.has("workflows")) { void this.fetchCrawls(); } } render() { return html`) => { if (!this.crawlItems) { console.debug("no crawl items with classname `crawl`"); return; } e.stopPropagation(); const selection: CollectionWorkflowList["selection"] = {}; Array.from(this.crawlItems).forEach((item) => { if (!item.dataset.crawlId) return; selection[item.dataset.crawlId] = item.selected; }); this.dispatchEvent( new CustomEvent("btrix-selection-change", { detail: { selection }, }), ); }} > ${repeat(this.workflows, ({ id }) => id, this.renderWorkflow)} `; } private readonly renderWorkflow = (workflow: Workflow) => { const crawlsAsync = this.crawlsMap.get(workflow.id) || Promise.resolve([]); const countAsync = crawlsAsync.then((crawls) => ({ total: crawls.length, selected: crawls.filter(({ id }) => this.selection[id]).length, })); return html` selected > 0 && selected === total, ), false, )} .indeterminate=${ // NOTE `indeterminate` is not a documented public property, // we're manually setting it since async child tree-items // doesn't work as of shoelace 2.8.0 until( countAsync.then( ({ total, selected }) => selected > 0 && selected < total, ), false, ) } ?disabled=${until( countAsync.then(({ total }) => total === 0), true, )} @click=${(e: MouseEvent) => { void countAsync.then(({ total }) => { if (!total) { // Prevent selection since we're just allowing auto-add e.stopPropagation(); } }); }} >
${this.renderName(workflow)}
${until( countAsync.then(({ total, selected }) => total === 1 ? msg( str`${selected.toLocaleString()} / ${total.toLocaleString()} crawl`, ) : total ? msg( str`${selected.toLocaleString()} / ${total.toLocaleString()} crawls`, ) : msg("0 crawls"), ), )}
id === this.collectionId, )} @click=${(e: MouseEvent) => { e.stopPropagation(); }} @sl-change=${(e: CustomEvent) => { e.stopPropagation(); this.dispatchEvent( new CustomEvent( "btrix-auto-add-change", { detail: { id: workflow.id, checked: (e.target as SlSwitch).checked, }, composed: true, }, ), ); }} > ${msg("Auto-Add")}
${until(crawlsAsync.then((crawls) => crawls.map(this.renderCrawl)))}
`; }; private readonly renderCrawl = (crawl: Crawl) => { const pageCount = +(crawl.stats?.done || 0); return html`
${pageCount === 1 ? msg(str`${pageCount.toLocaleString()} page`) : msg(str`${pageCount.toLocaleString()} pages`)}
${msg(str`Started by ${crawl.userName}`)}
`; }; /** * Get crawls for each workflow in list */ private async fetchCrawls() { try { this.workflows.forEach((workflow) => { this.crawlsMap.set( workflow.id, this.getCrawls({ cid: workflow.id, pageSize: CRAWLS_PAGE_SIZE }).then( ({ items }) => items, ), ); }); } catch (e: unknown) { console.debug(e); } } private async getCrawls( params: Partial<{ cid: string; }> & APIPaginationQuery & APISortQuery, ) { if (!this.authState) throw new Error("Missing attribute `authState`"); if (!this.orgId) throw new Error("Missing attribute `orgId`"); const query = queryString.stringify( { state: finishedCrawlStates, ...params, }, { arrayFormat: "comma", }, ); const data = await this.api.fetch>( `/orgs/${this.orgId}/crawls?${query}`, this.authState, ); return data; } // TODO consolidate collections/workflow name private renderName(workflow: Workflow) { if (workflow.name) return html`${workflow.name}`; if (workflow.firstSeed && workflow.seedCount) { const remainder = workflow.seedCount - 1; let nameSuffix: string | TemplateResult<1> = ""; if (remainder) { if (remainder === 1) { nameSuffix = html`${msg(str`+${remainder} URL`)}`; } else { nameSuffix = html`${msg(str`+${remainder} URLs`)}`; } } return html`
${workflow.firstSeed} ${nameSuffix}
`; } } }