diff --git a/.vscode/snippets.code-snippets b/.vscode/snippets.code-snippets index d1c0d3d6b8..01deede1af 100644 --- a/.vscode/snippets.code-snippets +++ b/.vscode/snippets.code-snippets @@ -72,11 +72,11 @@ "import { html } from \"lit\";", "import { ifDefined } from \"lit/directives/if-defined.js\";", "", - "import type { ${1:Component} } from \"@/${2:directory}/${3:component}\";", + "import type { ${TM_FILENAME_BASE} } from \"@/${2:directory}/${3:component}\";", "", "import \"@/${2:directory}/${3:component}\";", "", - "export type RenderProps = ${1:Component};", + "export type RenderProps = ${TM_FILENAME_BASE};", "", "export const renderComponent = (props: Partial) => {", " return html``;", diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts index 5bb3470e03..06523d5ba1 100644 --- a/frontend/.storybook/main.ts +++ b/frontend/.storybook/main.ts @@ -18,6 +18,7 @@ const config: StorybookConfig = { to: shoelaceAssetsPublicPath, }, { from: "../src/assets/", to: "/assets" }, + "../src/__generated__/static", ], addons: [ "@storybook/addon-webpack5-compiler-swc", diff --git a/frontend/.storybook/preview.ts b/frontend/.storybook/preview.ts index d4fc17c56f..0a8b5e20b8 100644 --- a/frontend/.storybook/preview.ts +++ b/frontend/.storybook/preview.ts @@ -5,6 +5,8 @@ import { setCustomElementsManifest, type Preview, } from "@storybook/web-components"; +import { delay, http, HttpResponse } from "msw"; +import { initialize, mswLoader } from "msw-storybook-addon"; // eslint-disable-next-line import-x/no-unresolved -- File is generated at build time import customElements from "@/__generated__/custom-elements.json"; @@ -14,7 +16,11 @@ import "../src/theme.stylesheet.css"; // Automatically document component properties setCustomElementsManifest(customElements); +// Initialize mock service worker +initialize(); + const preview: Preview = { + loaders: [mswLoader], parameters: { actions: { argTypesRegex: "^on[A-Z].*" }, controls: { @@ -24,6 +30,15 @@ const preview: Preview = { date: /Date$/i, }, }, + msw: { + handlers: [ + // Mock all API requests by default + http.get(/\/api\//, async () => { + await delay(500); + return new HttpResponse(null); + }), + ], + }, }, }; diff --git a/frontend/package.json b/frontend/package.json index 2bf47b19fa..3cfae80ea9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -141,6 +141,8 @@ "concurrently": "^9.1.2", "husky": "^8.0.3", "lint-staged": "^13.1.0", + "msw": "^2.12.4", + "msw-storybook-addon": "^2.0.6", "prettier-plugin-tailwindcss": "^0.5.12", "remark-gfm": "^4.0.1", "rollup-plugin-typescript-paths": "^1.4.0", @@ -179,5 +181,10 @@ "browserslist": [ "defaults" ], - "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e", + "msw": { + "workerDirectory": [ + "src/__generated__/static" + ] + } } diff --git a/frontend/src/__generated__/static/mockServiceWorker.js b/frontend/src/__generated__/static/mockServiceWorker.js new file mode 100644 index 0000000000..558540fa57 --- /dev/null +++ b/frontend/src/__generated__/static/mockServiceWorker.js @@ -0,0 +1,349 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + */ + +const PACKAGE_VERSION = '2.12.4' +const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') +const activeClientIds = new Set() + +addEventListener('install', function () { + self.skipWaiting() +}) + +addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +addEventListener('message', async function (event) { + const clientId = Reflect.get(event.source || {}, 'id') + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +addEventListener('fetch', function (event) { + const requestInterceptedAt = Date.now() + + // Bypass navigation requests. + if (event.request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if ( + event.request.cache === 'only-if-cached' && + event.request.mode !== 'same-origin' + ) { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been terminated (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + const requestId = crypto.randomUUID() + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)) +}) + +/** + * @param {FetchEvent} event + * @param {string} requestId + * @param {number} requestInterceptedAt + */ +async function handleRequest(event, requestId, requestInterceptedAt) { + const client = await resolveMainClient(event) + const requestCloneForEvents = event.request.clone() + const response = await getResponse( + event, + client, + requestId, + requestInterceptedAt, + ) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + const serializedRequest = await serializeRequest(requestCloneForEvents) + + // Clone the response so both the client and the library could consume it. + const responseClone = response.clone() + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + isMockedResponse: IS_MOCKED_RESPONSE in response, + request: { + id: requestId, + ...serializedRequest, + }, + response: { + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + headers: Object.fromEntries(responseClone.headers.entries()), + body: responseClone.body, + }, + }, + }, + responseClone.body ? [serializedRequest.body, responseClone.body] : [], + ) + } + + return response +} + +/** + * Resolve the main client for the given event. + * Client that issues a request doesn't necessarily equal the client + * that registered the worker. It's with the latter the worker should + * communicate with during the response resolving phase. + * @param {FetchEvent} event + * @returns {Promise} + */ +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (activeClientIds.has(event.clientId)) { + return client + } + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +/** + * @param {FetchEvent} event + * @param {Client | undefined} client + * @param {string} requestId + * @param {number} requestInterceptedAt + * @returns {Promise} + */ +async function getResponse(event, client, requestId, requestInterceptedAt) { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = event.request.clone() + + function passthrough() { + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers) + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get('accept') + if (acceptHeader) { + const values = acceptHeader.split(',').map((value) => value.trim()) + const filteredValues = values.filter( + (value) => value !== 'msw/passthrough', + ) + + if (filteredValues.length > 0) { + headers.set('accept', filteredValues.join(', ')) + } else { + headers.delete('accept') + } + } + + return fetch(requestClone, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const serializedRequest = await serializeRequest(event.request) + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + interceptedAt: requestInterceptedAt, + ...serializedRequest, + }, + }, + [serializedRequest.body], + ) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'PASSTHROUGH': { + return passthrough() + } + } + + return passthrough() +} + +/** + * @param {Client} client + * @param {any} message + * @param {Array} transferrables + * @returns {Promise} + */ +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage(message, [ + channel.port2, + ...transferrables.filter(Boolean), + ]) + }) +} + +/** + * @param {Response} response + * @returns {Response} + */ +function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error() + } + + const mockedResponse = new Response(response.body, response) + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }) + + return mockedResponse +} + +/** + * @param {Request} request + */ +async function serializeRequest(request) { + return { + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.arrayBuffer(), + keepalive: request.keepalive, + } +} diff --git a/frontend/src/components/ui/data-grid/data-grid.ts b/frontend/src/components/ui/data-grid/data-grid.ts index 636232aa66..767a5f1107 100644 --- a/frontend/src/components/ui/data-grid/data-grid.ts +++ b/frontend/src/components/ui/data-grid/data-grid.ts @@ -149,9 +149,14 @@ export class DataGrid< return html` - + ${this.formControlLabel + ? html`` + : nothing}
{ + if (!this.href) return; + return window.location.pathname.split("#")[0] === this.href.split("#")[0]; + }; + return html` {} : this.navigate.link} + part="base" > ${this.hideIcon diff --git a/frontend/src/features/archived-items/archived-item-list/archived-item-list-item.ts b/frontend/src/features/archived-items/archived-item-list/archived-item-list-item.ts index 7cb3da3cd6..223117c0fc 100644 --- a/frontend/src/features/archived-items/archived-item-list/archived-item-list-item.ts +++ b/frontend/src/features/archived-items/archived-item-list/archived-item-list-item.ts @@ -4,6 +4,8 @@ import { css, html, nothing } from "lit"; import { customElement, property, query } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; +import { dedupeStatusIcon } from "../templates/dedupe-status-icon"; + import type { ArchivedItemCheckedEvent } from "./types"; import { BtrixElement } from "@/classes/BtrixElement"; @@ -11,6 +13,7 @@ import { CrawlStatus } from "@/features/archived-items/crawl-status"; import { ReviewStatus, type ArchivedItem, type Crawl } from "@/types/crawler"; import { renderName } from "@/utils/crawler"; import { pluralOf } from "@/utils/pluralize"; +import { tw } from "@/utils/tailwind"; /** * @slot actionCell - Action cell @@ -104,7 +107,7 @@ export class ArchivedItemListItem extends BtrixElement { return html` ${this.checkbox @@ -146,7 +149,7 @@ export class ArchivedItemListItem extends BtrixElement { hoist > `} + ${dedupeStatusIcon(this.item)} `; - label = msg("Waiting"); + label = + originalState === "waiting_dedupe_index" + ? msg("Indexing Dedupe Source") + : msg("Waiting"); reason = originalState === "waiting_capacity" ? msg("At Capacity") - : msg("At Crawl Limit"); + : originalState === "waiting_org_limit" + ? msg("At Crawl Limit") + : ""; break; case "running": diff --git a/frontend/src/features/archived-items/index.ts b/frontend/src/features/archived-items/index.ts index c6e1989cb2..1dd2e3e26b 100644 --- a/frontend/src/features/archived-items/index.ts +++ b/frontend/src/features/archived-items/index.ts @@ -8,5 +8,6 @@ import("./crawl-pending-exclusions"); import("./crawl-queue"); import("./crawl-status"); import("./file-uploader"); +import("./item-dependency-tree"); import("./item-list-controls"); import("./upload-status"); diff --git a/frontend/src/features/archived-items/item-dependency-tree/index.ts b/frontend/src/features/archived-items/item-dependency-tree/index.ts new file mode 100644 index 0000000000..c0b3a9df29 --- /dev/null +++ b/frontend/src/features/archived-items/item-dependency-tree/index.ts @@ -0,0 +1 @@ +import "./item-dependency-tree"; diff --git a/frontend/src/features/archived-items/item-dependency-tree/item-dependency-tree.stylesheet.css b/frontend/src/features/archived-items/item-dependency-tree/item-dependency-tree.stylesheet.css new file mode 100644 index 0000000000..1bb8e9a505 --- /dev/null +++ b/frontend/src/features/archived-items/item-dependency-tree/item-dependency-tree.stylesheet.css @@ -0,0 +1,93 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer components { + sl-tree { + --indent-guide-width: 1px; + --indent-guide-color: var(--sl-color-neutral-300); + } + + /* Reset selected styles */ + sl-tree-item::part(item--selected) { + border-inline-start-color: transparent; + background: transparent; + } + + sl-tree-item::part(item) { + border-inline-start-width: 0; + } + + sl-tree-item::part(label) { + @apply w-full; + } + + sl-tree-item:focus { + @apply bg-cyan-50/50; + } + + sl-tree-item:hover .component--detail, + sl-tree-item:focus .component--detail, + sl-tree-item:hover .component--detail sl-icon, + sl-tree-item:focus .component--detail sl-icon { + @apply text-cyan-600; + } + + sl-tree-item::part(children)::before { + left: calc(1rem - (var(--indent-guide-width) / 2)); + bottom: calc(1rem + var(--sl-border-radius-medium)); + } + + .component--row { + grid-template-columns: + 1rem minmax(16rem, 1fr) minmax(8rem, 12rem) repeat( + 2, + minmax(11rem, 13rem) + ) + minmax(6rem, 8rem) + 2rem; + @apply grid w-full items-center gap-2; + } + + .component--dependency::part(indentation) { + @apply w-3; + } + + .component--dependency::part(base)::before { + content: ""; + width: 1.25rem; + left: calc(1rem - (var(--indent-guide-width) / 2)); + top: calc(1rem - var(--indent-guide-width)); + height: var(--sl-border-radius-medium); + border-left: var(--indent-guide-width) var(--indent-guide-style) + var(--indent-guide-color); + border-bottom: var(--indent-guide-width) var(--indent-guide-style) + var(--indent-guide-color); + + @apply absolute rounded-bl-lg; + } + + .component--dependency::part(label) { + @apply relative; + } + + .component--content { + @apply h-9; + } + + .component--detail { + @apply flex items-center gap-1.5 truncate; + } + + .component--detail sl-icon { + @apply text-neutral-500; + } + + .component--notInCollection .component--detail { + @apply opacity-75; + } + + .component--withHeader .component--detail sl-icon { + @apply hidden; + } +} diff --git a/frontend/src/features/archived-items/item-dependency-tree/item-dependency-tree.ts b/frontend/src/features/archived-items/item-dependency-tree/item-dependency-tree.ts new file mode 100644 index 0000000000..2cab217422 --- /dev/null +++ b/frontend/src/features/archived-items/item-dependency-tree/item-dependency-tree.ts @@ -0,0 +1,307 @@ +import { localized, msg } from "@lit/localize"; +import { Task } from "@lit/task"; +import type { SlTree, SlTreeItem } from "@shoelace-style/shoelace"; +import clsx from "clsx"; +import { html, nothing, unsafeCSS } from "lit"; +import { customElement, property, query } from "lit/decorators.js"; +import { repeat } from "lit/directives/repeat.js"; +import { until } from "lit/directives/until.js"; +import queryString from "query-string"; + +import stylesheet from "./item-dependency-tree.stylesheet.css"; + +import { BtrixElement } from "@/classes/BtrixElement"; +import { dedupeIconFor } from "@/features/collections/dedupe-badge"; +import type { ArchivedItemSectionName } from "@/pages/org/archived-item-detail/archived-item-detail"; +import { OrgTab, WorkflowTab } from "@/routes"; +import { noData } from "@/strings/ui"; +import type { APIPaginatedList } from "@/types/api"; +import type { Crawl } from "@/types/crawler"; +import { renderName } from "@/utils/crawler"; +import { pluralOf } from "@/utils/pluralize"; +import { tw } from "@/utils/tailwind"; + +const styles = unsafeCSS(stylesheet); + +// FIXME Sometimes the API returns circular dependencies +const dependenciesWithoutSelf = (item: Crawl) => + item.requiresCrawls.filter((id) => id !== item.id); + +@customElement("btrix-item-dependency-tree") +@localized() +export class ItemDependencyTree extends BtrixElement { + static styles = styles; + + @property({ type: String }) + collectionId?: string; + + @property({ type: Array }) + items?: Crawl[]; + + @property({ type: Boolean }) + showHeader = false; + + @query("sl-tree") + private readonly tree?: SlTree | null; + + private readonly timerIds: number[] = []; + + private readonly dependenciesMap = new Map< + string, + Crawl | Promise + >(); + + private readonly dependenciesTask = new Task(this, { + task: async ([items], { signal }) => { + if (!items?.length) return; + + const itemsMap = new Map(items.map((item) => [item.id, item])); + const newIds: string[] = []; + + items.forEach((item) => { + dependenciesWithoutSelf(item).forEach((id) => { + if (!this.dependenciesMap.get(id)) { + const cachedItem = itemsMap.get(id); + if (cachedItem) { + this.dependenciesMap.set(id, cachedItem); + } else { + newIds.push(id); + } + } + }); + }); + + const query = queryString.stringify( + { + ids: newIds, + }, + { + arrayFormat: "none", + }, + ); + + const request = this.api.fetch>( + `/orgs/${this.orgId}/crawls?${query}`, + { signal }, + ); + + newIds.forEach((id) => { + this.dependenciesMap.set( + id, + request.then(({ items }) => items.find((item) => item.id === id)), + ); + }); + + return request; + }, + args: () => [this.items] as const, + }); + + disconnectedCallback(): void { + this.timerIds.forEach(window.clearTimeout); + super.disconnectedCallback(); + } + + render() { + if (!this.items?.length) return; + + return html` + ${this.showHeader + ? html`
+
+ ${msg("Status")} +
+
${msg("Name")}
+
${msg("Dependencies")}
+
${msg("Date Started")}
+
${msg("Date Finished")}
+
${msg("Size")}
+
+ ${msg("Actions")} +
+
` + : nothing} + + ${repeat(this.items, ({ id }) => id, this.renderItem)} + + `; + } + + private readonly renderItem = (item: Crawl) => { + const dependencies = dependenciesWithoutSelf(item); + const hasDependencies = dependencies.length; + + return html` + + ${this.renderContent(item)} + ${hasDependencies ? dependencies.map(this.renderDependency) : nothing} + + `; + }; + + private readonly renderDependency = (id: string) => { + const skeleton = () => html` +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ `; + const noItem = () => html` +
+ + + +
+ ${msg("Missing item with ID")} ${id} +
+
+ `; + const item = this.dependenciesMap.get(id); + + return html` { + const item = this.tree?.querySelector(`#${id}`); + + if (item) { + item.scrollIntoView({ behavior: "smooth" }); + item.focus(); + } + }} + > + ${item + ? until( + Promise.resolve(item).then((item) => + item ? this.renderContent(item) : noItem(), + ), + skeleton(), + ) + : skeleton()} + `; + }; + + private readonly renderContent = (item: Crawl) => { + const dependencies = dependenciesWithoutSelf(item); + const collectionId = this.collectionId; + const inCollection = collectionId + ? item.collectionIds.includes(collectionId) + : item.dedupeCollId && item.collectionIds.includes(item.dedupeCollId); + + const status = () => { + let icon = "dash-circle"; + let variant = tw`text-neutral-400`; + let tooltip = msg("Not in Collection"); + + if (inCollection) { + icon = "check-circle"; + variant = tw`text-cyan-500`; + + if (collectionId) { + tooltip = msg("In Same Collection"); + } else { + tooltip = msg("In Collection"); + } + } + + return html` + + `; + }; + + const date = (value: string) => + this.localize.date(value, { + month: "2-digit", + year: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); + + return html`
+ ${status()} +
${renderName(item)}
+
+ + + + ${this.localize.number(dependencies.length)} + ${pluralOf( + this.showHeader ? "items" : "dependencies", + dependencies.length, + )} +
+
+ + + + ${date(item.started)} +
+
+ + + + ${item.finished ? date(item.finished) : noData} +
+
+ + + + ${this.localize.bytes(item.fileSize || 0, { unitDisplay: "short" })} +
+ ${this.renderLink( + `${this.navigate.orgBasePath}/${OrgTab.Workflows}/${item.cid}/${WorkflowTab.Crawls}/${item.id}#${"overview" as ArchivedItemSectionName}`, + )} +
`; + }; + + private renderLink(href: string) { + return html` + `; + } +} diff --git a/frontend/src/features/archived-items/templates/dedupe-files-notice.ts b/frontend/src/features/archived-items/templates/dedupe-files-notice.ts new file mode 100644 index 0000000000..5139f3e644 --- /dev/null +++ b/frontend/src/features/archived-items/templates/dedupe-files-notice.ts @@ -0,0 +1,56 @@ +import { msg } from "@lit/localize"; +import { html } from "lit"; +import { when } from "lit/directives/when.js"; + +export function dedupeFilesNotice({ + dependenciesHref, + collectionHref, +}: { dependenciesHref?: string; collectionHref?: string } = {}) { + return html` +
+ + + + ${msg("This crawl is dependent on other crawls.")} + + + ${when( + dependenciesHref, + (href) => + html`${msg("View Dependencies")}`, + )} +
+
+

+ ${msg( + "Files may contain incomplete or missing content due to deduplication.", + )} +

+ + ${when( + collectionHref, + (href) => html` +

+ ${msg( + "Download the collection for complete and deduplicated files.", + )} + ${msg("Go to Collection")} +

+ `, + )} +
+
`; +} diff --git a/frontend/src/features/archived-items/templates/dedupe-qa-notice.ts b/frontend/src/features/archived-items/templates/dedupe-qa-notice.ts new file mode 100644 index 0000000000..d9fb416f8a --- /dev/null +++ b/frontend/src/features/archived-items/templates/dedupe-qa-notice.ts @@ -0,0 +1,38 @@ +import { msg } from "@lit/localize"; +import { html } from "lit"; +import { when } from "lit/directives/when.js"; + +export function dedupeQANotice({ + dependenciesHref, +}: { dependenciesHref?: string } = {}) { + return html` +
+ + + + ${msg("This crawl is dependent on other crawls.")} + + + ${when( + dependenciesHref, + (href) => + html`${msg("View Dependencies")}`, + )} +
+
+

+ ${msg( + "Quality assurance tools are not currently supported for deduplication dependent crawls.", + )} +

+
+
`; +} diff --git a/frontend/src/features/archived-items/templates/dedupe-replay-notice.ts b/frontend/src/features/archived-items/templates/dedupe-replay-notice.ts new file mode 100644 index 0000000000..1c291dd869 --- /dev/null +++ b/frontend/src/features/archived-items/templates/dedupe-replay-notice.ts @@ -0,0 +1,62 @@ +import { msg } from "@lit/localize"; +import { html } from "lit"; +import { when } from "lit/directives/when.js"; + +import { tw } from "@/utils/tailwind"; + +export function dedupeReplayNotice({ + dependenciesHref, + collectionHref, + topClass, +}: { + dependenciesHref?: string; + collectionHref?: string; + topClass?: string; +} = {}) { + return html` +
+ + + + ${msg("This crawl is dependent on other crawls.")} + + + ${when( + dependenciesHref, + (href) => + html`${msg("View Dependencies")}`, + )} +
+
+

+ ${msg( + "Replay for this crawl may contain incomplete or missing pages due to its dependency of the deduplication source.", + )} +

+ ${when( + collectionHref, + (href) => html` +

+ ${msg( + "View the collection to replay the complete and deduplicated crawl.", + )} + ${msg("Go to Collection")} +

+ `, + )} +
+
`; +} diff --git a/frontend/src/features/archived-items/templates/dedupe-status-icon.ts b/frontend/src/features/archived-items/templates/dedupe-status-icon.ts new file mode 100644 index 0000000000..c3d7e7ec32 --- /dev/null +++ b/frontend/src/features/archived-items/templates/dedupe-status-icon.ts @@ -0,0 +1,43 @@ +import { msg } from "@lit/localize"; +import clsx from "clsx"; +import { html } from "lit"; + +import { + dedupeIconFor, + dedupeLabelFor, +} from "@/features/collections/dedupe-badge"; +import type { ArchivedItem } from "@/types/crawler"; +import { isCrawl } from "@/utils/crawler"; +import { tw } from "@/utils/tailwind"; + +export function dedupeStatusIcon(item: ArchivedItem) { + const hasDependents = isCrawl(item) && item.requiredByCrawls.length; + const hasDependencies = isCrawl(item) && item.requiresCrawls.length; + const dedupeEnabled = hasDependents || hasDependencies; + + let tooltip = msg("No Dependencies"); + let icon = "layers"; + + if (hasDependents && hasDependencies) { + tooltip = dedupeLabelFor.both; + icon = dedupeIconFor.both; + } else if (hasDependencies) { + tooltip = dedupeLabelFor.dependent; + icon = dedupeIconFor.dependent; + } else if (hasDependents) { + tooltip = dedupeLabelFor.dependency; + icon = dedupeIconFor.dependency; + } + + return html` + + + + `; +} diff --git a/frontend/src/features/collections/dedupe-badge.ts b/frontend/src/features/collections/dedupe-badge.ts new file mode 100644 index 0000000000..c2498ba5c2 --- /dev/null +++ b/frontend/src/features/collections/dedupe-badge.ts @@ -0,0 +1,78 @@ +import { localized, msg, str } from "@lit/localize"; +import { css, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +import { TailwindElement } from "@/classes/TailwindElement"; +import localize from "@/utils/localize"; +import { pluralOf } from "@/utils/pluralize"; + +export const dedupeIconFor = { + dependent: "layers-fill", + dependency: "layers-half", + both: "layers-half", +} as const; + +export const dedupeLabelFor = { + dependent: msg("Dependent"), + dependency: msg("Dependency"), + both: msg("Dependent"), +} as const; + +@customElement("btrix-dedupe-badge") +@localized() +export class DedupeBadge extends TailwindElement { + static styles = css` + :host { + display: contents; + } + `; + + @property({ type: Array }) + dependents?: string[] = []; + + @property({ type: Array }) + dependencies?: string[] = []; + + render() { + const dependentsCount = this.dependents?.length; + const dependenciesCount = this.dependencies?.length; + + if (!dependentsCount && !dependenciesCount) return; + + let tooltip = ""; + let icon: string = dedupeIconFor.both; + let text: string = dedupeLabelFor.both; + + if (dependentsCount && dependenciesCount) { + const number_of_dependent_crawls = `${localize.number(dependentsCount)} ${pluralOf("crawls", dependentsCount)}`; + const number_of_dependency_crawls = `${localize.number(dependenciesCount)} ${pluralOf("crawls", dependenciesCount)}`; + + tooltip = msg( + str`This crawl is a dependency of ${number_of_dependent_crawls} and is dependent on ${number_of_dependency_crawls} in the deduplication source.`, + ); + } else if (dependenciesCount) { + const number_of_dependency_crawls = `${localize.number(dependenciesCount)} ${pluralOf("crawls", dependenciesCount)}`; + + tooltip = msg( + str`This crawl is dependent on ${number_of_dependency_crawls}.`, + ); + icon = dedupeIconFor.dependent; + text = dedupeLabelFor.dependent; + } else if (dependentsCount) { + const number_of_dependent_crawls = `${localize.number(dependentsCount)} ${pluralOf("crawls", dependentsCount)}`; + + tooltip = msg( + str`This crawl is a dependency of ${number_of_dependent_crawls}.`, + ); + icon = dedupeIconFor.dependency; + text = dedupeLabelFor.dependency; + } + + return html` + + + ${text} + + `; + } +} diff --git a/frontend/src/features/collections/dedupe-source-badge.ts b/frontend/src/features/collections/dedupe-source-badge.ts new file mode 100644 index 0000000000..c43e275104 --- /dev/null +++ b/frontend/src/features/collections/dedupe-source-badge.ts @@ -0,0 +1,27 @@ +import { localized, msg } from "@lit/localize"; +import { css, html } from "lit"; +import { customElement } from "lit/decorators.js"; + +import { TailwindElement } from "@/classes/TailwindElement"; + +@customElement("btrix-dedupe-source-badge") +@localized() +export class DedupeSourceBadge extends TailwindElement { + static styles = css` + :host { + display: contents; + } + `; + + render() { + return html` + + + ${msg("Source")} + + `; + } +} diff --git a/frontend/src/features/collections/dedupe-workflows/dedupe-workflows.stylesheet.css b/frontend/src/features/collections/dedupe-workflows/dedupe-workflows.stylesheet.css new file mode 100644 index 0000000000..50ad7ade6e --- /dev/null +++ b/frontend/src/features/collections/dedupe-workflows/dedupe-workflows.stylesheet.css @@ -0,0 +1,34 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer components { + :host { + --border: 1px solid var(--sl-panel-border-color); + } + + .component--row { + grid-template-columns: repeat(2, minmax(12rem, 1fr)) 12rem 2rem; + @apply grid w-full items-center gap-2; + } + + sl-details::part(base) { + @apply border-0; + } + + sl-details::part(summary-icon) { + @apply order-first ml-2 mr-2.5; + } + + sl-details::part(header) { + @apply h-10 p-0 transition-shadow duration-fast; + } + + sl-details::part(content) { + @apply p-0; + } + + sl-details[open]::part(header) { + @apply shadow-sm; + } +} diff --git a/frontend/src/features/collections/dedupe-workflows/dedupe-workflows.ts b/frontend/src/features/collections/dedupe-workflows/dedupe-workflows.ts new file mode 100644 index 0000000000..a9d8671ee7 --- /dev/null +++ b/frontend/src/features/collections/dedupe-workflows/dedupe-workflows.ts @@ -0,0 +1,264 @@ +import { localized, msg } from "@lit/localize"; +import { Task } from "@lit/task"; +import clsx from "clsx"; +import { html, nothing, unsafeCSS } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; +import { repeat } from "lit/directives/repeat.js"; +import { until } from "lit/directives/until.js"; +import { when } from "lit/directives/when.js"; +import queryString from "query-string"; + +import stylesheet from "./dedupe-workflows.stylesheet.css"; + +import { BtrixElement } from "@/classes/BtrixElement"; +import { OrgTab } from "@/routes"; +import type { APIPaginatedList } from "@/types/api"; +import type { Crawl, ListWorkflow } from "@/types/crawler"; +import { SortDirection } from "@/types/utils"; +import { finishedCrawlStates, renderName } from "@/utils/crawler"; +import { pluralOf } from "@/utils/pluralize"; +import { tw } from "@/utils/tailwind"; + +const INITIAL_PAGE_SIZE = 1000; + +const styles = unsafeCSS(stylesheet); + +@customElement("btrix-dedupe-workflows") +@localized() +export class DedupeWorkflows extends BtrixElement { + static styles = styles; + + @property({ type: Array }) + workflows?: ListWorkflow[]; + + @property({ type: Boolean }) + showHeader = false; + + @state() + private workflowCrawlsMap = new Map< + /* workflow ID: */ string, + Promise | undefined> + >(); + + private readonly workflowCrawlsTask = new Task(this, { + task: async ([workflows], { signal }) => { + if (!workflows) return; + + // Preload crawls + workflows.forEach(({ id, dedupeCollId, crawlSuccessfulCount }) => { + if (!this.workflowCrawlsMap.get(id)) { + this.workflowCrawlsMap.set( + id, + crawlSuccessfulCount && dedupeCollId + ? this.getCrawls( + { + workflowId: id, + dedupeCollId, + }, + signal, + ) + : Promise.resolve(undefined), + ); + } + }); + }, + args: () => [this.workflows] as const, + }); + + render() { + return html` + ${this.showHeader + ? html`
+
${msg("Workflow Name")}
+
${msg("Crawl Runs")}
+
${msg("Total Size")}
+
+ ${msg("Actions")} +
+
` + : nothing} + +
+ ${repeat(this.workflows || [], ({ id }) => id, this.renderWorkflow)} +
+
`; + } + + private readonly renderWorkflow = (workflow: ListWorkflow) => { + const totalCrawls = workflow.crawlSuccessfulCount; + // TOOD Virtualize scroll + const content = () => html` +
+
+ ${until( + this.workflowCrawlsMap + .get(workflow.id) + ?.then((crawls) => + crawls?.total + ? html`${this.localize.number(crawls.total)} ${msg("indexed")} + ${pluralOf("crawls", crawls.total)}` + : msg("No indexed crawls"), + ), + )} +
+ + ${until( + this.workflowCrawlsMap + .get(workflow.id) + ?.then((crawls) => this.renderCrawls(workflow, crawls)), + html`
+ ${Array.from({ length: totalCrawls }).map( + () => html` + + `, + )} +
`, + )} +
+ `; + + return html` + { + if (!this.workflowCrawlsMap.get(workflow.id)) { + this.workflowCrawlsMap.set( + workflow.id, + workflow.dedupeCollId + ? this.getCrawls({ + workflowId: workflow.id, + dedupeCollId: workflow.dedupeCollId, + }) + : Promise.resolve(undefined), + ); + this.workflowCrawlsMap = new Map(this.workflowCrawlsMap); + } + }} + ?disabled=${!totalCrawls} + > +
+
+ + + + ${renderName(workflow)} +
+
+ + + + ${this.localize.number(totalCrawls)} ${msg("crawl")} + ${pluralOf("runs", totalCrawls)} +
+
+ + + + ${this.localize.bytes(workflow.totalSize ? +workflow.totalSize : 0)} +
+
+ e.stopPropagation()} + > + + ${when( + this.appState.isCrawler, + () => html` + + + ${msg("Edit Workflow Settings")} + + + `, + )} + + + ${msg("Go to Workflow")} + + + +
+
+ + ${when(totalCrawls, content)} +
+ `; + }; + + private readonly renderCrawls = ( + workflow: ListWorkflow, + crawls?: APIPaginatedList, + ) => { + return html`
+ ${when( + crawls?.items, + (items) => + html``, + )} +
`; + }; + + private async getCrawls( + { + workflowId, + ...params + }: { + workflowId: string; + dedupeCollId: string; + }, + signal?: AbortSignal, + ) { + const query = queryString.stringify( + { + cid: workflowId, + pageSize: INITIAL_PAGE_SIZE, + sortBy: "started", + sortDirection: SortDirection.Descending, + state: finishedCrawlStates, + ...params, + }, + { + arrayFormat: "comma", + }, + ); + + try { + return await this.api.fetch>( + `/orgs/${this.orgId}/crawls?${query}`, + { signal }, + ); + } catch (err) { + console.debug(err); + } + } +} diff --git a/frontend/src/features/collections/dedupe-workflows/index.ts b/frontend/src/features/collections/dedupe-workflows/index.ts new file mode 100644 index 0000000000..373b3e100a --- /dev/null +++ b/frontend/src/features/collections/dedupe-workflows/index.ts @@ -0,0 +1 @@ +import "./dedupe-workflows"; diff --git a/frontend/src/features/collections/index.ts b/frontend/src/features/collections/index.ts index 2fd3fbe5fe..7a0a3638e1 100644 --- a/frontend/src/features/collections/index.ts +++ b/frontend/src/features/collections/index.ts @@ -7,6 +7,9 @@ import("./collection-edit-dialog"); import("./collection-create-dialog"); import("./collection-initial-view-dialog"); import("./collection-workflow-list"); +import("./dedupe-badge"); +import("./dedupe-source-badge"); +import("./dedupe-workflows/dedupe-workflows"); import("./linked-collections"); import("./select-collection-access"); import("./select-collection-page"); diff --git a/frontend/src/features/collections/linked-collections/linked-collections-list-item.ts b/frontend/src/features/collections/linked-collections/linked-collections-list-item.ts index 6a2d039426..8317c4a619 100644 --- a/frontend/src/features/collections/linked-collections/linked-collections-list-item.ts +++ b/frontend/src/features/collections/linked-collections/linked-collections-list-item.ts @@ -10,6 +10,7 @@ import type { import { isActualCollection } from "./utils"; import { TailwindElement } from "@/classes/TailwindElement"; +import { NavigateController } from "@/controllers/navigate"; import { pluralOf } from "@/utils/pluralize"; import { tw } from "@/utils/tailwind"; @@ -31,6 +32,8 @@ export class LinkedCollectionsListItem extends TailwindElement { @property({ type: Boolean }) loading = false; + private readonly navigate = new NavigateController(this); + render() { const item = this.item; @@ -45,38 +48,29 @@ export class LinkedCollectionsListItem extends TailwindElement { >
${item.name}
${dedupeEnabled - ? html` - ${msg("Dedupe Source")} - ` + ? html`` : nothing}
`, ]; if (actual) { content.push( - html`
- ${item.crawlCount} - ${pluralOf("items", item.crawlCount)} -
`, + html`${item.crawlCount} ${pluralOf("items", item.crawlCount)}`, ); } if (this.baseUrl) { content.push( html`
- - - - +
`, ); } diff --git a/frontend/src/features/crawl-workflows/workflow-action-menu/workflow-action-menu.ts b/frontend/src/features/crawl-workflows/workflow-action-menu/workflow-action-menu.ts index a93b1ddbf9..3073b50f1b 100644 --- a/frontend/src/features/crawl-workflows/workflow-action-menu/workflow-action-menu.ts +++ b/frontend/src/features/crawl-workflows/workflow-action-menu/workflow-action-menu.ts @@ -180,7 +180,7 @@ export class WorkflowActionMenu extends BtrixElement { ClipboardController.copyToClipboard(workflow.firstSeed)} > - + ${msg("Copy Crawl Start URL")} diff --git a/frontend/src/features/crawl-workflows/workflow-list.ts b/frontend/src/features/crawl-workflows/workflow-list.ts index aabf0e04b5..2e240845d7 100644 --- a/frontend/src/features/crawl-workflows/workflow-list.ts +++ b/frontend/src/features/crawl-workflows/workflow-list.ts @@ -170,10 +170,6 @@ export class WorkflowListItem extends BtrixElement { box-shadow: var(--sl-shadow-x-small); } - .row:hover { - box-shadow: var(--sl-shadow-small); - } - .col { padding-top: var(--sl-spacing-small); padding-bottom: var(--sl-spacing-small); @@ -198,6 +194,10 @@ export class WorkflowListItem extends BtrixElement { height: 1rem; } + .desc > sl-icon { + font-size: var(--sl-font-size-small); + } + .notSpecified { color: var(--sl-color-neutral-400); } @@ -314,17 +314,41 @@ export class WorkflowListItem extends BtrixElement { ${when(this.workflow?.shareable, ShareableNotice)} ${this.safeRender(this.renderName)}
-
+
${this.safeRender((workflow) => { + const badges = []; if (workflow.schedule) { - return humanizeSchedule(workflow.schedule, { - length: "short", - }); + badges.push(html` +
+ + ${humanizeSchedule(workflow.schedule, { + length: "short", + })} +
+ `); + } else if (workflow.lastStartedByName) { + badges.push(html` +
+ + ${msg("Manual run")} +
+ `); } - if (workflow.lastStartedByName) { - return msg(str`Manual run by ${workflow.lastStartedByName}`); + if (workflow.dedupeCollId) { + badges.push(html` +
+ + ${msg("Dedupe enabled")} +
+ `); } - return msg("---"); + return html`${badges.length ? badges : noData}`; })}
`; @@ -362,18 +386,19 @@ export class WorkflowListItem extends BtrixElement { })} `; } - if (workflow.totalSize) { - return this.localize.bytes(+workflow.totalSize, { + return this.localize.bytes( + workflow.totalSize ? +workflow.totalSize : 0, + { unitDisplay: "narrow", - }); - } - return notSpecified; + }, + ); })}
- ${this.safeRender( - (workflow) => - `${this.localize.number(workflow.crawlCount, { notation: "compact" })} ${pluralOf("crawls", workflow.crawlCount)}`, + ${this.safeRender((workflow) => + workflow.crawlCount + ? `${this.localize.number(workflow.crawlCount, { notation: "compact" })} ${pluralOf("crawls", workflow.crawlCount)}` + : notSpecified, )}
`, @@ -545,7 +570,9 @@ export class WorkflowListItem extends BtrixElement { }; private safeRender( - render: (workflow: ListWorkflow) => string | TemplateResult<1>, + render: ( + workflow: ListWorkflow, + ) => string | TemplateResult<1> | undefined | void, ) { if (!this.workflow) { return html``; @@ -644,7 +671,7 @@ export class WorkflowList extends LitElement { listItems!: WorkflowListItem[]; static ColumnTemplate = { - name: html`
${msg(html`Name & Schedule`)}
`, + name: html`
${msg(html`Name & Details`)}
`, "latest-crawl": html`
${msg("Latest Crawl")}
`, "total-crawls": html`
${msg("Total Size")}
`, modified: html`
${msg("Last Modified")}
`, diff --git a/frontend/src/pages/crawls.ts b/frontend/src/pages/crawls.ts index 7a6c66af47..6623af40d2 100644 --- a/frontend/src/pages/crawls.ts +++ b/frontend/src/pages/crawls.ts @@ -30,7 +30,7 @@ const sortableFields: Record< defaultDirection: "desc", }, fileSize: { - label: msg("File Size"), + label: msg("Size"), defaultDirection: "desc", }, }; diff --git a/frontend/src/pages/org/archived-item-detail/archived-item-detail.ts b/frontend/src/pages/org/archived-item-detail/archived-item-detail.ts index a2d2fc7e3e..e41f4412be 100644 --- a/frontend/src/pages/org/archived-item-detail/archived-item-detail.ts +++ b/frontend/src/pages/org/archived-item-detail/archived-item-detail.ts @@ -6,6 +6,7 @@ import { customElement, property, query, state } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; import { when } from "lit/directives/when.js"; import capitalize from "lodash/fp/capitalize"; +import queryString from "query-string"; import { badges, badgesSkeleton } from "./templates/badges"; @@ -13,27 +14,36 @@ import { BtrixElement } from "@/classes/BtrixElement"; import { type Dialog } from "@/components/ui/dialog"; import { ClipboardController } from "@/controllers/clipboard"; import type { CrawlMetadataEditor } from "@/features/archived-items/item-metadata-editor"; +import { dedupeFilesNotice } from "@/features/archived-items/templates/dedupe-files-notice"; +import { dedupeQANotice } from "@/features/archived-items/templates/dedupe-qa-notice"; +import { dedupeReplayNotice } from "@/features/archived-items/templates/dedupe-replay-notice"; +import { emptyMessage } from "@/layouts/emptyMessage"; import { pageBack, pageHeader, pageNav, type Breadcrumb, } from "@/layouts/pageHeader"; -import { OrgTab, WorkflowTab } from "@/routes"; +import { panelBody } from "@/layouts/panel"; +import { Tab as CollectionTab } from "@/pages/org/collection-detail/types"; +import { CommonTab, OrgTab, WorkflowTab } from "@/routes"; import type { APIPaginatedList } from "@/types/api"; import type { ArchivedItem, Crawl, CrawlConfig, + CrawlReplay, Seed, Workflow, } from "@/types/crawler"; import type { QARun } from "@/types/qa"; +import { SortDirection } from "@/types/utils"; import type { StorageSeedFile } from "@/types/workflow"; import { isApiError } from "@/utils/api"; import { isActive, isCrawl, + isCrawlReplay, isNotFailed, isSuccessfullyFinished, renderName, @@ -53,11 +63,14 @@ const SECTIONS = [ "files", "logs", "config", + "dependencies", ] as const; type SectionName = (typeof SECTIONS)[number]; const POLL_INTERVAL_SECONDS = 5; +export type { SectionName as ArchivedItemSectionName }; + /** * Detail page for an archived item (crawl or upload) or crawl run. * @@ -138,6 +151,7 @@ export class ArchivedItemDetail extends BtrixElement { files: msg("WACZ Files"), logs: msg("Logs"), config: msg("Crawl Settings"), + dependencies: msg("Dependencies"), }; private get listUrl(): string { @@ -158,6 +172,10 @@ export class ArchivedItemDetail extends BtrixElement { return `${new URL(window.location.href).pathname}/review/screenshots${this.mostRecentSuccessQARun ? `?qaRunId=${this.mostRecentSuccessQARun.id}` : ""}`; } + private get dependenciesUrl(): string { + return `${new URL(window.location.href).pathname}${window.location.search}#${"dependencies" satisfies SectionName}`; + } + private timerId?: number; private get hasFiles(): boolean | null { @@ -184,7 +202,7 @@ export class ArchivedItemDetail extends BtrixElement { private readonly seedFileTask = new Task(this, { task: async ([item], { signal }) => { if (!item) return; - if (!isCrawl(item)) return; + if (!isCrawlReplay(item)) return; if (!item.config.seedFileId) return null; return await this.getSeedFile(item.config.seedFileId, signal); @@ -192,8 +210,36 @@ export class ArchivedItemDetail extends BtrixElement { args: () => [this.item] as const, }); + private readonly dependenciesTask = new Task(this, { + task: async ([item], { signal }) => { + if (!item) return; + if (!isCrawlReplay(item)) return; + if (!item.requiresCrawls.length) return; + + const query = queryString.stringify( + { + ids: item.requiresCrawls, + sortBy: "started", + sortDirection: SortDirection.Descending, + }, + { + arrayFormat: "comma", + }, + ); + + return this.api.fetch>( + `/orgs/${this.orgId}/crawls?${query}`, + { signal }, + ); + }, + args: () => [this.item] as const, + }); + willUpdate(changedProperties: PropertyValues) { if (changedProperties.has("itemId") && this.itemId) { + if (changedProperties.get("itemId")) { + this.resetItem(); + } void this.fetchCrawl(); if (this.itemType === "crawl") { void this.fetchSeeds(); @@ -206,8 +252,11 @@ export class ArchivedItemDetail extends BtrixElement { } if ( (changedProperties.has("workflowId") && this.workflowId) || - (changedProperties.has("item") && this.item?.cid) + (!this.workflowId && changedProperties.has("item") && this.item?.cid) ) { + if (changedProperties.get("workflowId")) { + this.workflow = undefined; + } void this.fetchWorkflow(); } if (changedProperties.has("qaRuns")) { @@ -267,6 +316,22 @@ export class ArchivedItemDetail extends BtrixElement { } } + private resetItem() { + const hashValue = window.location.hash.slice(1); + if (SECTIONS.includes(hashValue as (typeof SECTIONS)[number])) { + this.activeTab = hashValue as SectionName; + } else { + this.activeTab = "overview"; + } + + this.item = undefined; + this.seeds = undefined; + this.isRunActive = false; + this.qaRuns = undefined; + this.mostRecentNonFailedQARun = undefined; + this.mostRecentSuccessQARun = undefined; + } + connectedCallback(): void { // Set initial active section based on URL #hash value const hash = window.location.hash.slice(1); @@ -313,6 +378,8 @@ export class ArchivedItemDetail extends BtrixElement { render() { const authToken = this.authState?.headers.Authorization.split(" ")[1]; const isSuccess = this.item && isSuccessfullyFinished(this.item); + const dedupeDependent = + this.item && isCrawl(this.item) && this.item.requiresCrawls.length; let sectionContent: string | TemplateResult<1> = ""; @@ -327,9 +394,14 @@ export class ArchivedItemDetail extends BtrixElement { html`${this.tabLabels.qa} `, )}
- ${when(this.qaRuns, this.renderQAHeader)} + ${when(!dedupeDependent && this.qaRuns, this.renderQAHeader)}
`, html` + ${dedupeDependent + ? dedupeQANotice({ + dependenciesHref: this.dependenciesUrl, + }) + : nothing} `, @@ -676,6 +752,15 @@ export class ArchivedItemDetail extends BtrixElement { iconLibrary: "default", icon: "file-code-fill", })} + ${this.item && + isCrawlReplay(this.item) && + this.item.requiresCrawls.length + ? renderNavItem({ + section: "dependencies", + iconLibrary: "default", + icon: "layers-fill", + }) + : nothing} `, )} @@ -838,6 +923,24 @@ export class ArchivedItemDetail extends BtrixElement { } private renderReplay() { + const dedupeCollId = + this.item && + isCrawl(this.item) && + this.item.requiresCrawls.length && + this.item.dedupeCollId; + + return html` + ${dedupeCollId + ? dedupeReplayNotice({ + dependenciesHref: this.dependenciesUrl, + collectionHref: `${this.navigate.orgBasePath}/${OrgTab.Collections}/${CommonTab.View}/${dedupeCollId}`, + }) + : nothing} +
${this.renderRWP()}
+ `; + } + + private renderRWP() { if (!this.item) return; const replaySource = `/api/orgs/${this.item.oid}/${ this.item.type === "upload" ? "uploads" : "crawls" @@ -1035,33 +1138,105 @@ export class ArchivedItemDetail extends BtrixElement { } private renderCollections() { - const noneText = html`${msg("None")}`; + const dedupeId = this.item && isCrawl(this.item) && this.item.dedupeCollId; + return html` - - - ${when( - this.item, - (item) => - when( - item.collections.length, - () => html` - - `, - () => noneText, - ), - () => html``, - )} - - + ${when( + this.item, + (item) => + when( + item.collections.length, + () => html` + + `, + () => + panelBody({ + content: html`

+ ${msg("This item is not included in any collections.")} +

`, + }), + ), + () => html``, + )} + `; + } + + private renderDependencies() { + if (!this.item) return; + + if (!isCrawlReplay(this.item)) { + return panelBody({ + content: emptyMessage({ + message: msg("Crawl dependencies are not applicable for this item."), + }), + }); + } + + const { dedupeCollId, requiresCrawls } = this.item; + const noDeps = panelBody({ + content: emptyMessage({ + message: msg("This crawl doesn't have any dependencies."), + }), + }); + + if (!requiresCrawls.length) { + return noDeps; + } + + return html` + ${this.dependenciesTask.render({ + complete: (deps) => + deps + ? html`
+
+ ${msg("Dependent on")} ${this.localize.number(deps.total)} + ${pluralOf("items", deps.total)} + ${when( + dedupeCollId, + (id) => html` + ${msg("in")} + + ${msg("deduplication source")} + + `, + )} +
+
+ + ` + : noDeps, + })} `; } private renderFiles() { + const dedupeCollId = + this.item && + isCrawl(this.item) && + this.item.requiresCrawls.length && + this.item.dedupeCollId; + return html` + ${this.hasFiles && dedupeCollId + ? dedupeFilesNotice({ + dependenciesHref: this.dependenciesUrl, + collectionHref: `${this.navigate.orgBasePath}/${OrgTab.Collections}/${CommonTab.View}/${dedupeCollId}`, + }) + : nothing} ${this.hasFiles ? html`