Manually approve pages in QA review (#1576)

- Automatically update view to first page if page ID isn't specified
- Show current page URL in location bar (resolves
https://github.com/webrecorder/browsertrix-cloud/issues/1495)
- Approve, reject, or leave notes on a page
- Display temporary list of links to pages in the sidebar
This commit is contained in:
sua yoo 2024-03-12 10:08:51 -07:00 committed by GitHub
parent 8ba29ca776
commit 9f312c075e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 731 additions and 42 deletions

View File

@ -1,7 +1,7 @@
import { css } from "lit";
import SlDialog from "@shoelace-style/shoelace/dist/components/dialog/dialog.js";
import dialogStyles from "@shoelace-style/shoelace/dist/components/dialog/dialog.styles.js";
import { customElement } from "lit/decorators.js";
import { customElement, queryAssignedElements } from "lit/decorators.js";
/**
* <sl-dialog> with custom CSS
@ -55,4 +55,20 @@ export class Dialog extends SlDialog {
// same version of `@lit/reactive-element` as Shoelace -- at the time of
// writing, that's `@lit/reactive-element@2.0.2`)
] as typeof SlDialog.styles;
@queryAssignedElements({ selector: "form", flatten: true })
readonly formElems!: HTMLFormElement[];
/**
* Submit form using external buttons to bypass
* incorrect `getRootNode` in Chrome.
*
* TODO refactor dialog instances that self implements `form.requestSubmit`
*/
submit = () => {
const form = this.formElems[0];
if (!form) return;
form.requestSubmit();
};
}

View File

@ -3,3 +3,4 @@ import "./archived-items";
import "./browser-profiles";
import "./collections";
import "./crawl-workflows";
import "./qa";

View File

@ -0,0 +1 @@
import("./page-qa-toolbar");

View File

@ -0,0 +1,445 @@
import { type PropertyValues, css, html } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
import { keyed } from "lit/directives/keyed.js";
import { when } from "lit/directives/when.js";
import { localized, msg, str } from "@lit/localize";
import type { SlTextarea } from "@shoelace-style/shoelace";
import { merge } from "immutable";
import { TailwindElement } from "@/classes/TailwindElement";
import type { Dialog } from "@/components/ui/dialog";
import { APIController } from "@/controllers/api";
import { NotifyController } from "@/controllers/notify";
import { type AuthState } from "@/utils/AuthService";
import type { ArchivedItemPage } from "@/types/crawler";
/**
* Manage crawl QA page review
*/
@localized()
@customElement("btrix-page-qa-toolbar")
export class PageQAToolbar extends TailwindElement {
static styles = css`
:host {
--btrix-border: 1px solid var(--sl-color-neutral-300);
--btrix-border-radius: var(--sl-border-radius-medium);
}
.btnGroup {
display: flex;
align-items: stretch;
justify-content: stretch;
outline: var(--btrix-border);
outline-offset: -1px;
box-shadow: var(--sl-shadow-x-small);
border-radius: var(--sl-input-height-small);
height: var(--sl-input-height-small);
transition: var(--sl-transition-x-fast) background-color;
}
.btnGroup.approved {
background-color: var(--sl-color-success-500);
}
.btnGroup.rejected {
background-color: var(--sl-color-danger-500);
}
.btnGroup.disabled button {
opacity: 0.3;
cursor: not-allowed;
}
.btnGroup button {
display: flex;
align-items: center;
justify-content: center;
height: var(--sl-input-height-small);
width: 4rem;
border-top: var(--btrix-border);
border-bottom: var(--btrix-border);
transition:
var(--sl-transition-x-fast) background-color,
var(--sl-transition-x-fast) border,
var(--sl-transition-x-fast) border-radius,
var(--sl-transition-x-fast) box-shadow,
var(--sl-transition-x-fast) transform;
}
.btnGroup button:first-of-type {
border-left: var(--btrix-border);
border-right: 1px solid transparent;
border-start-start-radius: var(--sl-input-height-small);
border-end-start-radius: var(--sl-input-height-small);
}
.btnGroup button:nth-of-type(2) {
border-left: 1px solid transparent;
border-right: 1px solid transparent;
}
.btnGroup button:last-of-type {
border-left: 1px solid transparent;
border-right: var(--btrix-border);
border-start-end-radius: var(--sl-input-height-small);
border-end-end-radius: var(--sl-input-height-small);
}
.rate.active {
color: var(--sl-color-neutral-0);
}
.rate.active:hover {
background-color: rgba(255, 255, 255, 0.15);
}
.rate:not(.active),
.comment {
background-color: var(--sl-color-neutral-0);
}
.btnGroup:not(.disabled) .rate:not(.active):hover {
border: var(--btrix-border);
box-shadow: var(--sl-shadow-x-small);
transform: scale(1.1);
}
.rate:first-of-type:not(.active):hover {
border-start-end-radius: var(--sl-border-radius-large);
border-end-end-radius: var(--sl-border-radius-large);
}
.rate:last-of-type:not(.active):hover {
border-start-start-radius: var(--sl-border-radius-large);
border-end-start-radius: var(--sl-border-radius-large);
}
.btnGroup:not(.disabled) .approve:not(.active):hover sl-icon {
color: var(--sl-color-success-500);
}
.btnGroup:not(.disabled) .reject:not(.active):hover sl-icon {
color: var(--sl-color-danger-500);
}
.btnGroup:not(.disabled) .comment:hover sl-icon {
transform: scale(1.1);
color: var(--sl-color-blue-500);
}
.comment.active sl-icon {
color: var(--sl-color-blue-500);
}
.btnGroup:has(button.active:first-of-type) button:nth-of-type(2) {
border-left: var(--btrix-border);
border-start-start-radius: var(--btrix-border-radius);
border-end-start-radius: var(--btrix-border-radius);
}
.btnGroup:has(button.active:last-of-type) button:nth-of-type(2) {
border-right: var(--btrix-border);
border-start-end-radius: var(--btrix-border-radius);
border-end-end-radius: var(--btrix-border-radius);
}
.btnGroup button:nth-of-type(2) {
border-left: var(--btrix-border);
border-right: var(--btrix-border);
}
.btnGroup sl-icon {
font-size: var(--font-size-base);
transition:
var(--sl-transition-x-fast) color,
var(--sl-transition-x-fast) transform;
}
`;
@property({ type: Object })
authState?: AuthState;
@property({ type: String })
orgId?: string;
@property({ type: String })
itemId?: string;
@property({ type: String })
pageId?: string;
@state()
private page?: ArchivedItemPage;
@state()
private showComments = false;
@query("btrix-dialog")
private readonly dialog!: Dialog;
@query('sl-textarea[name="pageComment"]')
private readonly textarea!: SlTextarea;
private readonly api = new APIController(this);
private readonly notify = new NotifyController(this);
protected willUpdate(
changedProperties: PropertyValues<this> | Map<PropertyKey, unknown>,
): void {
if (changedProperties.has("pageId") && this.pageId) {
void this.fetchPage();
}
}
render() {
const disabled = !this.page;
const approved = this.page?.approved === true;
const rejected = this.page?.approved === false;
const commented = Boolean(this.page?.notes?.length);
return html`
<fieldset
class=${classMap({
btnGroup: true,
approved: approved,
commented: commented,
rejected: rejected,
disabled: disabled,
})}
aria-label=${msg("QA rating")}
?disabled=${disabled}
>
<button
class=${classMap({
rate: true,
approve: true,
active: approved,
})}
aria-checked=${approved}
?disabled=${disabled}
@click=${async () =>
this.submitReview({ approved: approved ? null : true })}
>
<sl-icon name="hand-thumbs-up" label=${msg("Approve")}></sl-icon>
</button>
<button
role="checkbox"
class=${classMap({
comment: true,
active: commented,
})}
aria-checked=${commented}
?disabled=${disabled}
@click=${() => (this.showComments = true)}
>
<sl-icon name="chat-square-text" label=${msg("Comment")}></sl-icon>
</button>
<button
class=${classMap({
rate: true,
reject: true,
active: rejected,
})}
aria-checked=${rejected}
?disabled=${disabled}
@click=${async () =>
this.submitReview({ approved: rejected ? null : false })}
>
<sl-icon name="hand-thumbs-down" label=${msg("Reject")}></sl-icon>
</button>
</fieldset>
<btrix-dialog
label=${msg("Page Review Comments")}
?open=${this.showComments}
@sl-hide=${() => (this.showComments = false)}
@sl-after-hide=${async () => this.fetchPage()}
>
${keyed(this.showComments, this.renderComments())}
</p>
<sl-button
slot="footer"
size="small"
variant="primary"
@click=${() => this.dialog.submit()}
>
${msg("Submit Comment")}
</sl-button>
</btrix-dialog>
`;
}
private renderComments() {
const comments = this.page?.notes || [];
return html`
${when(
comments.length,
() => html`
<btrix-details>
<span slot="title"
>${msg(str`Comments (${comments.length.toLocaleString()})`)}</span
>
${when(
this.page?.notes,
(notes) => html`
<ul>
${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 ${new Date(comment.created + "Z").toLocaleDateString()}`,
)}
</div>
<sl-icon-button
class="hover:text-danger"
name="trash3"
@click=${async () => this.deleteComment(comment.id)}
></sl-icon-button>
</div>
<div class="rounded-b border-b border-l border-r p-2">
${comment.text}
</div>
</li> `,
)}
</ul>
`,
() => html`
<p class="text-neutral-500">
${msg("This page doesn't have any comments.")}
</p>
`,
)}
</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 async submitReview({
approved,
}: {
approved: ArchivedItemPage["approved"];
}) {
if (!this.page) return;
try {
await this.api.fetch(
`/orgs/${this.orgId}/crawls/${this.itemId}/pages/${this.page.id}`,
this.authState!,
{
method: "PATCH",
body: JSON.stringify({ approved }),
},
);
this.page = merge<ArchivedItemPage>(this.page, { approved });
this.notify.toast({
message: msg("Updated page review."),
variant: "success",
icon: "check2-circle",
});
} catch (e: unknown) {
console.debug(e);
this.notify.toast({
message: msg("Sorry, couldn't submit page review at this time."),
variant: "danger",
icon: "exclamation-octagon",
});
}
}
private async onSubmitComment(e: SubmitEvent) {
e.preventDefault();
const value = this.textarea.value;
if (!value) return;
try {
await this.api.fetch(
`/orgs/${this.orgId}/crawls/${this.itemId}/pages/${this.pageId}/notes`,
this.authState!,
{
method: "POST",
body: JSON.stringify({ text: value }),
},
);
this.showComments = false;
this.notify.toast({
message: msg("Updated comments."),
variant: "success",
icon: "check2-circle",
});
} catch (e: unknown) {
console.debug(e);
this.notify.toast({
message: msg("Sorry, couldn't add comment at this time."),
variant: "danger",
icon: "exclamation-octagon",
});
}
}
private async deleteComment(commentId: string): Promise<void> {
try {
await this.api.fetch(
`/orgs/${this.orgId}/crawls/${this.itemId}/pages/${this.pageId}/notes/delete`,
this.authState!,
{
method: "POST",
body: JSON.stringify({ delete_list: [commentId] }),
},
);
this.fetchPage();
this.notify.toast({
message: msg("Successfully deleted comment."),
variant: "success",
icon: "check2-circle",
});
} catch {
this.notify.toast({
message: msg("Sorry, couldn't delete comment at this time."),
variant: "danger",
icon: "exclamation-octagon",
});
}
}
private async fetchPage(): Promise<void> {
if (!this.pageId) return;
try {
this.page = await this.getPage(this.pageId);
} catch {
this.notify.toast({
message: msg("Sorry, couldn't retrieve archived item at this time."),
variant: "danger",
icon: "exclamation-octagon",
});
}
}
private async getPage(pageId: string): Promise<ArchivedItemPage> {
return this.api.fetch<ArchivedItemPage>(
`/orgs/${this.orgId}/crawls/${this.itemId}/pages/${pageId}`,
this.authState!,
);
}
}

View File

@ -1,7 +1,15 @@
import { html, css, nothing, type PropertyValues } from "lit";
import {
html,
css,
nothing,
type PropertyValues,
type TemplateResult,
} from "lit";
import { state, property, customElement } from "lit/decorators.js";
import { msg, localized } from "@lit/localize";
import { choose } from "lit/directives/choose.js";
import { when } from "lit/directives/when.js";
import queryString from "query-string";
import { TailwindElement } from "@/classes/TailwindElement";
import { type AuthState } from "@/utils/AuthService";
@ -10,9 +18,11 @@ import { NavigateController } from "@/controllers/navigate";
import { APIController } from "@/controllers/api";
import { NotifyController } from "@/controllers/notify";
import { renderName } from "@/utils/crawler";
import { type ArchivedItem } from "@/types/crawler";
import type { ArchivedItem, ArchivedItemPage } from "@/types/crawler";
import type { APIPaginationQuery, APIPaginatedList } from "@/types/api";
export type QATab = "screenshots" | "replay";
const TABS = ["screenshots", "replay"] as const;
export type QATab = (typeof TABS)[number];
@localized()
@customElement("btrix-archived-item-qa")
@ -33,6 +43,7 @@ export class ArchivedItemQA extends TailwindElement {
"main"
"pageListHeader"
"pageList";
grid-template-columns: 100%;
grid-template-rows: repeat(4, max-content);
}
@ -41,7 +52,7 @@ export class ArchivedItemQA extends TailwindElement {
grid-template:
"mainHeader pageListHeader"
"main pageList";
grid-template-columns: 1fr 24rem;
grid-template-columns: 75% 1fr;
grid-template-rows: min-content 1fr;
}
}
@ -72,6 +83,9 @@ export class ArchivedItemQA extends TailwindElement {
@property({ type: String })
itemId?: string;
@property({ type: String })
itemPageId?: string;
@property({ type: Boolean })
isCrawler = false;
@ -81,6 +95,12 @@ export class ArchivedItemQA extends TailwindElement {
@state()
private item?: ArchivedItem;
@state()
private pages?: APIPaginatedList<ArchivedItemPage>;
@state()
private page?: ArchivedItemPage;
private readonly api = new APIController(this);
private readonly navigate = new NavigateController(this);
private readonly notify = new NotifyController(this);
@ -89,15 +109,34 @@ export class ArchivedItemQA extends TailwindElement {
changedProperties: PropertyValues<this> | Map<PropertyKey, unknown>,
): void {
if (changedProperties.has("itemId") && this.itemId) {
void this.fetchArchivedItem();
void this.initItem();
}
if (changedProperties.has("itemPageId") && this.itemPageId) {
void this.fetchPage();
}
}
private async initItem() {
void this.fetchCrawl();
await this.fetchPages({ page: 1 });
const firstPage = this.pages?.items[0];
if (!this.itemPageId && firstPage) {
this.navigate.to(
`${window.location.pathname}?itemPageId=${firstPage.id}`,
);
}
}
render() {
if (!this.pages) {
return html`loading pages...`;
}
const crawlBaseUrl = `${this.navigate.orgBasePath}/items/crawl/${this.itemId}`;
const itemName = this.item ? renderName(this.item) : nothing;
return html`
<nav class="mb-7">
<nav class="mb-7 text-success-600">
<a
class="text-sm font-medium text-neutral-500 hover:text-neutral-600"
href=${`${crawlBaseUrl}`}
@ -118,43 +157,151 @@ export class ArchivedItemQA extends TailwindElement {
<h1>${msg("Review")} &mdash; ${itemName}</h1>
</header>
<section class="main outline">
<nav>
<btrix-navigation-button
id="screenshot-tab"
href=${`${crawlBaseUrl}/review/screenshots`}
.active=${this.tab === "screenshots"}
size="small"
@click=${this.navigate.link}
>${msg("Screenshots")}</btrix-navigation-button
>
<btrix-navigation-button
id="replay-tab"
href=${`${crawlBaseUrl}/review/replay`}
.active=${this.tab === "replay"}
size="small"
@click=${this.navigate.link}
>${msg("Replay")}</btrix-navigation-button
>
<nav class="flex items-center justify-between p-2">
<div class="flex gap-4">
<btrix-navigation-button
id="screenshot-tab"
href=${`${crawlBaseUrl}/review/screenshots`}
?active=${this.tab === "screenshots"}
@click=${this.navigate.link}
>
${msg("Screenshots")}
</btrix-navigation-button>
<btrix-navigation-button
id="replay-tab"
href=${`${crawlBaseUrl}/review/replay`}
?active=${this.tab === "replay"}
@click=${this.navigate.link}
>
${msg("Replay")}
</btrix-navigation-button>
</div>
<div class="flex gap-4">
<sl-button size="small">
<sl-icon slot="prefix" name="arrow-left"></sl-icon>
${msg("Previous Page")}
</sl-button>
<btrix-page-qa-toolbar
.authState=${this.authState}
.orgId=${this.orgId}
.itemId=${this.itemId}
.pageId=${this.itemPageId}
></btrix-page-qa-toolbar>
<sl-button variant="primary" size="small">
<sl-icon slot="suffix" name="arrow-right"></sl-icon>
${msg("Next Page")}
</sl-button>
</div>
</nav>
<div role="region" aria-labelledby="${this.tab}-tab">
${choose(
this.tab,
[
["screenshots", this.renderScreenshots],
["replay", this.renderReplay],
],
() => html`<btrix-not-found></btrix-not-found>`,
)}
</div>
${this.renderToolbar()} ${this.renderSections()}
</section>
<h2 class="pageListHeader outline">
${msg("Pages List")} <sl-button>${msg("Finish Review")}</sl-button>
</h2>
<section class="pageList outline">[page list]</section>
<section class="pageList outline">
<ul>
${this.pages.items.map(
(page) => html`
<li>
<a
class="underline"
href="${window.location.pathname}?itemPageId=${page.id}"
@click=${this.navigate.link}
>
id: ${page.id}</a
>
</li>
`,
)}
</ul>
pg ${this.pages.page} of
${this.pages
? Math.ceil(this.pages.total / this.pages.pageSize)
: "unknown"}
</section>
</article>
`;
}
private renderToolbar() {
return html`
<div
class="${this.tab === "replay"
? "rounded-t-lg"
: "rounded-lg"} my-2 flex h-12 items-center border bg-neutral-50 text-base"
>
<div class="ml-1 flex">
${choose(this.tab, [
[
"replay",
() => html`
<sl-icon-button name="arrow-clockwise"></sl-icon-button>
`,
],
[
"screenshots",
() => html`
<sl-icon-button name="intersect"></sl-icon-button>
<sl-icon-button name="vr"></sl-icon-button>
`,
],
])}
</div>
<div
class="mx-1.5 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
class="font-monostyle text-xs text-neutral-500"
date=${`${page.timestamp}Z`}
month="2-digit"
day="2-digit"
year="2-digit"
hour="2-digit"
minute="2-digit"
>
</sl-format-date>
`,
)}
</div>
</div>
`;
}
private renderSections() {
const tabSection: Record<
QATab,
{ render: () => TemplateResult<1> | undefined }
> = {
screenshots: {
render: this.renderScreenshots,
},
replay: {
render: this.renderReplay,
},
};
return html`
${TABS.map((tab) => {
const section = tabSection[tab];
const isActive = tab === this.tab;
return html`
<section
class="${isActive ? "" : "invisible absolute -top-full -left-full"}"
aria-labelledby="${this.tab}-tab"
aria-hidden=${!isActive}
>
${section.render()}
</section>
`;
})}
`;
}
private readonly renderScreenshots = () => {
return html`[screenshots]`;
};
@ -163,9 +310,9 @@ export class ArchivedItemQA extends TailwindElement {
return html`[replay]`;
};
private async fetchArchivedItem(): Promise<void> {
private async fetchCrawl(): Promise<void> {
try {
this.item = await this.getArchivedItem();
this.item = await this.getCrawl();
} catch {
this.notify.toast({
message: msg("Sorry, couldn't retrieve archived item at this time."),
@ -175,8 +322,58 @@ export class ArchivedItemQA extends TailwindElement {
}
}
private async getArchivedItem(): Promise<ArchivedItem> {
const apiPath = `/orgs/${this.orgId}/all-crawls/${this.itemId}`;
return this.api.fetch<ArchivedItem>(apiPath, this.authState!);
private async fetchPages(params?: APIPaginationQuery): Promise<void> {
try {
this.pages = await this.getPages(params);
} catch {
this.notify.toast({
message: msg("Sorry, couldn't retrieve archived item at this time."),
variant: "danger",
icon: "exclamation-octagon",
});
}
}
private async getCrawl(): Promise<ArchivedItem> {
return this.api.fetch<ArchivedItem>(
`/orgs/${this.orgId}/crawls/${this.itemId}`,
this.authState!,
);
}
private async getPages(
params?: APIPaginationQuery,
): Promise<APIPaginatedList<ArchivedItemPage>> {
const query = queryString.stringify(
{
...params,
},
{
arrayFormat: "comma",
},
);
return this.api.fetch<APIPaginatedList<ArchivedItemPage>>(
`/orgs/${this.orgId}/crawls/${this.itemId}/pages?${query}`,
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 archived item at this time."),
variant: "danger",
icon: "exclamation-octagon",
});
}
}
private async getPage(pageId: string): Promise<ArchivedItemPage> {
return this.api.fetch<ArchivedItemPage>(
`/orgs/${this.orgId}/crawls/${this.itemId}/pages/${pageId}`,
this.authState!,
);
}
}

View File

@ -52,6 +52,7 @@ export type OrgParams = {
items: {
itemType?: Crawl["type"];
itemId?: string;
itemPageId?: string;
qaTab?: QATab;
workflowId?: string;
collectionId?: string;
@ -529,6 +530,7 @@ export class Org extends LiteElement {
.authState=${this.authState!}
orgId=${this.orgId}
itemId=${params.itemId}
itemPageId=${ifDefined(params.itemPageId)}
tab=${params.qaTab}
?isCrawler=${this.isCrawler}
></btrix-archived-item-qa>`;

View File

@ -91,6 +91,9 @@
--sl-input-help-text-font-size-medium: var(--sl-font-size-x-small);
--sl-shadow-x-small: 0px 1px 2px rgba(0, 0, 0, 0.15);
/* Transition */
--sl-transition-x-fast: 100ms;
}
body {
@ -264,6 +267,22 @@
visibility: hidden;
clip: rect(0 0 0 0);
}
.scrollbar-hidden {
scrollbar-width: none;
}
.scrollbar-hidden::-webkit-scrollbar {
display: none;
}
.fade-out-r {
mask-image: linear-gradient(
to right,
var(--sl-panel-background-color) calc(100% - 1rem),
transparent
);
}
}
@layer components {
@ -284,7 +303,15 @@
}
}
/* Following styles won't work with layers */
.sl-toast-stack {
bottom: 0;
top: auto;
}
/* Ensure buttons in shadow dom inherit hover color */
[class^="hover\:text-"]::part(base):hover,
[class*=" hover\:text-"]::part(base):hover {
color: inherit;
}

View File

@ -215,6 +215,6 @@ export type ArchivedItemPage = {
>;
userid?: string;
modified?: string;
approved?: boolean;
approved?: boolean | null;
notes?: ArchivedItemPageComment[];
};

View File

@ -50,7 +50,7 @@ export function renderName(item: ArchivedItem | Workflow) {
}
}
return html`
<div class="flex overflow-hidden whitespace-nowrap">
<div class="inline-flex overflow-hidden whitespace-nowrap">
<span class="min-w-0 truncate">${item.firstSeed}</span>
<span>${nameSuffix}</span>
</div>