browsertrix/frontend/src/pages/org/collection-editor.ts
sua yoo 66b3befef9
Frontend collections beta UI (#886)
- Support for creating new collections and editing existing collections
- Can select crawling workflows which adds entire workflow, and then deselect individual crawls
- Can edit existing collections and add more crawls
- Can view, create and delete collections via new Collections top-level nav entry
2023-06-06 17:52:01 -07:00

1224 lines
36 KiB
TypeScript

import type { TemplateResult } from "lit";
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 { styleMap } from "lit/directives/style-map.js";
import { ref } from "lit/directives/ref.js";
import debounce from "lodash/fp/debounce";
import { mergeDeep } from "immutable";
import omit from "lodash/fp/omit";
import groupBy from "lodash/fp/groupBy";
import keyBy from "lodash/fp/keyBy";
import orderBy from "lodash/fp/orderBy";
import flow from "lodash/fp/flow";
import Fuse from "fuse.js";
import queryString from "query-string";
import { serialize } from "@shoelace-style/shoelace/dist/utilities/form.js";
import type { SlMenuItem } from "@shoelace-style/shoelace";
import type {
CheckboxChangeEvent,
CheckboxGroupList,
} from "../../components/checkbox-list";
import type { MarkdownChangeEvent } from "../../components/markdown-editor";
import type { AuthState } from "../../utils/AuthService";
import LiteElement, { html } from "../../utils/LiteElement";
import { maxLengthValidator } from "../../utils/form";
import type {
APIPaginatedList,
APIPaginationQuery,
APISortQuery,
} from "../../types/api";
import type { Collection } from "../../types/collection";
import type { Crawl, CrawlState, Workflow } from "../../types/crawler";
import type { PageChangeEvent } from "../../components/pagination";
const TABS = ["crawls", "metadata"] as const;
type Tab = (typeof TABS)[number];
type SearchFields = "name" | "firstSeed";
type SearchResult = {
item: {
key: SearchFields;
value: string;
};
};
type SortField = "lastRun" | "modified" | "created" | "firstSeed";
type SortDirection = "asc" | "desc";
const sortableFields: Record<
SortField,
{ label: string; defaultDirection?: SortDirection }
> = {
lastRun: {
label: msg("Latest Crawl"),
defaultDirection: "desc",
},
modified: {
label: msg("Last Modified"),
defaultDirection: "desc",
},
created: {
label: msg("Created At"),
defaultDirection: "desc",
},
firstSeed: {
label: msg("Crawl Start URL"),
defaultDirection: "asc",
},
};
const finishedCrawlStates: CrawlState[] = [
"complete",
"partial_complete",
"timed_out",
];
const WORKFLOW_CRAWL_LIMIT = 100;
const WORKFLOW_PAGE_SIZE = 10;
const CRAWL_PAGE_SIZE = 5;
const MIN_SEARCH_LENGTH = 2;
export type CollectionSubmitEvent = CustomEvent<{
values: {
name: string;
description: string | null;
crawlIds: string[];
oldCrawlIds?: string[];
};
}>;
/**
* @event on-submit
*/
@localized()
export class CollectionEditor extends LiteElement {
@property({ type: Object })
authState!: AuthState;
@property({ type: String })
orgId!: string;
@property({ type: Boolean })
isCrawler?: boolean;
@property({ type: String })
collectionId?: string;
@property({ type: Object })
metadataValues?: Collection;
@property({ type: Boolean })
isSubmitting = false;
@state()
private collectionCrawls?: Crawl[];
// Store crawl IDs to compare later
private savedCollectionCrawlIds: string[] = [];
@state()
private workflows?: APIPaginatedList & {
items: Workflow[];
};
@state()
private workflowPagination: {
[workflowId: string]: APIPaginationQuery & {
items: Workflow[];
};
} = {};
@state()
private workflowIsLoading: {
[workflowId: string]: boolean;
} = {};
@state()
private selectedCrawls: {
[crawlId: string]: Crawl;
} = {};
@state()
private activeTab: Tab = TABS[0];
@state()
private orderWorkflowsBy: {
field: SortField;
direction: SortDirection;
} = {
field: "lastRun",
direction: sortableFields["lastRun"].defaultDirection!,
};
@state()
private filterWorkflowsBy: Partial<Record<keyof Crawl, any>> = {};
@state()
private searchByValue: string = "";
@state()
private searchResultsOpen = false;
private get hasSearchStr() {
return this.searchByValue.length >= MIN_SEARCH_LENGTH;
}
private get selectedSearchFilterKey() {
return Object.keys(this.fieldLabels).find((key) =>
Boolean((this.filterWorkflowsBy as any)[key])
);
}
// TODO localize
private numberFormatter = new Intl.NumberFormat(undefined, {
notation: "compact",
});
// For fuzzy search:
private fuse = new Fuse([], {
keys: ["value"],
shouldSort: false,
threshold: 0.2, // stricter; default is 0.6
});
private validateNameMax = maxLengthValidator(50);
private readonly fieldLabels: Record<SearchFields, string> = {
name: msg("Name"),
firstSeed: msg("Crawl Start URL"),
};
private readonly tabLabels: Record<Tab, string> = {
crawls: msg("Select Crawls"),
metadata: msg("Metadata"),
};
protected async willUpdate(changedProperties: Map<string, any>) {
if (changedProperties.has("orgId") && this.orgId) {
this.fetchSearchValues();
}
if (
(changedProperties.has("orgId") && this.orgId) ||
changedProperties.has("filterWorkflowsBy") ||
changedProperties.has("orderWorkflowsBy")
) {
this.fetchWorkflows();
}
if (changedProperties.has("collectionId") && this.collectionId) {
this.fetchCollectionCrawls();
}
}
connectedCallback(): void {
// Set initial active section and dialog based on URL #hash value
this.getActivePanelFromHash();
super.connectedCallback();
window.addEventListener("hashchange", this.getActivePanelFromHash);
}
disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener("hashchange", this.getActivePanelFromHash);
}
render() {
return html`<form
name="collectionForm"
autocomplete="off"
@submit=${this.onSubmit}
>
<btrix-tab-list
activePanel="collectionForm-${this.activeTab}"
progressPanel="collectionForm-${this.activeTab}"
>
${guard(
[this.activeTab],
() => html`
<h3 slot="header" class="font-semibold">
${this.tabLabels[this.activeTab]}
</h3>
${TABS.map(this.renderTab)}
`
)}
<btrix-tab-panel name="collectionForm-crawls">
${this.renderSelectCrawls()}
</btrix-tab-panel>
<btrix-tab-panel name="collectionForm-metadata">
${guard(
[this.metadataValues, this.isSubmitting, this.workflowIsLoading],
this.renderMetadata
)}
</btrix-tab-panel>
</btrix-tab-list>
</form>`;
}
private renderTab = (tab: Tab) => {
const isActive = tab === this.activeTab;
const completed = false; // TODO
const iconProps = {
name: "circle",
library: "default",
class: "text-neutral-400",
};
if (isActive) {
iconProps.name = "pencil-circle-dashed";
iconProps.library = "app";
iconProps.class = "text-base";
} else if (completed) {
iconProps.name = "check-circle";
}
return html`
<btrix-tab
slot="nav"
name="collectionForm-${tab}"
class="whitespace-nowrap"
@click=${() => this.goToTab(tab)}
>
<sl-icon
name=${iconProps.name}
library=${iconProps.library}
class="inline-block align-middle mr-1 text-base ${iconProps.class}"
></sl-icon>
<span class="inline-block align-middle whitespace-normal">
${this.tabLabels[tab]}
</span>
</btrix-tab>
`;
};
private renderSelectCrawls = () => {
return html`
<section class="grid grid-cols-1 md:grid-cols-2 gap-4">
<section class="col-span-1 flex flex-col">
<h4 class="text-base font-semibold mb-3">
${msg("Crawls in Collection")}
</h4>
<div class="border rounded-lg py-2 flex-1">
${guard(
[
this.isCrawler,
this.collectionCrawls,
this.selectedCrawls,
this.workflowPagination,
],
this.renderCollectionWorkflowList
)}
</div>
</section>
<section class="col-span-1 flex flex-col">
<h4 class="text-base font-semibold mb-3">${msg("All Workflows")}</h4>
${when(
this.workflows?.total,
() =>
html`
<div class="flex-0 border rounded bg-neutral-50 p-2 mb-2">
${guard(
[
this.searchResultsOpen,
this.searchByValue,
this.filterWorkflowsBy,
this.orderWorkflowsBy,
],
this.renderWorkflowListControls
)}
</div>
`
)}
<div class="flex-1">
${guard(
[
this.isCrawler,
this.workflows,
this.collectionCrawls,
this.selectedCrawls,
this.workflowIsLoading,
],
this.renderWorkflowList
)}
</div>
<footer class="mt-4 flex justify-center">
${when(
this.workflows?.total,
() => html`
<btrix-pagination
page=${this.workflows!.page}
totalCount=${this.workflows!.total}
size=${this.workflows!.pageSize}
@page-change=${async (e: PageChangeEvent) => {
await this.fetchWorkflows({
page: e.detail.page,
});
// Scroll to top of list
this.scrollIntoView({ behavior: "smooth" });
}}
></btrix-pagination>
`
)}
</footer>
</section>
<footer
class="col-span-full border rounded-lg px-6 py-4 flex justify-end"
>
${when(
this.collectionId,
() => html`
<sl-button
size="small"
variant="primary"
?disabled=${this.isSubmitting ||
Object.values(this.workflowIsLoading).some(
(isLoading) => isLoading === true
)}
?loading=${this.isSubmitting}
@click=${this.submitCrawlSelectionChanges}
>
${msg("Save Crawl Selection")}
</sl-button>
`,
() => html`
<sl-button size="small" @click=${() => this.goToTab("metadata")}>
<sl-icon slot="suffix" name="chevron-right"></sl-icon>
${msg("Enter Metadata")}
</sl-button>
`
)}
</footer>
</section>
`;
};
private renderMetadata = () => {
return html`
<section class="border rounded-lg">
<div class="p-6">
<sl-input
class="mb-2 with-max-help-text"
name="name"
label=${msg("Name")}
placeholder=${msg("My Collection")}
autocomplete="off"
value=${this.metadataValues?.name}
required
help-text=${this.validateNameMax.helpText}
@sl-input=${this.validateNameMax.validate}
></sl-input>
<fieldset>
<label class="form-label">${msg("Description")}</label>
<btrix-markdown-editor
name="description"
initialValue=${this.metadataValues?.description}
maxlength=${4000}
></btrix-markdown-editor>
</fieldset>
</div>
<footer class="border-t px-6 py-4 flex justify-between">
${when(
!this.collectionId,
() => html`
<sl-button size="small" @click=${() => this.goToTab("crawls")}>
<sl-icon slot="prefix" name="chevron-left"></sl-icon>
${msg("Select Crawls")}
</sl-button>
`
)}
<sl-button
class="ml-auto"
type="submit"
size="small"
variant="primary"
?disabled=${this.isSubmitting ||
Object.values(this.workflowIsLoading).some(
(isLoading) => isLoading === true
)}
?loading=${this.isSubmitting}
>
${this.collectionId ? msg("Save Metadata") : msg("Save Collection")}
</sl-button>
</footer>
</section>
`;
};
private renderCollectionWorkflowList = () => {
if (this.collectionId && !this.collectionCrawls) {
return this.renderLoading();
}
if (!this.collectionCrawls?.length) {
return html`
<div
class="flex flex-col items-center justify-center text-center p-4 my-12"
>
<span class="text-base font-semibold text-primary"
>${msg("No Crawls in this Collection, yet")}</span
>
<p class="max-w-[24em] mx-auto mt-4">
${(this.workflows && !this.workflows.total) || !this.isCrawler
? msg(
"Select Workflows or individual Crawls. You can always come back and add Crawls later."
)
: msg(
"Create a Workflow to select Crawls. You can always come back and add Crawls later."
)}
</p>
</div>
`;
}
const groupedByWorkflow = groupBy("cid")(this.collectionCrawls) as any;
return html`
<btrix-checkbox-list>
${Object.keys(groupedByWorkflow).map((workflowId) =>
this.renderWorkflowCrawls(
workflowId,
orderBy(["finished"])(["desc"])(
groupedByWorkflow[workflowId]
) as any
)
)}
</btrix-checkbox-list>
`;
};
private renderWorkflowCrawls(workflowId: string, crawls: Crawl[]) {
const selectedCrawlIds = crawls
.filter(({ id }) => this.selectedCrawls[id])
.map(({ id }) => id);
const allChecked = crawls.length === selectedCrawlIds.length;
// Use latest crawl for workflow information, since we
// may not have access to workflow details
const firstCrawl = crawls[0];
return html`
<btrix-checkbox-list-item
?checked=${selectedCrawlIds.length}
?allChecked=${allChecked}
group
aria-controls=${selectedCrawlIds.join(" ")}
@on-change=${(e: CheckboxChangeEvent) => {
if (e.detail.checked || !allChecked) {
this.selectCrawls(crawls);
} else {
this.deselectCrawls(crawls);
}
}}
>
<div class="grid grid-cols-[1fr_4.6rem_2.5rem] gap-3 items-center">
<div class="col-span-1 min-w-0 truncate">
${this.renderCrawlName(firstCrawl)}
</div>
<div
class="col-span-1 text-neutral-500 text-xs font-monostyle truncate h-4"
>
${crawls.length === 1
? msg("1 crawl")
: msg(str`${this.numberFormatter.format(crawls.length)} crawls`)}
</div>
<div class="col-span-1 border-l flex items-center justify-center">
<btrix-button
class="expandBtn p-2 text-base transition-transform"
aria-label=${msg("Expand row")}
aria-expanded="false"
aria-controls=${`workflow-${workflowId}`}
@click=${(e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
this.toggleWorkflow(workflowId);
}}
icon
>
<sl-icon name="chevron-double-down"></sl-icon>
</btrix-button>
</div>
</div>
<section
id=${`workflow-${workflowId}-group`}
slot="group"
class="checkboxGroup overflow-hidden offscreen"
${ref(this.checkboxGroupUpdated)}
>
${guard(
[this.selectedCrawls, this.workflowPagination[workflowId]],
() => this.renderWorkflowCrawlList(workflowId, crawls)
)}
</section>
</btrix-checkbox-list-item>
`;
}
private renderWorkflowCrawlList = (workflowId: string, crawls: Crawl[]) => {
const { page = 1 } = this.workflowPagination[workflowId] || {};
return html`
<btrix-checkbox-group-list>
${crawls
.slice((page - 1) * CRAWL_PAGE_SIZE, page * CRAWL_PAGE_SIZE)
.map((crawl) => this.renderCrawl(crawl, workflowId))}
</btrix-checkbox-group-list>
${when(
crawls.length > CRAWL_PAGE_SIZE,
() => html`
<footer class="flex justify-center">
<btrix-pagination
page=${page}
totalCount=${crawls.length}
size=${CRAWL_PAGE_SIZE}
compact
@page-change=${async (e: PageChangeEvent) => {
this.workflowPagination = mergeDeep(this.workflowPagination, {
[workflowId]: { page: e.detail.page },
});
}}
></btrix-pagination>
</footer>
`
)}
`;
};
private renderCrawl(crawl: Crawl, workflowId?: string) {
return html`
<btrix-checkbox-list-item
id=${crawl.id}
name="crawlIds"
value=${crawl.id}
?checked=${this.selectedCrawls[crawl.id]}
@on-change=${(e: CheckboxChangeEvent) => {
if (e.detail.checked) {
this.selectedCrawls = mergeDeep(this.selectedCrawls, {
[crawl.id]: crawl,
});
} else {
this.selectedCrawls = omit([crawl.id])(this.selectedCrawls) as any;
}
}}
>
<div class="flex items-center">
<btrix-crawl-status
state=${crawl.state}
hideLabel
></btrix-crawl-status>
<div class="flex-1">
${workflowId
? html`<sl-format-date
date=${`${crawl.finished}Z`}
month="2-digit"
day="2-digit"
year="2-digit"
hour="2-digit"
minute="2-digit"
></sl-format-date>`
: this.renderSeedsLabel(crawl.firstSeed, crawl.seedCount)}
</div>
<div class="w-16 font-monostyle truncate">
<sl-tooltip content=${msg("Pages in crawl")}>
<div class="flex items-center">
<sl-icon
class="text-base"
name="file-earmark-richtext"
></sl-icon>
<div class="ml-1 text-xs">
${this.numberFormatter.format(+(crawl.stats?.done || 0))}
</div>
</div>
</sl-tooltip>
</div>
<div class="w-14">
<sl-format-bytes
class="text-neutral-500 text-xs font-monostyle"
value=${crawl.fileSize || 0}
display="narrow"
></sl-format-bytes>
</div>
</div>
</btrix-checkbox-list-item>
`;
}
private renderWorkflowListControls = () => {
return html`
<div>
<div class="mb-2">${this.renderSearch()}</div>
<div class="flex items-center">
<div class="whitespace-nowrap text-neutral-500 mx-2">
${msg("Sort by:")}
</div>
<sl-select
class="flex-1"
size="small"
pill
value=${this.orderWorkflowsBy.field}
@sl-change=${(e: Event) => {
const field = (e.target as HTMLSelectElement).value as SortField;
this.orderWorkflowsBy = {
field: field,
direction:
sortableFields[field].defaultDirection ||
this.orderWorkflowsBy.direction,
};
}}
>
${Object.entries(sortableFields).map(
([value, { label }]) => html`
<sl-option value=${value}>${label}</sl-option>
`
)}
</sl-select>
<sl-icon-button
name="arrow-down-up"
label=${msg("Reverse sort")}
@click=${() => {
this.orderWorkflowsBy = {
...this.orderWorkflowsBy,
direction:
this.orderWorkflowsBy.direction === "asc" ? "desc" : "asc",
};
}}
></sl-icon-button>
</div>
</div>
`;
};
private renderSearch() {
return html`
<btrix-combobox
?open=${this.searchResultsOpen}
@request-close=${() => {
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.filterWorkflowsBy = {
...this.filterWorkflowsBy,
[key]: item.value,
};
}}
>
<sl-input
size="small"
placeholder=${msg("Search by name or Crawl Start URL")}
clearable
value=${this.searchByValue}
@sl-clear=${() => {
this.searchResultsOpen = false;
this.onSearchInput.cancel();
const { name, firstSeed, ...otherFilters } = this.filterWorkflowsBy;
this.filterWorkflowsBy = otherFilters;
}}
@sl-input=${this.onSearchInput}
>
${when(
this.selectedSearchFilterKey,
() =>
html`<sl-tag
slot="prefix"
size="small"
pill
style="margin-left: var(--sl-spacing-3x-small)"
>${this.fieldLabels[
this.selectedSearchFilterKey as SearchFields
]}</sl-tag
>`,
() => html`<sl-icon name="search" slot="prefix"></sl-icon>`
)}
</sl-input>
${this.renderSearchResults()}
</btrix-combobox>
`;
}
private renderSearchResults() {
if (!this.hasSearchStr) {
return html`
<sl-menu-item slot="menu-item" disabled
>${msg("Start typing to view crawl filters.")}</sl-menu-item
>
`;
}
const searchResults = this.fuse.search(this.searchByValue).slice(0, 10);
if (!searchResults.length) {
return html`
<sl-menu-item slot="menu-item" disabled
>${msg("No matching crawls found.")}</sl-menu-item
>
`;
}
return html`
${searchResults.map(
({ item }: SearchResult) => html`
<sl-menu-item
slot="menu-item"
data-key=${item.key}
value=${item.value}
>
<sl-tag slot="prefix" size="small" pill
>${this.fieldLabels[item.key]}</sl-tag
>
${item.value}
</sl-menu-item>
`
)}
`;
}
private renderWorkflowList = () => {
if (!this.workflows) {
return this.renderLoading();
}
if (!this.workflows.total) {
return html`
<div class="h-full flex justify-center items-center">
${when(
this.isCrawler,
() => html`
<sl-button
href=${`/orgs/${this.orgId}/workflows?new&jobType=`}
variant="primary"
@click=${this.navLink}
>
<sl-icon slot="prefix" name="plus-lg"></sl-icon>
${msg("New Crawl Workflow")}
</sl-button>
`,
() =>
html`
<p class="text-neutral-400 text-center max-w-[24em]">
${msg("Your organization doesn't have any Crawl Workflows.")}
</p>
`
)}
</div>
`;
}
const groupedByWorkflow = groupBy("cid")(this.collectionCrawls) as any;
return html`
<btrix-checkbox-list>
${this.workflows.items.map((workflow) =>
this.renderWorkflowItem(workflow, groupedByWorkflow[workflow.id])
)}
</btrix-checkbox-list>
`;
};
private renderWorkflowItem(workflow: Workflow, crawls: Crawl[] = []) {
const selectedCrawls = crawls.filter(({ id }) => this.selectedCrawls[id]);
const allChecked = workflow.crawlSuccessfulCount === selectedCrawls.length;
return html`
<btrix-checkbox-list-item
?checked=${selectedCrawls.length}
?allChecked=${allChecked}
?disabled=${this.collectionId && !this.collectionCrawls}
group
@on-change=${(e: CheckboxChangeEvent) => {
if (e.detail.checked || !allChecked) {
this.selectWorkflow(workflow.id);
} else {
this.deselectCrawls(crawls);
}
}}
>
<div class="relative">
<div class="grid grid-cols-[1fr_12ch] gap-3">
${this.renderWorkflowDetails(workflow)}
</div>
${this.workflowIsLoading[workflow.id]
? html`<div
class="absolute top-0 left-0 right-0 bottom-0 bg-white bg-opacity-50 flex items-center justify-center text-lg -ml-11"
>
<sl-spinner></sl-spinner>
</div>`
: ""}
</div>
</btrix-checkbox-list-item>
`;
}
private renderWorkflowDetails(workflow: Workflow) {
return html`
<div class="col-span-1 py-3 whitespace-nowrap truncate">
<div class="text-neutral-700 h-6 truncate">
${this.renderCrawlName(workflow)}
</div>
<div class="text-neutral-500 text-xs font-monostyle truncate h-4">
<sl-format-date
date=${workflow.lastCrawlTime || workflow.modified}
month="2-digit"
day="2-digit"
year="2-digit"
hour="2-digit"
minute="2-digit"
></sl-format-date>
</div>
</div>
<div class="col-span-1 py-3">
<div class="text-neutral-700 truncate h-6">
<sl-format-bytes
value=${workflow.totalSize}
display="narrow"
></sl-format-bytes>
</div>
<div class="text-neutral-500 text-xs font-monostyle truncate h-4">
${this.renderCrawlCount(workflow)}
</div>
</div>
`;
}
private renderCrawlCount(workflow: Workflow) {
const count = Math.min(WORKFLOW_CRAWL_LIMIT, workflow.crawlSuccessfulCount);
let message = "";
if (count === 1) {
message = msg("1 crawl");
} else {
message = msg(str`${this.numberFormatter.format(count)} crawls`);
}
return html`<span class="inline-block align-middle">${message}</span
>${workflow.crawlSuccessfulCount > count
? html`<sl-tooltip
content=${msg(
str`Only showing latest ${WORKFLOW_CRAWL_LIMIT} crawls`
)}
>
<sl-icon
class="inline-block align-middle"
name="exclamation-triangle"
></sl-icon>
</sl-tooltip>`
: ""}`;
}
private renderCrawlName(item: Workflow | Crawl) {
if (item.name) return html`<span class="min-w-0">${item.name}</span>`;
if (!item.firstSeed) return html`<span class="min-w-0">${item.id}</span>`;
return this.renderSeedsLabel(
item.firstSeed,
(item as Crawl).seedCount || item.config?.seeds.length
);
}
private renderSeedsLabel(firstSeed: string, seedCount: number) {
let nameSuffix: any = "";
const remainder = seedCount - 1;
if (remainder) {
if (remainder === 1) {
nameSuffix = html`<span class="ml-1 text-neutral-500"
>${msg(str`+${this.numberFormatter.format(remainder)} URL`)}</span
>`;
} else {
nameSuffix = html`<span class="ml-1 text-neutral-500"
>${msg(str`+${this.numberFormatter.format(remainder)} URLs`)}</span
>`;
}
}
return html`
<div class="flex">
<span class="min-w-0 truncate">${firstSeed}</span>${nameSuffix}
</div>
`;
}
private renderLoading = () => html`
<div class="w-full flex items-center justify-center my-24 text-3xl">
<sl-spinner></sl-spinner>
</div>
`;
private selectCrawls(crawls: Crawl[]) {
const allCrawls = crawls.reduce(
(acc: any, crawl: Crawl) => ({
...acc,
[crawl.id]: crawl,
}),
{}
);
this.selectedCrawls = mergeDeep(this.selectedCrawls, allCrawls);
}
private deselectCrawls(crawls: Crawl[]) {
this.selectedCrawls = omit(crawls.map(({ id }) => id))(
this.selectedCrawls
) as any;
}
private async selectWorkflow(workflowId: string) {
const crawls = await this.fetchWorkflowCrawls(workflowId);
this.selectCrawls(crawls);
}
private checkboxGroupUpdated = async (el: any) => {
await this.updateComplete;
if (el) {
await el.updateComplete;
if (el.classList.contains("offscreen")) {
// Set up initial position for expand/contract toggle
el.style.marginTop = `-${el.clientHeight}px`;
el.style.opacity = "0";
el.style.pointerEvents = "none";
el.classList.remove("offscreen");
}
}
};
private toggleWorkflow = async (workflowId: string) => {
const checkboxGroup = this.querySelector(
`#workflow-${workflowId}-group`
) as HTMLElement;
const listItem = checkboxGroup.closest(
"btrix-checkbox-list-item"
) as HTMLElement;
const expandBtn = listItem.querySelector(".expandBtn") as HTMLElement;
const expand = !(expandBtn.getAttribute("aria-expanded") === "true");
expandBtn.setAttribute("aria-expanded", expand.toString());
checkboxGroup.classList.add("transition-all");
if (expand) {
expandBtn.classList.add("rotate-180");
checkboxGroup.style.marginTop = "0px";
checkboxGroup.style.opacity = "100%";
checkboxGroup.style.pointerEvents = "auto";
} else {
expandBtn.classList.remove("rotate-180");
checkboxGroup.style.marginTop = `-${checkboxGroup.clientHeight}px`;
checkboxGroup.style.opacity = "0";
checkboxGroup.style.pointerEvents = "none";
}
};
private onSearchInput = debounce(150)((e: any) => {
this.searchByValue = e.target.value.trim();
if (this.searchResultsOpen === false && this.hasSearchStr) {
this.searchResultsOpen = true;
}
if (!this.searchByValue && this.selectedSearchFilterKey) {
const {
[this.selectedSearchFilterKey as SearchFields]: _,
...otherFilters
} = this.filterWorkflowsBy;
this.filterWorkflowsBy = {
...otherFilters,
};
}
}) as any;
private async submitCrawlSelectionChanges() {
this.dispatchEvent(
<CollectionSubmitEvent>new CustomEvent("on-submit", {
detail: {
values: {
oldCrawlIds: this.savedCollectionCrawlIds,
crawlIds: Object.keys(this.selectedCrawls),
},
},
})
);
}
private async onSubmit(event: SubmitEvent) {
event.preventDefault();
event.stopPropagation();
await this.updateComplete;
const form = event.target as HTMLFormElement;
if (form.querySelector("[data-invalid]")) {
return;
}
const values = serialize(form);
if (!this.collectionId) {
// Crawl IDs can only be saved in new collections
values.crawlIds = Object.keys(this.selectedCrawls);
}
this.dispatchEvent(
<CollectionSubmitEvent>new CustomEvent("on-submit", {
detail: { values },
})
);
}
private getActivePanelFromHash = () => {
const hashValue = window.location.hash.slice(1);
if (TABS.includes(hashValue as any)) {
this.activeTab = hashValue as Tab;
} else {
this.goToTab(TABS[0], { replace: true });
}
};
private goToTab(tab: Tab, { replace = false } = {}) {
const path = `${window.location.href.split("#")[0]}#${tab}`;
if (replace) {
window.history.replaceState(null, "", path);
} else {
window.history.pushState(null, "", path);
}
this.activeTab = tab;
}
private async fetchWorkflows(params: APIPaginationQuery = {}) {
try {
this.workflows = await this.getWorkflows({
page: params.page || this.workflows?.page || 1,
pageSize:
params.pageSize || this.workflows?.pageSize || WORKFLOW_PAGE_SIZE,
});
} catch (e: any) {
this.notify({
message: msg("Sorry, couldn't retrieve Workflows at this time."),
variant: "danger",
icon: "exclamation-octagon",
});
}
}
private async getWorkflows(
params: APIPaginationQuery
): Promise<APIPaginatedList> {
const query = queryString.stringify({
...params,
...this.filterWorkflowsBy,
sortBy: this.orderWorkflowsBy.field,
sortDirection: this.orderWorkflowsBy.direction === "desc" ? -1 : 1,
});
const data: APIPaginatedList = await this.apiFetch(
`/orgs/${this.orgId}/crawlconfigs?${query}`,
this.authState!
);
return data;
}
private async fetchCollectionCrawls() {
if (!this.collectionId) return;
try {
const { items: crawls } = await this.getCrawls({
collectionId: this.collectionId,
sortBy: "finished",
pageSize: WORKFLOW_CRAWL_LIMIT,
});
this.selectedCrawls = mergeDeep(
this.selectedCrawls,
crawls.reduce(
(acc, crawl) => ({
...acc,
[crawl.id]: crawl,
}),
{}
)
);
// TODO remove omit once API removes errors
this.collectionCrawls = crawls.map(omit("errors")) as Crawl[];
// Store crawl IDs to compare later
this.savedCollectionCrawlIds = this.collectionCrawls.map(({ id }) => id);
} catch {
this.notify({
message: msg(
"Sorry, couldn't retrieve Crawls in Collection at this time."
),
variant: "danger",
icon: "exclamation-octagon",
});
}
}
private async fetchWorkflowCrawls(workflowId: string): Promise<Crawl[]> {
this.workflowIsLoading = mergeDeep(this.workflowIsLoading, {
[workflowId]: true,
});
let workflowCrawls: Crawl[] = [];
try {
const { items } = await this.getCrawls({
cid: workflowId,
state: finishedCrawlStates,
sortBy: "finished",
pageSize: WORKFLOW_CRAWL_LIMIT,
});
// TODO remove omit once API removes errors
const crawls = items.map(omit("errors")) as Crawl[];
this.collectionCrawls = flow(
keyBy("id"),
(res) => mergeDeep(keyBy("id")(this.collectionCrawls), res),
Object.values
)(crawls) as any;
workflowCrawls = crawls;
} catch {
this.notify({
message: msg("Sorry, couldn't retrieve Crawl Workflow at this time."),
variant: "danger",
icon: "exclamation-octagon",
});
}
this.workflowIsLoading = mergeDeep(this.workflowIsLoading, {
[workflowId]: false,
});
return workflowCrawls;
}
private async getCrawls(
params: Partial<{
cid?: string;
collectionId?: string;
state: CrawlState[];
}> &
APIPaginationQuery &
APISortQuery
): Promise<APIPaginatedList> {
const query = queryString.stringify(params || {}, {
arrayFormat: "comma",
});
const data: APIPaginatedList = await this.apiFetch(
`/orgs/${this.orgId}/crawls?${query}`,
this.authState!
);
return data;
}
private async fetchSearchValues() {
try {
const { names, firstSeeds } = await this.apiFetch(
`/orgs/${this.orgId}/crawlconfigs/search-values`,
this.authState!
);
// Update search/filter collection
const toSearchItem =
(key: SearchFields) =>
(value: string): SearchResult["item"] => ({
key,
value,
});
this.fuse.setCollection([
...names.map(toSearchItem("name")),
...firstSeeds.map(toSearchItem("firstSeed")),
] as any);
} catch (e) {
console.debug(e);
}
}
}
customElements.define("btrix-collection-editor", CollectionEditor);