browsertrix/frontend/src/pages/org/archived-item-qa/archived-item-qa.ts
sua yoo 7ac06608b2
Fix archived item status icon size + minor review UI updates (#1734)
Related to https://github.com/webrecorder/browsertrix/issues/1477, minor
UI tweaks as fast follow:

- Makes archived item status icons all the same size
- Increases hover hit target of archived item cells
- Reduces top padding of QA review page
- Adds "Back" button to QA review page to match all other pages
- Updates percentage formatting of tab labels to match pages
2024-04-23 17:08:24 -04:00

1350 lines
40 KiB
TypeScript

import { localized, msg, str } from "@lit/localize";
import type { SlTextarea } from "@shoelace-style/shoelace";
import { serialize } from "@shoelace-style/shoelace/dist/utilities/form.js";
import { merge } from "immutable";
import { html, nothing, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import { cache } from "lit/directives/cache.js";
import { choose } from "lit/directives/choose.js";
import { guard } from "lit/directives/guard.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { until } from "lit/directives/until.js";
import { when } from "lit/directives/when.js";
import queryString from "query-string";
import { styles } from "./styles";
import type * as QATypes from "./types";
import { renderReplay } from "./ui/replay";
import { renderResources } from "./ui/resources";
import { renderScreenshots } from "./ui/screenshots";
import { renderSeverityBadge } from "./ui/severityBadge";
import { renderText } from "./ui/text";
import { TailwindElement } from "@/classes/TailwindElement";
import type { Dialog } from "@/components/ui/dialog";
import { APIController } from "@/controllers/api";
import { NavigateController } from "@/controllers/navigate";
import { NotifyController } from "@/controllers/notify";
import {
type QaFilterChangeDetail,
type QaPaginationChangeDetail,
type QaSortChangeDetail,
type SortableFieldNames,
type SortDirection,
} from "@/features/qa/page-list/page-list";
import { type UpdatePageApprovalDetail } from "@/features/qa/page-qa-approval";
import type { SelectDetail } from "@/features/qa/qa-run-dropdown";
import type {
APIPaginatedList,
APIPaginationQuery,
APISortQuery,
} from "@/types/api";
import type { ArchivedItem, ArchivedItemPageComment } from "@/types/crawler";
import type { ArchivedItemQAPage, QARun } from "@/types/qa";
import { type AuthState } from "@/utils/AuthService";
import { finishedCrawlStates, isActive, renderName } from "@/utils/crawler";
import { formatISODateString, getLocale } from "@/utils/localization";
const DEFAULT_PAGE_SIZE = 100;
type PageResource = {
status?: number;
mime?: string;
type?: string;
};
// From https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types
const IMG_EXTS = [
"apng",
"avif",
"gif",
"jpg",
"jpeg",
"jfif",
"pjpeg",
"pjp",
"png",
"svg",
"webp",
"tif",
"tiff",
"bmp",
"ico",
"cur",
];
const tabToPrefix: Record<QATypes.QATab, string> = {
screenshots: "view",
text: "text",
resources: "pageinfo",
replay: "",
};
@localized()
@customElement("btrix-archived-item-qa")
export class ArchivedItemQA extends TailwindElement {
static styles = styles;
@property({ type: Object })
authState?: AuthState;
@property({ type: String })
orgId?: string;
@property({ type: String })
itemId?: string;
@property({ type: String })
itemPageId?: string;
@property({ type: String })
qaRunId?: string;
@property({ type: String })
tab: QATypes.QATab = "screenshots";
@state()
private item?: ArchivedItem;
@state()
finishedQARuns:
| (QARun & { state: (typeof finishedCrawlStates)[number] })[]
| undefined = [];
@state()
private pages?: APIPaginatedList<ArchivedItemQAPage>;
@property({ type: Object })
page?: ArchivedItemQAPage;
@state()
private crawlData: QATypes.ReplayData = null;
@state()
private qaData: QATypes.ReplayData = null;
// indicate whether the crawl / qa endpoints have been registered in SW
// if not, requires loading via <replay-web-page>
// endpoints may be registered but crawlData / qaData may still be missing
@state()
private crawlDataRegistered = false;
@state()
private qaDataRegistered = false;
@state()
private splitView = true;
@state()
filterPagesBy: {
filterQABy?: string;
gte?: number;
gt?: number;
lte?: number;
lt?: number;
reviewed?: boolean;
approved?: boolean;
hasNotes?: boolean;
} = {};
@state()
sortPagesBy: APISortQuery & { sortBy: SortableFieldNames } = {
sortBy: "screenshotMatch",
sortDirection: 1,
};
private readonly api = new APIController(this);
private readonly navigate = new NavigateController(this);
private readonly notify = new NotifyController(this);
private readonly replaySwReg =
navigator.serviceWorker.getRegistration("/replay/");
@query("#replayframe")
private readonly replayFrame?: HTMLIFrameElement | null;
@query(".reviewDialog")
private readonly reviewDialog?: Dialog | null;
@query(".commentDialog")
private readonly commentDialog?: Dialog | null;
@query('sl-textarea[name="pageComment"]')
private readonly commentTextarea?: SlTextarea | null;
connectedCallback(): void {
super.connectedCallback();
// Receive messages from replay-web-page windows
void this.replaySwReg.then((reg) => {
if (!reg) {
console.log("[debug] no reg, listening to messages");
// window.addEventListener("message", this.onWindowMessage);
}
});
window.addEventListener("message", this.onWindowMessage);
}
disconnectedCallback(): void {
super.disconnectedCallback();
if (this.crawlData?.blobUrl) URL.revokeObjectURL(this.crawlData.blobUrl);
if (this.qaData?.blobUrl) URL.revokeObjectURL(this.qaData.blobUrl);
window.removeEventListener("message", this.onWindowMessage);
}
private readonly onWindowMessage = (event: MessageEvent) => {
const sourceLoc = (event.source as Window | null)?.location.href;
// ensure its an rwp frame
if (sourceLoc && sourceLoc.indexOf("?source=") > 0) {
void this.handleRwpMessage(sourceLoc);
}
};
/**
* Callback for when hidden RWP embeds are loaded and ready.
* This won't fire if the RWP service worker is already
* registered on the page due to RWP conditionally rendering
* if the sw is not present.
*/
private async handleRwpMessage(sourceLoc: string) {
console.log("[debug] handleRwpMessage", sourceLoc);
// check if has /qa/ in path, then QA
if (sourceLoc.indexOf("%2Fqa%2F") >= 0 && !this.qaDataRegistered) {
this.qaDataRegistered = true;
console.log("[debug] onWindowMessage qa", this.qaData);
await this.fetchContentForTab({ qa: true });
await this.updateComplete;
// otherwise main crawl replay
} else if (!this.crawlDataRegistered) {
this.crawlDataRegistered = true;
console.log("[debug] onWindowMessage crawl", this.crawlData);
await this.fetchContentForTab();
await this.updateComplete;
}
// if (this.crawlData && this.qaData) {
// window.removeEventListener("message", this.onWindowMessage);
// }
}
protected async willUpdate(
changedProperties: PropertyValues<this> | Map<PropertyKey, unknown>,
) {
if (changedProperties.has("itemId") && this.itemId) {
void this.initItem();
} else if (
changedProperties.has("filterPagesBy") ||
changedProperties.has("sortPagesBy") ||
changedProperties.has("qaRunId")
) {
void this.fetchPages();
}
if (
(changedProperties.has("itemPageId") ||
changedProperties.has("qaRunId")) &&
this.itemPageId
) {
void this.fetchPage();
}
// Re-fetch when tab, archived item page, or QA run ID changes
// from an existing one, probably due to user interaction
if (changedProperties.get("tab") || changedProperties.get("page")) {
if (this.tab === "screenshots") {
if (this.crawlData?.blobUrl)
URL.revokeObjectURL(this.crawlData.blobUrl);
if (this.qaData?.blobUrl) URL.revokeObjectURL(this.qaData.blobUrl);
}
// TODO prefetch content for other tabs?
void this.fetchContentForTab();
void this.fetchContentForTab({ qa: true });
} else if (changedProperties.get("qaRunId")) {
void this.fetchContentForTab({ qa: true });
}
}
private async initItem() {
void this.fetchCrawl();
await this.fetchQARuns();
const searchParams = new URLSearchParams(window.location.search);
if (this.qaRunId) {
if (this.itemPageId) {
void this.fetchPages({ page: 1 });
} else {
await this.fetchPages({ page: 1 });
}
}
const firstQARun = this.finishedQARuns?.[0];
const firstPage = this.pages?.items[0];
if (!this.qaRunId && firstQARun) {
searchParams.set("qaRunId", firstQARun.id);
}
if (!this.itemPageId && firstPage) {
searchParams.set("itemPageId", firstPage.id);
}
this.navigate.to(`${window.location.pathname}?${searchParams.toString()}`);
}
/**
* Get current page position with previous and next items
*/
private getPageListSliceByCurrent(
pageId = this.itemPageId,
): [
ArchivedItemQAPage | undefined,
ArchivedItemQAPage | undefined,
ArchivedItemQAPage | undefined,
] {
if (!pageId || !this.pages) {
return [undefined, undefined, undefined];
}
const pages = this.pages.items;
const idx = pages.findIndex(({ id }) => id === pageId);
return [pages[idx - 1], pages[idx], pages[idx + 1]];
}
private navToPage(pageId: string) {
const searchParams = new URLSearchParams(window.location.search);
searchParams.set("itemPageId", pageId);
this.navigate.to(
`${window.location.pathname}?${searchParams.toString()}`,
undefined,
/* resetScroll: */ false,
);
}
render() {
const crawlBaseUrl = `${this.navigate.orgBasePath}/items/crawl/${this.itemId}`;
const searchParams = new URLSearchParams(window.location.search);
const itemName = this.item ? renderName(this.item) : nothing;
const [prevPage, currentPage, nextPage] = this.getPageListSliceByCurrent();
const currentQARun = this.finishedQARuns?.find(
({ id }) => id === this.qaRunId,
);
const disableReview = !currentQARun || isActive(currentQARun.state);
return html`
${this.renderHidden()}
<div class="flex items-center gap-2">
<a
class="font-medium text-neutral-500 hover:text-neutral-600"
href=${`${crawlBaseUrl}#qa`}
@click=${this.navigate.link}
>
<sl-icon
name="arrow-left"
class="inline-block align-middle"
></sl-icon>
<span class="inline-block align-middle"> ${msg("Back")} </span>
</a>
<div class="text-neutral-400" role="separator">/</div>
<h1 class="text-neutral-400">
${msg("Review Archived Item")}
<btrix-beta-badge placement="right"></btrix-beta-badge>
</h1>
</div>
<article class="qa-grid grid gap-x-6 gap-y-0">
<header
class="grid--header flex flex-wrap items-center justify-between gap-1 border-b py-2"
>
<div class="flex items-center gap-2 overflow-hidden">
<h2
class="flex-1 flex-shrink-0 basis-32 truncate text-base font-semibold leading-tight"
>
${itemName}
</h2>
${when(
this.finishedQARuns,
(qaRuns) => html`
<btrix-qa-run-dropdown
.items=${qaRuns}
selectedId=${this.qaRunId || ""}
@btrix-select=${(e: CustomEvent<SelectDetail>) => {
const params = new URLSearchParams(searchParams);
params.set("qaRunId", e.detail.item.id);
this.navigate.to(
`${window.location.pathname}?${params.toString()}`,
);
}}
></btrix-qa-run-dropdown>
`,
)}
</div>
<div class="ml-auto flex">
<sl-button
size="small"
variant="text"
href=${`${crawlBaseUrl}#qa`}
@click=${this.navigate.link}
>${msg("Exit Review")}</sl-button
>
<sl-tooltip
content=${msg(
"Reviews are temporarily disabled during analysis runs.",
)}
?disabled=${!disableReview}
>
<sl-button
variant="success"
size="small"
@click=${() => void this.reviewDialog?.show()}
?disabled=${disableReview}
>
<sl-icon slot="prefix" name="patch-check"> </sl-icon>
${msg("Finish Review")}
</sl-button>
</sl-tooltip>
</div>
</header>
<div
class="grid--pageToolbar flex flex-wrap items-center justify-stretch gap-2 overflow-hidden border-b py-2 @container"
>
<h3
class="flex-auto flex-shrink-0 flex-grow basis-32 truncate text-base font-semibold text-neutral-700"
title="${this.page?.title ?? ""}"
>
${
this.page?.title ||
html`<span class="opacity-50">${msg("No page title")}</span>`
}
</h3>
<div
class="ml-auto flex flex-grow basis-auto flex-wrap justify-between gap-2 @lg:flex-grow-0"
>
<sl-button
size="small"
@click=${this.navPrevPage}
?disabled=${!prevPage}
class="order-1"
>
<sl-icon slot="prefix" name="arrow-left"></sl-icon>
${msg("Previous Page")}
</sl-button>
<sl-tooltip
content=${msg(
"Approvals are temporarily disabled during analysis runs.",
)}
?disabled=${!disableReview}
class="order-3 mx-auto flex w-full justify-center @lg:order-2 @lg:mx-0 @lg:w-auto"
>
<btrix-page-qa-approval
.authState=${this.authState}
.orgId=${this.orgId}
.itemId=${this.itemId}
.pageId=${this.itemPageId}
.page=${this.page}
?disabled=${disableReview}
@btrix-show-comments=${() => void this.commentDialog?.show()}
@btrix-update-page-approval=${this.onUpdatePageApproval}
></btrix-page-qa-approval>
</sl-tooltip>
<sl-button
variant="primary"
size="small"
?disabled=${!nextPage}
outline
@click=${this.navNextPage}
class="order-2 @lg:order-3"
>
<sl-icon slot="suffix" name="arrow-right"></sl-icon>
${msg("Next Page")}
</sl-button>
</div>
</div>
<div class="grid--tabGroup flex min-w-0 flex-col">
<nav
class="-mx-3 my-0 flex gap-2 overflow-x-auto px-3 py-2 lg:mx-0 lg:px-0"
>
<btrix-navigation-button
id="screenshot-tab"
href=${`${crawlBaseUrl}/review/screenshots?${searchParams.toString()}`}
?active=${this.tab === "screenshots"}
@click=${this.onTabNavClick}
>
<sl-icon name="images"></sl-icon>
${msg("Screenshots")}
${when(this.page?.qa || currentPage?.qa, (qa) =>
renderSeverityBadge(qa.screenshotMatch),
)}
</btrix-navigation-button>
<btrix-navigation-button
id="text-tab"
href=${`${crawlBaseUrl}/review/text?${searchParams.toString()}`}
?active=${this.tab === "text"}
@click=${this.onTabNavClick}
>
<sl-icon name="file-text-fill"></sl-icon>
${msg("Text")}
${when(this.page?.qa || currentPage?.qa, (qa) =>
renderSeverityBadge(qa.textMatch),
)}
</btrix-navigation-button>
<btrix-navigation-button
id="text-tab"
href=${`${crawlBaseUrl}/review/resources?${searchParams.toString()}`}
?active=${this.tab === "resources"}
@click=${this.onTabNavClick}
>
<sl-icon name="puzzle-fill"></sl-icon>
${msg("Resources")}
</btrix-navigation-button>
<btrix-navigation-button
id="replay-tab"
href=${`${crawlBaseUrl}/review/replay?${searchParams.toString()}`}
?active=${this.tab === "replay"}
@click=${this.onTabNavClick}
>
<sl-icon name="replaywebpage" library="app"></sl-icon>
${msg("Replay")}
</btrix-navigation-button>
</nav>
${this.renderPanelToolbar()} ${this.renderPanel()}
</div>
<section
class="grid--pageList grid grid-rows-[auto_1fr] *:min-h-0 *:min-w-0"
>
<h3
class="my-4 text-base font-semibold leading-none text-neutral-800"
>
${msg("Pages")}
</h3>
<btrix-qa-page-list
class="flex flex-col"
.qaRunId=${this.qaRunId}
.itemPageId=${this.itemPageId}
.pages=${this.pages}
.orderBy=${{
field: this.sortPagesBy.sortBy,
direction: (this.sortPagesBy.sortDirection === -1
? "desc"
: "asc") as SortDirection,
}}
.filterBy=${this.filterPagesBy}
totalPages=${+(this.item?.stats?.done || 0)}
@btrix-qa-pagination-change=${(
e: CustomEvent<QaPaginationChangeDetail>,
) => {
const { page } = e.detail;
void this.fetchPages({ page });
}}
@btrix-qa-page-select=${(e: CustomEvent<string>) => {
this.navToPage(e.detail);
}}
@btrix-qa-filter-change=${(
e: CustomEvent<QaFilterChangeDetail>,
) => {
this.filterPagesBy = {
...this.filterPagesBy,
...e.detail,
};
}}
@btrix-qa-sort-change=${(e: CustomEvent<QaSortChangeDetail>) => {
this.sortPagesBy = {
...this.sortPagesBy,
...e.detail,
};
}}
></btrix-qa-page-list>
</section>
</article>
<btrix-dialog
class="commentDialog"
label=${msg("Page Comments")}
>
${this.renderComments()}
</p>
<sl-button
slot="footer"
size="small"
variant="primary"
@click=${() => this.commentDialog?.submit()}
>
${msg("Submit Comment")}
</sl-button>
</btrix-dialog>
<btrix-dialog
class="reviewDialog [--width:60rem]"
label=${msg("QA Review")}
>
<form class="qaReviewForm" @submit=${this.onReviewSubmit}>
<div class="flex flex-col gap-6 md:flex-row">
<div>
<sl-radio-group
class="mb-5"
name="reviewStatus"
label=${msg("Rate this crawl:")}
value=${this.item?.reviewStatus ?? ""}
required
>
<sl-radio value="5">
<strong class="font-semibold">${msg("Excellent!")}</strong>
<div class="text-xs text-neutral-600">
${msg(
"This archived item perfectly replicates the original pages.",
)}
</div>
</sl-radio>
<sl-radio value="4">
<strong class="font-semibold">${msg("Good")}</strong>
<div class="text-xs text-neutral-600">
${msg(
"Looks and functions nearly the same as the original pages.",
)}
</div>
</sl-radio>
<sl-radio value="3" checked>
<strong class="font-semibold">${msg("Fair")}</strong>
<div class="text-xs text-neutral-600">
${msg(
"Similar to the original pages, but may be missing non-critical content or functionality.",
)}
</div>
</sl-radio>
<sl-radio value="2">
<strong class="font-semibold">${msg("Poor")}</strong>
<div class="text-xs text-neutral-600">
${msg(
"Some similarities with the original pages, but missing critical content or functionality.",
)}
</div>
</sl-radio>
<sl-radio value="1">
<strong class="font-semibold">${msg("Bad")}</strong>
<div class="text-xs text-neutral-600">
${msg(
"Missing all content and functionality from the original pages.",
)}
</div>
</sl-radio>
</sl-radio-group>
</div>
<div class="flex-1 pl-4 md:border-l">
<sl-textarea
label=${msg("Update archived item description?")}
name="description"
value=${this.item?.description ?? ""}
placeholder=${msg("No description")}
></sl-textarea>
</div>
</div>
</form>
<div slot="footer" class="flex justify-between">
<sl-button size="small">${msg("Cancel")}</sl-button>
<sl-button
variant="primary"
size="small"
type="submit"
@click=${() => this.reviewDialog?.submit()}
>
<sl-icon name="patch-check" slot="prefix"></sl-icon>
${msg("Submit Review")}
</sl-button>
</div>
</btrix-dialog>
`;
}
private renderHidden() {
const iframe = (reg?: ServiceWorkerRegistration) =>
when(this.page, () => {
const onLoad = reg
? () => {
void this.fetchContentForTab();
void this.fetchContentForTab({ qa: true });
}
: () => {
console.debug("waiting for post message instead");
};
// Use iframe to access replay content
// Use a 'non-existent' URL on purpose so that RWP itself is not rendered,
// but we need a /replay iframe for proper fetch() to service worker
return html`
<iframe
class="hidden"
id="replayframe"
src="/replay/non-existent"
@load=${onLoad}
></iframe>
`;
});
const rwp = (reg?: ServiceWorkerRegistration) =>
when(
!reg || !this.crawlDataRegistered || !this.qaDataRegistered,
() => html`
<div class="offscreen" aria-hidden="true">
${this.itemId && !this.crawlDataRegistered
? this.renderRWP(this.itemId, { qa: false })
: nothing}
${this.qaRunId && !this.qaDataRegistered
? this.renderRWP(this.qaRunId, { qa: true })
: nothing}
</div>
`,
);
return guard(
[
this.replaySwReg,
this.page,
this.itemId,
this.qaRunId,
this.crawlDataRegistered,
this.qaDataRegistered,
],
() =>
until(
this.replaySwReg.then((reg) => {
return html`${iframe(reg)}${rwp(reg)}`;
}),
),
);
}
private renderComments() {
return html`
${when(
this.page?.notes?.length,
(commentCount) => html`
<btrix-details open>
<span slot="title">
${msg(str`Comments (${commentCount.toLocaleString()})`)}
</span>
<ul>
${this.page?.notes?.map(
(comment) =>
html`<li class="mb-3">
<div
class="flex items-center justify-between rounded-t border bg-neutral-50 text-xs leading-none text-neutral-600"
>
<div class="p-2">
${msg(
str`${comment.userName} commented on ${formatISODateString(
comment.created,
{
hour: undefined,
minute: undefined,
},
)}`,
)}
</div>
<sl-tooltip content=${msg("Delete comment")}>
<sl-icon-button
class="hover:text-danger"
name="trash3"
label=${msg("Delete comment")}
@click=${async () =>
this.deletePageComment(comment.id)}
></sl-icon-button>
</sl-tooltip>
</div>
<div class="rounded-b border-b border-l border-r p-2">
${comment.text}
</div>
</li> `,
)}
</ul>
</btrix-details>
`,
)}
<form @submit=${this.onSubmitComment}>
<sl-textarea
name="pageComment"
label=${msg("Add a comment")}
placeholder=${msg("Enter page feedback")}
minlength="1"
maxlength="500"
></sl-textarea>
</form>
`;
}
private renderPanelToolbar() {
const buttons = html`
${choose(this.tab, [
// [
// "replay",
// () => html`
// <div class="flex">
// <sl-icon-button name="arrow-clockwise"></sl-icon-button>
// </div>
// `,
// ],
[
"screenshots",
() => html`
<div class="flex">
<sl-tooltip
content=${msg("Toggle screenshot wipe view")}
placement="bottom-start"
>
<btrix-button
icon
variant=${!this.splitView ? "primary" : "neutral"}
@click="${() => (this.splitView = !this.splitView)}"
>
<sl-icon name="vr" label=${msg("Split view")}></sl-icon>
</btrix-button>
</sl-tooltip>
</div>
`,
],
])}
`;
return html`
<div
class="${this.tab === "replay"
? "rounded-t-lg"
: "rounded-lg mb-3"} flex h-12 items-center gap-2 border bg-neutral-50 p-2 text-base"
>
${buttons}
<div
class="flex h-8 min-w-0 flex-1 items-center justify-between gap-2 overflow-hidden whitespace-nowrap rounded border bg-neutral-0 px-2 text-sm"
>
<div class="fade-out-r scrollbar-hidden flex-1 overflow-x-scroll">
<span class="pr-2">${this.page?.url || "http://"}</span>
</div>
${when(
this.page,
(page) => html`
<sl-format-date
lang=${getLocale()}
class="font-monostyle text-xs text-neutral-500"
date=${`${page.ts}Z`}
month="2-digit"
day="2-digit"
year="2-digit"
hour="2-digit"
minute="2-digit"
>
</sl-format-date>
`,
)}
</div>
</div>
`;
}
private renderPanel() {
// cache DOM for faster switching between tabs
const choosePanel = () => {
switch (this.tab) {
case "screenshots":
return renderScreenshots(this.crawlData, this.qaData, this.splitView);
case "text":
return renderText(this.crawlData, this.qaData);
case "resources":
return renderResources(this.crawlData, this.qaData);
case "replay":
return renderReplay(this.crawlData);
default:
break;
}
};
return html`
<section aria-labelledby="${this.tab}-tab" class="flex-1 overflow-hidden">
${cache(choosePanel())}
</section>
`;
}
private readonly renderRWP = (
rwpId: string,
{ qa, url }: { qa: boolean; url?: string },
) => {
if (!rwpId) return;
const replaySource = `/api/orgs/${this.orgId}/crawls/${this.itemId}${qa ? `/qa/${rwpId}` : ""}/replay.json`;
const headers = this.authState?.headers;
const config = JSON.stringify({ headers });
console.log("[debug] rendering rwp", rwpId);
return guard(
[rwpId, this.page, this.authState],
() => html`
<replay-web-page
source="${replaySource}"
coll="${rwpId}"
config="${config}"
replayBase="/replay/"
embed="replayonly"
noCache="true"
url="${ifDefined(url)}"
></replay-web-page>
`,
);
};
private readonly onTabNavClick = (e: MouseEvent) => {
this.navigate.link(e, undefined, /* resetScroll: */ false);
};
private async onUpdatePageApproval(e: CustomEvent<UpdatePageApprovalDetail>) {
const updated = e.detail;
if (!this.page || this.page.id !== updated.id) return;
this.page = merge<ArchivedItemQAPage>(this.page, updated);
const reviewStatusChanged = this.page.approved !== updated.approved;
if (reviewStatusChanged) {
void this.fetchPages();
}
}
private async fetchCrawl(): Promise<void> {
try {
this.item = await this.getCrawl();
} catch {
this.notify.toast({
message: msg("Sorry, couldn't retrieve archived item at this time."),
variant: "danger",
icon: "exclamation-octagon",
});
}
}
private navNextPage() {
const [, , nextPage] = this.getPageListSliceByCurrent();
if (nextPage) {
this.navToPage(nextPage.id);
}
}
private navPrevPage() {
const [prevPage] = this.getPageListSliceByCurrent();
if (prevPage) {
this.navToPage(prevPage.id);
}
}
private async onSubmitComment(e: SubmitEvent) {
e.preventDefault();
const value = this.commentTextarea?.value;
if (!value) return;
void this.commentDialog?.hide();
try {
const { data } = await this.api.fetch<{ data: ArchivedItemPageComment }>(
`/orgs/${this.orgId}/crawls/${this.itemId}/pages/${this.itemPageId}/notes`,
this.authState!,
{
method: "POST",
body: JSON.stringify({ text: value }),
},
);
const commentForm = this.commentDialog?.querySelector("form");
if (commentForm) {
commentForm.reset();
}
const comments = [...this.page!.notes!, data];
this.page = merge<ArchivedItemQAPage>(this.page!, { notes: comments });
void this.fetchPages();
} catch (e: unknown) {
void this.commentDialog?.show();
console.debug(e);
this.notify.toast({
message: msg("Sorry, couldn't add comment at this time."),
variant: "danger",
icon: "exclamation-octagon",
});
}
}
private async deletePageComment(commentId: string): Promise<void> {
try {
await this.api.fetch(
`/orgs/${this.orgId}/crawls/${this.itemId}/pages/${this.itemPageId}/notes/delete`,
this.authState!,
{
method: "POST",
body: JSON.stringify({ delete_list: [commentId] }),
},
);
const comments = this.page!.notes!.filter(({ id }) => id !== commentId);
this.page = merge<ArchivedItemQAPage>(this.page!, { notes: comments });
void this.fetchPages();
} catch {
this.notify.toast({
message: msg("Sorry, couldn't delete comment at this time."),
variant: "danger",
icon: "exclamation-octagon",
});
}
}
private async fetchQARuns(): Promise<void> {
try {
this.finishedQARuns = (await this.getQARuns()).filter(({ state }) =>
finishedCrawlStates.includes(state),
);
} catch {
this.notify.toast({
message: msg("Sorry, couldn't retrieve analysis runs at this time."),
variant: "danger",
icon: "exclamation-octagon",
});
}
}
private async getQARuns(): Promise<QARun[]> {
return this.api.fetch<QARun[]>(
`/orgs/${this.orgId}/crawls/${this.itemId}/qa?skipFailed=true`,
this.authState!,
);
}
private async getCrawl(): Promise<ArchivedItem> {
return this.api.fetch<ArchivedItem>(
`/orgs/${this.orgId}/crawls/${this.itemId}`,
this.authState!,
);
}
private async fetchPage(): Promise<void> {
if (!this.itemPageId) return;
try {
this.page = await this.getPage(this.itemPageId);
} catch {
this.notify.toast({
message: msg("Sorry, couldn't retrieve page at this time."),
variant: "danger",
icon: "exclamation-octagon",
});
}
}
private resolveType(url: string, { mime = "", type }: PageResource) {
if (type) {
type = type.toLowerCase();
}
// Map common mime types where important information would be lost
// if we only use first half to more descriptive resource types
if (type === "script" || mime.includes("javascript")) {
return "javascript";
}
if (type === "stylesheet" || mime.includes("css")) {
return "stylesheet";
}
if (type === "image") {
return "image";
}
if (type === "font") {
return "font";
}
if (type === "ping") {
return "other";
}
if (url.endsWith("favicon.ico")) {
return "favicon";
}
let path = "";
try {
path = new URL(url).pathname;
} catch (e) {
// ignore
}
const ext = path.slice(path.lastIndexOf(".") + 1);
if (type === "fetch" || type === "xhr") {
if (IMG_EXTS.includes(ext)) {
return "image";
}
}
if (mime.includes("json") || ext === "json") {
return "json";
}
if (mime.includes("pdf") || ext === "pdf") {
return "pdf";
}
if (
type === "document" ||
mime.includes("html") ||
ext === "html" ||
ext === "htm"
) {
return "html";
}
if (!mime) {
return "other";
}
return mime.split("/")[0];
}
private async fetchContentForTab({ qa } = { qa: false }): Promise<void> {
const page = this.page;
const tab = this.tab;
const sourceId = qa ? this.qaRunId : this.itemId;
const frameWindow = this.replayFrame?.contentWindow;
if (!page || !sourceId || !frameWindow) {
console.log(
"[debug] no page replaId or frameWindow",
page,
sourceId,
frameWindow,
);
return;
}
if (qa && tab === "replay") {
return;
}
const timestamp = page.ts?.split(".")[0].replace(/\D/g, "");
const pageUrl = page.url;
const doLoad = async (isQA: boolean) => {
const urlPrefix = tabToPrefix[tab];
const urlPart = `${timestamp}mp_/${urlPrefix ? `urn:${urlPrefix}:` : ""}${pageUrl}`;
const url = `/replay/w/${sourceId}/${urlPart}`;
// TODO check status code
if (tab === "replay") {
return { replayUrl: url };
}
const resp = await frameWindow.fetch(url);
//console.log("resp:", resp);
if (!resp.ok) {
throw resp.status;
}
if (tab === "screenshots") {
const blob = await resp.blob();
const blobUrl = URL.createObjectURL(blob) || "";
return { blobUrl };
} else if (tab === "text") {
const text = await resp.text();
return { text };
} else {
// tab === "resources"
const json = (await resp.json()) as {
urls: PageResource[];
};
// console.log(json);
const typeMap = new Map<string, QATypes.GoodBad>();
let good = 0,
bad = 0;
for (const [url, entry] of Object.entries(json.urls)) {
const { status = 0, type, mime } = entry;
const resType = this.resolveType(url, entry);
// for debugging
logResource(isQA, resType, url, type, mime, status);
if (!typeMap.has(resType)) {
if (status < 400) {
typeMap.set(resType, { good: 1, bad: 0 });
good++;
} else {
typeMap.set(resType, { good: 0, bad: 1 });
bad++;
}
} else {
const count = typeMap.get(resType);
if (status < 400) {
count!.good++;
good++;
} else {
count!.bad++;
bad++;
}
typeMap.set(resType, count!);
}
}
typeMap.set("Total", { good, bad });
// const text = JSON.stringify(
// Object.fromEntries(typeMap.entries()),
// null,
// 2,
// );
return { resources: Object.fromEntries(typeMap.entries()) };
}
return { text: "" };
};
try {
const content = await doLoad(qa);
if (qa) {
this.qaData = {
...this.qaData,
...content,
};
this.qaDataRegistered = true;
} else {
this.crawlData = {
...this.crawlData,
...content,
};
this.crawlDataRegistered = true;
}
} catch (e: unknown) {
console.log("[debug] error:", e);
// check if this endpoint is registered, if not, ensure re-render
if (e === 404) {
let hasEndpoint = false;
try {
const resp = await frameWindow.fetch(`/replay/w/api/c/${sourceId}`);
hasEndpoint = !!resp.ok;
} catch (e) {
hasEndpoint = false;
}
if (qa) {
this.qaData = hasEndpoint ? {} : null;
this.qaDataRegistered = hasEndpoint;
} else {
this.crawlData = hasEndpoint ? {} : null;
this.crawlDataRegistered = hasEndpoint;
}
}
}
}
private async getPage(pageId: string): Promise<ArchivedItemQAPage> {
return this.api.fetch<ArchivedItemQAPage>(
this.qaRunId
? `/orgs/${this.orgId}/crawls/${this.itemId}/qa/${this.qaRunId}/pages/${pageId}`
: `/orgs/${this.orgId}/crawls/${this.itemId}/pages/${pageId}`,
this.authState!,
);
}
private async fetchPages(params?: APIPaginationQuery): Promise<void> {
try {
this.pages = await this.getPages({
page: params?.page ?? this.pages?.page ?? 1,
pageSize: params?.pageSize ?? this.pages?.pageSize ?? DEFAULT_PAGE_SIZE,
...this.sortPagesBy,
});
} catch {
this.notify.toast({
message: msg("Sorry, couldn't retrieve pages at this time."),
variant: "danger",
icon: "exclamation-octagon",
});
}
}
private async getPages(
params?: APIPaginationQuery & APISortQuery & { reviewed?: boolean },
): Promise<APIPaginatedList<ArchivedItemQAPage>> {
const query = queryString.stringify(
{
...this.filterPagesBy,
...params,
},
{
arrayFormat: "comma",
},
);
return this.api.fetch<APIPaginatedList<ArchivedItemQAPage>>(
`/orgs/${this.orgId}/crawls/${this.itemId ?? ""}/qa/${this.qaRunId ?? ""}/pages?${query}`,
this.authState!,
);
}
private async onReviewSubmit(e: SubmitEvent) {
e.preventDefault();
const form = e.currentTarget as HTMLFormElement;
const params = serialize(form);
if (!params.reviewStatus) {
return;
}
try {
const data = await this.api.fetch<{ updated: boolean }>(
`/orgs/${this.orgId}/all-crawls/${this.itemId}`,
this.authState!,
{
method: "PATCH",
body: JSON.stringify({
reviewStatus: +params.reviewStatus,
description: params.description,
}),
},
);
if (!data.updated) {
throw data;
}
void this.reviewDialog?.hide();
this.navigate.to(
`${this.navigate.orgBasePath}/items/crawl/${this.itemId}#qa`,
);
this.notify.toast({
message: msg("Saved QA review."),
variant: "success",
icon: "check2-circle",
});
} catch (e) {
this.notify.toast({
message: msg("Sorry, couldn't submit QA review at this time."),
variant: "danger",
icon: "exclamation-octagon",
});
}
}
}
// leaving here for further debugging of resources
function logResource(
_isQA: boolean,
_resType: string,
_url: string,
_type?: string,
_mime?: string,
_status = 0,
) {
// console.log(
// _isQA ? "replay" : "crawl",
// _status >= 400 ? "bad" : "good",
// _resType,
// _type,
// _mime,
// _status,
// _url,
// );
}