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:
parent
8ba29ca776
commit
9f312c075e
@ -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();
|
||||
};
|
||||
}
|
||||
|
||||
@ -3,3 +3,4 @@ import "./archived-items";
|
||||
import "./browser-profiles";
|
||||
import "./collections";
|
||||
import "./crawl-workflows";
|
||||
import "./qa";
|
||||
|
||||
1
frontend/src/features/qa/index.ts
Normal file
1
frontend/src/features/qa/index.ts
Normal file
@ -0,0 +1 @@
|
||||
import("./page-qa-toolbar");
|
||||
445
frontend/src/features/qa/page-qa-toolbar.ts
Normal file
445
frontend/src/features/qa/page-qa-toolbar.ts
Normal 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!,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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")} — ${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!,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>`;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -215,6 +215,6 @@ export type ArchivedItemPage = {
|
||||
>;
|
||||
userid?: string;
|
||||
modified?: string;
|
||||
approved?: boolean;
|
||||
approved?: boolean | null;
|
||||
notes?: ArchivedItemPageComment[];
|
||||
};
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user