browsertrix/frontend/src/pages/org/archived-item-detail/ui/qa.ts
Emma Segal-Grossman 224b011070
Small UI fixes (#1934)
Fixes a few things that have been bugging me:

- Overflow buttons in list view now (mostly) take up the their full cell
area, instead of there being a couple pixels around the button where
clicking would do nothing or cause navigation

  - | before | after |
    | --- | --- |
| <img width="238" alt="Screenshot 2024-07-16 at 3 35 25 PM"
src="https://github.com/user-attachments/assets/afbda6d6-703b-4ed8-96be-a9c37660430d">
| <img width="236" alt="Screenshot 2024-07-16 at 3 35 02 PM"
src="https://github.com/user-attachments/assets/417a326a-08d2-42b2-85c3-fa007ea3bff8">
|

- Changes the class that `tab-list` uses internally so that it doesn't
conflict with Tailwind's `container` class, which prevents the tab
content from being limited at the default Tailwind container width
- Adds a couple of Tailwind plugins for styling CSS parts
(`part-[...]:`) and for arbitrary attributes (`attr-[...]:`)
2024-07-16 17:01:55 -04:00

983 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { localized, msg, str } from "@lit/localize";
import { Task } from "@lit/task";
import type {
SlChangeEvent,
SlSelect,
SlShowEvent,
} from "@shoelace-style/shoelace";
import {
css,
html,
nothing,
type PropertyValues,
type TemplateResult,
} from "lit";
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 queryString from "query-string";
import { QA_RUNNING_STATES } from "../archived-item-detail";
import { TailwindElement } from "@/classes/TailwindElement";
import { type Dialog } from "@/components/ui/dialog";
import type { MenuItemLink } from "@/components/ui/menu-item-link";
import type { OverflowDropdown } from "@/components/ui/overflow-dropdown";
import type { PageChangeEvent } from "@/components/ui/pagination";
import { APIController } from "@/controllers/api";
import { NavigateController } from "@/controllers/navigate";
import { NotifyController } from "@/controllers/notify";
import { iconFor as iconForPageReview } from "@/features/qa/page-list/helpers";
import * as pageApproval from "@/features/qa/page-list/helpers/approval";
import type { SelectDetail } from "@/features/qa/qa-run-dropdown";
import type {
APIPaginatedList,
APIPaginationQuery,
APISortQuery,
} from "@/types/api";
import { type ArchivedItem, type ArchivedItemPage } from "@/types/crawler";
import type { QARun } from "@/types/qa";
import { type Auth, type AuthState } from "@/utils/AuthService";
import { finishedCrawlStates } from "@/utils/crawler";
import { humanizeExecutionSeconds } from "@/utils/executionTimeFormatter";
import { formatNumber, getLocale } from "@/utils/localization";
import { pluralOf } from "@/utils/pluralize";
type QAStatsThreshold = {
lowerBoundary: `${number}`;
count: number;
};
type QAStats = Record<"screenshotMatch" | "textMatch", QAStatsThreshold[]>;
const qaStatsThresholds = [
{
lowerBoundary: "0.0",
cssColor: "var(--sl-color-danger-500)",
label: msg("Severe Inconsistencies"),
},
{
lowerBoundary: "0.5",
cssColor: "var(--sl-color-warning-500)",
label: msg("Moderate Inconsistencies"),
},
{
lowerBoundary: "0.9",
cssColor: "var(--sl-color-success-500)",
label: msg("Good Match"),
},
];
const notApplicable = () =>
html`<span class="text-neutral-400">${msg("n/a")}</span>`;
function statusWithIcon(
icon: TemplateResult<1>,
label: string | TemplateResult<1>,
) {
return html`
<div class="flex items-center gap-2">
<span class="inline-flex text-base">${icon}</span>${label}
</div>
`;
}
/**
* @fires btrix-qa-runs-update
*/
@localized()
@customElement("btrix-archived-item-detail-qa")
export class ArchivedItemDetailQA extends TailwindElement {
static styles = css`
btrix-table {
--btrix-cell-padding-top: var(--sl-spacing-x-small);
--btrix-cell-padding-bottom: var(--sl-spacing-x-small);
--btrix-cell-padding-left: var(--sl-spacing-small);
--btrix-cell-padding-right: var(--sl-spacing-small);
}
`;
@property({ type: Object, attribute: false })
authState?: AuthState;
@property({ type: String, attribute: false })
orgId?: string;
@property({ type: String, attribute: false })
crawlId?: string;
@property({ type: String, attribute: false })
itemType: ArchivedItem["type"] = "crawl";
@property({ type: Object, attribute: false })
crawl?: ArchivedItem;
@property({ type: String, attribute: false })
qaRunId?: string;
@property({ type: Array, attribute: false })
qaRuns?: QARun[];
@property({ attribute: false })
mostRecentNonFailedQARun?: QARun;
@state()
private pages?: APIPaginatedList<ArchivedItemPage>;
private readonly qaStats = new Task(this, {
// mostRecentNonFailedQARun passed as arg for reactivity so that meter will auto-update
// like progress bar as the analysis run finishes new pages
task: async ([
orgId,
crawlId,
qaRunId,
authState,
mostRecentNonFailedQARun,
]) => {
if (!qaRunId || !authState || !mostRecentNonFailedQARun)
throw new Error("Missing args");
const stats = await this.getQAStats(orgId, crawlId, qaRunId, authState);
return stats;
},
args: () =>
[
this.orgId!,
this.crawlId!,
this.qaRunId,
this.authState,
this.mostRecentNonFailedQARun,
] as const,
});
@state()
private deleting: string | null = null;
@query("#qaPagesSortBySelect")
private readonly qaPagesSortBySelect?: SlSelect | null;
@query("#deleteQARunDialog")
private readonly deleteQADialog?: Dialog | null;
private readonly api = new APIController(this);
private readonly navigate = new NavigateController(this);
private readonly notify = new NotifyController(this);
willUpdate(changedProperties: PropertyValues<this>) {
if (changedProperties.has("crawlId") && this.crawlId) {
void this.fetchPages();
}
}
render() {
const fileCount = this.crawl?.filePageCount || 0;
const errorCount = this.crawl?.errorPageCount || 0;
const doneCount = this.crawl?.stats?.done
? parseInt(this.crawl.stats.done)
: 0;
const htmlCount = doneCount - fileCount - errorCount;
return html`
<div class="mb-5 rounded-lg border p-2">
<btrix-desc-list horizontal>
<btrix-desc-list-item label=${msg("Analysis Status")}>
${when(
this.qaRuns,
() =>
this.mostRecentNonFailedQARun
? html`<btrix-crawl-status
state=${this.mostRecentNonFailedQARun.state}
type="qa"
class="min-w-32"
></btrix-crawl-status>`
: statusWithIcon(
html`<sl-icon
name="slash-circle"
class="text-neutral-400"
></sl-icon>`,
html`<span class="text-neutral-400">
${msg("Not Analyzed")}
</span>`,
),
this.renderLoadingDetail,
)}
</btrix-desc-list-item>
${this.mostRecentNonFailedQARun?.state === "running"
? html`
<btrix-desc-list-item label=${msg("Analysis Progress")}>
<sl-tooltip
content="${msg(
str`${
this.mostRecentNonFailedQARun.stats.found === 0
? msg("Loading")
: `${this.mostRecentNonFailedQARun.stats.done}/${this.mostRecentNonFailedQARun.stats.found}`
} ${pluralOf("pages", this.mostRecentNonFailedQARun.stats.found)}`,
)}"
placement="bottom"
hoist
>
<sl-progress-bar
value=${(100 * this.mostRecentNonFailedQARun.stats.done) /
this.mostRecentNonFailedQARun.stats.found || 1}
?indeterminate=${this.mostRecentNonFailedQARun.stats
.found === 0}
style="--height: 0.5rem;"
class="mt-2 w-32"
></sl-progress-bar>
</sl-tooltip>
</btrix-desc-list-item>
`
: ""}
<btrix-desc-list-item label=${msg("QA Rating")}>
${when(
this.crawl,
(crawl) =>
html`<btrix-qa-review-status
.status=${crawl.reviewStatus}
></btrix-qa-review-status>`,
this.renderLoadingDetail,
)}
</btrix-desc-list-item>
<btrix-desc-list-item label=${msg("Total Analysis Time")}>
${when(
this.qaRuns,
() =>
this.mostRecentNonFailedQARun && this.crawl?.qaCrawlExecSeconds
? humanizeExecutionSeconds(this.crawl.qaCrawlExecSeconds)
: notApplicable(),
this.renderLoadingDetail,
)}
</btrix-desc-list-item>
</btrix-desc-list>
</div>
${this.renderDeleteConfirmDialog()}
<btrix-tab-group>
<btrix-tab-group-tab slot="nav" panel="pages">
<sl-icon name="file-richtext-fill"></sl-icon>
${msg("Pages")}
</btrix-tab-group-tab>
<btrix-tab-group-tab
slot="nav"
panel="runs"
?disabled=${!this.qaRuns?.length}
>
<sl-icon name="list-ul"></sl-icon>
${msg("Analysis Runs")}
</btrix-tab-group-tab>
<sl-divider></sl-divider>
<btrix-tab-group-panel name="pages" class="block">
<btrix-card class="gap-y-1">
<div slot="title" class="flex flex-wrap justify-between">
${msg("Crawl Results")}
<div class="text-neutral-500">
<sl-tooltip
content=${msg(
"Non-HTML files captured as pages are known good files that the crawler found as clickable links on a page and don't need to be analyzed. Failed pages did not respond when the crawler tried to visit them.",
)}
>
<sl-icon class="text-base" name="info-circle"></sl-icon>
</sl-tooltip>
</div>
</div>
<div>
<p>
<span class="text-primary">${htmlCount}</span> ${msg(
"HTML Pages",
)}
</p>
<p>
<span class="text-neutral-600">${fileCount}</span> ${msg(
"Non-HTML Files Captured As Pages",
)}
</p>
<p>
<span class="text-danger">${errorCount}</span> ${msg(
"Failed Pages",
)}
</p>
</div>
${when(this.mostRecentNonFailedQARun && this.qaRuns, (qaRuns) =>
this.renderAnalysis(qaRuns),
)}
</btrix-card>
<div>
<h4 class="mb-2 mt-4 text-lg leading-8">
<span class="font-semibold">${msg("Pages")}</span> (${(
this.pages?.total ?? 0
).toLocaleString()})
</h4>
</div>
${this.renderPageListControls()} ${this.renderPageList()}
</btrix-tab-group-panel>
<btrix-tab-group-panel name="runs" class="block">
<btrix-table
class="-mx-3 grid-cols-[repeat(4,_auto)_min-content] overflow-x-auto px-3"
>
<btrix-table-head>
<btrix-table-header-cell>
${msg("Status")}
</btrix-table-header-cell>
<btrix-table-header-cell>
${msg("Started")}
</btrix-table-header-cell>
<btrix-table-header-cell>
${msg("Finished")}
</btrix-table-header-cell>
<btrix-table-header-cell>
${msg("Started by")}
</btrix-table-header-cell>
<btrix-table-header-cell class="px-0">
<span class="sr-only">${msg("Row actions")}</span>
</btrix-table-header-cell>
</btrix-table-head>
<btrix-table-body class="rounded border">
${when(this.qaRuns, this.renderQARunRows)}
</btrix-table-body>
</btrix-table>
</btrix-tab-group-panel>
</btrix-tab-group>
`;
}
private readonly renderQARunRows = (qaRuns: QARun[]) => {
if (!qaRuns.length) {
return html`
<div
class="col-span-4 flex h-full flex-col items-center justify-center gap-2 p-3 text-xs text-neutral-500"
>
<sl-icon name="slash-circle"></sl-icon>
${msg("No analysis runs, yet")}
</div>
`;
}
return qaRuns.map(
(run, idx) => html`
<btrix-table-row class=${idx > 0 ? "border-t" : ""}>
<btrix-table-cell>
<btrix-crawl-status
.state=${run.state}
type="qa"
></btrix-crawl-status>
</btrix-table-cell>
<btrix-table-cell>
<sl-format-date
lang=${getLocale()}
date=${`${run.started}Z`}
month="2-digit"
day="2-digit"
year="2-digit"
hour="2-digit"
minute="2-digit"
></sl-format-date>
</btrix-table-cell>
<btrix-table-cell>
${run.finished
? html`
<sl-format-date
lang=${getLocale()}
date=${`${run.finished}Z`}
month="2-digit"
day="2-digit"
year="2-digit"
hour="2-digit"
minute="2-digit"
></sl-format-date>
`
: notApplicable()}
</btrix-table-cell>
<btrix-table-cell>${run.userName}</btrix-table-cell>
<btrix-table-cell class="p-0">
<div class="col action">
<btrix-overflow-dropdown
@sl-show=${async (e: SlShowEvent) => {
const dropdown = e.currentTarget as OverflowDropdown;
const downloadLink = dropdown.querySelector<MenuItemLink>(
"btrix-menu-item-link",
);
if (!downloadLink) {
console.debug("no download link");
return;
}
downloadLink.loading = true;
const file = await this.getQARunDownloadLink(run.id);
if (file) {
downloadLink.disabled = false;
downloadLink.href = file.path;
} else {
downloadLink.disabled = true;
}
downloadLink.loading = false;
}}
>
<sl-menu>
${run.state === "canceled"
? nothing
: html`
<btrix-menu-item-link href="#" download>
<sl-icon
name="cloud-download"
slot="prefix"
></sl-icon>
${msg("Download Analysis Run")}
</btrix-menu-item-link>
<sl-divider></sl-divider>
`}
<sl-menu-item
@click=${() => {
this.deleting = run.id;
void this.deleteQADialog?.show();
}}
style="--sl-color-neutral-700: var(--danger)"
>
<sl-icon name="trash3" slot="prefix"></sl-icon>
${msg("Delete Analysis Run")}
</sl-menu-item>
</sl-menu>
</btrix-overflow-dropdown>
</div>
</btrix-table-cell>
</btrix-table-row>
`,
);
};
private readonly renderDeleteConfirmDialog = () => {
const runToBeDeleted = this.qaRuns?.find((run) => run.id === this.deleting);
return html`
<btrix-dialog
id="deleteQARunDialog"
.label=${msg("Delete Analysis Run?")}
>
<b class="font-semibold"
>${msg(
"All of the data included in this analysis run will be deleted.",
)}</b
>
${runToBeDeleted &&
html`<div>
${msg(
str`This analysis run includes data for ${runToBeDeleted.stats.done} ${pluralOf("pages", runToBeDeleted.stats.done)} and was started on `,
)}
<sl-format-date
lang=${getLocale()}
date=${`${runToBeDeleted.started}Z`}
month="2-digit"
day="2-digit"
year="2-digit"
hour="2-digit"
minute="2-digit"
></sl-format-date>
${msg("by")} ${runToBeDeleted.userName}.
</div>
<div slot="footer" class="flex justify-between">
<sl-button
size="small"
@click=${() => void this.deleteQADialog?.hide()}
>
${msg("Cancel")}
</sl-button>
<sl-button
size="small"
variant="danger"
@click=${async () => {
await this.deleteQARun(runToBeDeleted.id);
this.dispatchEvent(new CustomEvent("btrix-qa-runs-update"));
this.deleting = null;
void this.deleteQADialog?.hide();
}}
>${msg("Delete Analysis Run")}</sl-button
>
</div>`}
</btrix-dialog>
`;
};
private readonly renderLoadingDetail = () =>
html`<div class="min-w-32"><sl-spinner class="size-4"></sl-spinner></div>`;
private renderAnalysis(qaRuns: QARun[]) {
const isRunning =
this.mostRecentNonFailedQARun &&
QA_RUNNING_STATES.includes(this.mostRecentNonFailedQARun.state);
const qaRun = qaRuns.find(({ id }) => id === this.qaRunId);
if (!qaRun && isRunning) {
return html`<btrix-alert class="mb-3" variant="success">
${msg("Running QA analysis on pages...")}
</btrix-alert>`;
}
if (!qaRun) {
return html`<btrix-alert class="mb-3" variant="warning">
${msg("This analysis run doesn't exist.")}
</btrix-alert>`;
}
return html`
<div
class="mb-3 mt-6 flex flex-wrap justify-between border-b pb-3 text-base font-semibold leading-none"
>
<div class="flex flex-wrap items-center gap-x-3">
${msg("HTML Page Match Analysis")}
${when(this.qaRuns, (qaRuns) => {
const finishedQARuns = qaRuns.filter(({ state }) =>
finishedCrawlStates.includes(state),
);
const latestFinishedSelected =
this.qaRunId === finishedQARuns[0]?.id;
const finishedAndRunningQARuns = qaRuns.filter(
({ state }) =>
finishedCrawlStates.includes(state) ||
QA_RUNNING_STATES.includes(state),
);
const mostRecentSelected =
this.qaRunId === finishedAndRunningQARuns[0]?.id;
return html`
<div>
<sl-tooltip
content=${mostRecentSelected
? msg("Youre viewing the latest analysis run results.")
: msg("Youre viewing results from an older analysis run.")}
>
<sl-tag
size="small"
variant=${mostRecentSelected ? "success" : "warning"}
>
${mostRecentSelected
? msg("Current")
: latestFinishedSelected
? msg("Last Finished")
: msg("Outdated")}
</sl-tag>
</sl-tooltip>
<btrix-qa-run-dropdown
.items=${finishedAndRunningQARuns}
selectedId=${this.qaRunId || ""}
@btrix-select=${(e: CustomEvent<SelectDetail>) =>
(this.qaRunId = e.detail.item.id)}
></btrix-qa-run-dropdown>
</div>
`;
})}
</div>
<div class="flex items-center gap-2 text-neutral-500">
<div class="text-sm font-normal">
${qaRun.state === "starting"
? msg("Analysis starting")
: `${formatNumber(qaRun.stats.done)}/${formatNumber(qaRun.stats.found)}
${pluralOf("pages", qaRun.stats.found)} ${msg("analyzed")}`}
</div>
<sl-tooltip
content=${msg(
"Match analysis compares pages during a crawl against their replay during an analysis run. A good match indicates that the crawl is probably good, whereas severe inconsistencies may indicate a bad crawl.",
)}
>
<sl-icon class="text-base" name="info-circle"></sl-icon>
</sl-tooltip>
</div>
</div>
<figure>
<btrix-table class="grid-cols-[min-content_1fr]">
<btrix-table-head class="sr-only">
<btrix-table-header-cell>
${msg("Statistic")}
</btrix-table-header-cell>
<btrix-table-header-cell> ${msg("Chart")} </btrix-table-header-cell>
</btrix-table-head>
<btrix-table-body>
<btrix-table-row>
<btrix-table-cell class="font-medium">
${msg("Screenshots")}
</btrix-table-cell>
<btrix-table-cell class="p-0">
${this.qaStats.value
? this.renderMeter(
qaRun.stats.found,
this.qaStats.value.screenshotMatch,
isRunning,
)
: this.renderMeter()}
</btrix-table-cell>
</btrix-table-row>
<btrix-table-row>
<btrix-table-cell class="font-medium">
${msg("Text")}
</btrix-table-cell>
<btrix-table-cell class="p-0">
${this.qaStats.value
? this.renderMeter(
qaRun.stats.found,
this.qaStats.value.textMatch,
isRunning,
)
: this.renderMeter()}
</btrix-table-cell>
</btrix-table-row>
</btrix-table-body>
</btrix-table>
</figure>
<figcaption slot="footer" class="mt-2">
<dl class="flex flex-wrap items-center justify-end gap-4">
${qaStatsThresholds.map(
(threshold) => html`
<div class="flex items-center gap-2">
<dt
class="size-4 flex-shrink-0 rounded"
style="background-color: ${threshold.cssColor}"
>
<span class="sr-only">${threshold.lowerBoundary}</span>
</dt>
<dd>${threshold.label}</dd>
</div>
`,
)}
</dl>
</figcaption>
`;
}
private renderMeter(): TemplateResult<1>;
private renderMeter(
pageCount: number,
barData: QAStatsThreshold[],
qaIsRunning: boolean | undefined,
): TemplateResult<1>;
private renderMeter(
pageCount?: number,
barData?: QAStatsThreshold[],
qaIsRunning?: boolean,
) {
if (!pageCount || !barData) {
return html`<sl-skeleton
class="h-4 flex-1 [--border-radius:var(--sl-border-radius-medium)]"
effect="sheen"
></sl-skeleton>`;
}
barData = barData.filter(
(bar) => (bar.lowerBoundary as string) !== "No data",
);
const analyzedPageCount = barData.reduce(
(prev, cur) => prev + cur.count,
0,
);
const remainingPageCount = pageCount - analyzedPageCount;
const remainderBarLabel = qaIsRunning ? msg("Pending") : msg("Incomplete");
console.log({ pageCount, barData, analyzedPageCount });
return html`
<btrix-meter class="flex-1" value=${analyzedPageCount} max=${pageCount}>
${barData.map((bar) => {
const threshold = qaStatsThresholds.find(
({ lowerBoundary }) => bar.lowerBoundary === lowerBoundary,
);
const idx = threshold ? qaStatsThresholds.indexOf(threshold) : -1;
return bar.count !== 0
? html`
<btrix-meter-bar
value=${(bar.count / analyzedPageCount) * 100}
style="--background-color: ${threshold?.cssColor ?? "none"}"
aria-label=${threshold?.label ?? ""}
>
<div class="text-center">
${threshold?.label}
<div class="text-xs opacity-80">
${idx === 0
? `<${+qaStatsThresholds[idx + 1].lowerBoundary * 100}%`
: idx === qaStatsThresholds.length - 1
? `>=${threshold ? +threshold.lowerBoundary * 100 : 0}%`
: `${threshold ? +threshold.lowerBoundary * 100 : 0}-${+qaStatsThresholds[idx + 1].lowerBoundary * 100 || 100}%`}
${msg("match", { desc: "label for match percentage" })}
<br />
${formatNumber(bar.count)} ${pluralOf("pages", bar.count)}
</div>
</div>
</btrix-meter-bar>
`
: nothing;
})}
${remainingPageCount > 0
? html`
<btrix-meter-bar
slot="available"
value=${(remainingPageCount / pageCount) * 100}
aria-label=${remainderBarLabel}
style="--background-color: none"
>
<div class="text-center">
${remainderBarLabel}
<div class="text-xs opacity-80">
${formatNumber(remainingPageCount)}
${pluralOf("pages", remainingPageCount)}
</div>
</div>
</btrix-meter-bar>
`
: nothing}
</btrix-meter>
`;
}
private renderPageListControls() {
return html`
<div
class="z-40 mb-1 flex flex-wrap items-center gap-2 rounded-lg border bg-neutral-50 px-5 py-3"
>
<div class="flex w-full grow items-center md:w-fit">
<sl-select
id="qaPagesSortBySelect"
class="label-same-line"
label=${msg("Sort by:")}
size="small"
value=${this.qaRunId ? "approved.-1" : "url.1"}
pill
@sl-change=${(e: SlChangeEvent) => {
const { value } = e.target as SlSelect;
const [field, direction] = (
Array.isArray(value) ? value[0] : value
).split(".");
void this.fetchPages({
sortBy: field,
sortDirection: +direction,
page: 1,
});
}}
>
<sl-option value="title.1">${msg("Title")}</sl-option>
<sl-option value="url.1">${msg("URL")}</sl-option>
<sl-option value="notes.-1" ?disabled=${!this.qaRunId}
>${msg("Most Comments")}</sl-option
>
<sl-option value="approved.-1" ?disabled=${!this.qaRunId}>
${msg("Recently Approved")}
</sl-option>
<sl-option value="approved.1" ?disabled=${!this.qaRunId}>
${msg("Not Approved")}
</sl-option>
</sl-select>
</div>
</div>
`;
}
private renderPageList() {
const pageTitle = (page: ArchivedItemPage) => html`
<div class="truncate font-medium">
${page.title ||
html`<span class="opacity-50">${msg("No page title")}</span>`}
</div>
<div class="truncate text-xs leading-4 text-neutral-600">${page.url}</div>
`;
return html`
<btrix-table
class="-mx-3 overflow-x-auto px-5"
style="grid-template-columns: ${[
"[clickable-start] minmax(12rem, auto)",
"minmax(min-content, 12rem)",
"minmax(min-content, 12rem) [clickable-end]",
].join(" ")}"
>
<btrix-table-head>
<btrix-table-header-cell>${msg("Page")}</btrix-table-header-cell>
<btrix-table-header-cell>${msg("Approval")}</btrix-table-header-cell>
<btrix-table-header-cell>${msg("Comments")}</btrix-table-header-cell>
</btrix-table-head>
<btrix-table-body class="rounded border">
${this.pages?.items.map(
(page, idx) => html`
<btrix-table-row
class="${idx > 0 ? "border-t" : ""} ${this.qaRunId
? "cursor-pointer transition-colors focus-within:bg-neutral-50 hover:bg-neutral-50"
: ""} select-none"
>
<btrix-table-cell
class="block overflow-hidden"
rowClickTarget=${ifDefined(this.qaRunId ? "a" : undefined)}
>
${this.qaRunId
? html`
<a
href=${`${this.navigate.orgBasePath}/items/${this.itemType}/${this.crawlId}/review/screenshots?qaRunId=${this.qaRunId}&itemPageId=${page.id}`}
title=${msg(str`Review "${page.title ?? page.url}"`)}
@click=${this.navigate.link}
>
${pageTitle(page)}
</a>
`
: pageTitle(page)}
</btrix-table-cell>
<btrix-table-cell
>${this.renderApprovalStatus(page)}</btrix-table-cell
>
<btrix-table-cell>
${page.notes?.length
? html`
<sl-tooltip class="invert-tooltip">
<div slot="content">
<div class="text-xs text-neutral-400">
${msg("Newest comment:")}
</div>
<div class="leading04 max-w-60 text-xs">
${page.notes[page.notes.length - 1].text}
</div>
</div>
${statusWithIcon(
html`<sl-icon
name="chat-square-text-fill"
class="text-blue-600"
></sl-icon>`,
`${page.notes.length.toLocaleString()} ${pluralOf("comments", page.notes.length)}`,
)}
</sl-tooltip>
`
: html`<span class="text-neutral-400">
${msg("None")}
</span>`}
</btrix-table-cell>
</btrix-table-row>
`,
)}
</btrix-table-body>
</btrix-table>
${when(this.pages, (pages) =>
pages.total > pages.pageSize
? html`
<footer class="mt-3 flex justify-center">
<btrix-pagination
page=${pages.page}
size=${pages.pageSize}
totalCount=${pages.total}
@page-change=${(e: PageChangeEvent) => {
void this.fetchPages({
page: e.detail.page,
});
}}
></btrix-pagination>
</footer>
`
: nothing,
)}
`;
}
private renderApprovalStatus(page: ArchivedItemPage) {
const approvalStatus = pageApproval.approvalFromPage(page);
const status = approvalStatus === "commentOnly" ? null : approvalStatus;
const icon = iconForPageReview(status);
const label =
pageApproval.labelFor(status) ??
html`<span class="text-neutral-400">${msg("None")}</span>`;
return statusWithIcon(icon, label);
}
async fetchPages(params?: APIPaginationQuery & APISortQuery): Promise<void> {
try {
await this.updateComplete;
let sortBy = params?.sortBy;
let sortDirection = params?.sortDirection;
if (!sortBy && this.qaPagesSortBySelect?.value[0]) {
const value = this.qaPagesSortBySelect.value;
if (value) {
const [field, direction] = (
Array.isArray(value) ? value[0] : value
).split(".");
sortBy = field;
sortDirection = +direction;
}
}
this.pages = await this.getPages({
page: params?.page ?? this.pages?.page ?? 1,
pageSize: params?.pageSize ?? this.pages?.pageSize ?? 10,
sortBy,
sortDirection,
});
} catch {
this.notify.toast({
message: msg("Sorry, couldn't retrieve archived item at this time."),
variant: "danger",
icon: "exclamation-octagon",
});
}
}
private async getPages(
params?: APIPaginationQuery & APISortQuery & { reviewed?: boolean },
): Promise<APIPaginatedList<ArchivedItemPage>> {
const query = queryString.stringify(
{
...params,
},
{
arrayFormat: "comma",
},
);
return this.api.fetch<APIPaginatedList<ArchivedItemPage>>(
`/orgs/${this.orgId}/crawls/${this.crawlId}/pages?${query}`,
this.authState!,
);
}
private async getQARunDownloadLink(qaRunId: string) {
try {
const { resources } = await this.api.fetch<QARun>(
`/orgs/${this.orgId}/crawls/${this.crawlId}/qa/${qaRunId}/replay.json`,
this.authState!,
);
// TODO handle more than one file
return resources?.[0];
} catch (e) {
console.debug(e);
}
}
private async deleteQARun(id: string) {
try {
await this.api.fetch(
`/orgs/${this.orgId}/crawls/${this.crawlId}/qa/delete`,
this.authState!,
{ method: "POST", body: JSON.stringify({ qa_run_ids: [id] }) },
);
} catch (e) {
console.error(e);
}
}
private async getQAStats(
orgId: string,
crawlId: string,
qaRunId: string,
authState: Auth,
) {
const query = queryString.stringify(
{
screenshotThresholds: [0.5, 0.9],
textThresholds: [0.5, 0.9],
},
{
arrayFormat: "comma",
},
);
return this.api.fetch<QAStats>(
`/orgs/${orgId}/crawls/${crawlId}/qa/${qaRunId}/stats?${query}`,
authState,
);
}
}