browsertrix/frontend/src/features/qa/page-list/page-list.ts
2024-04-22 20:53:17 -04:00

337 lines
10 KiB
TypeScript

import { localized, msg, str } from "@lit/localize";
import type { SlChangeEvent, SlSelect } from "@shoelace-style/shoelace";
import { html, type PropertyValues } from "lit";
import { customElement, property, query } from "lit/decorators.js";
import { repeat } from "lit/directives/repeat.js";
import { TailwindElement } from "@/classes/TailwindElement";
import { type PageChangeEvent } from "@/components/ui/pagination";
import type { APIPaginatedList, APISortQuery } from "@/types/api";
import type { ArchivedItemQAPage } from "@/types/qa";
export type SortDirection = "asc" | "desc";
export type SortableFieldNames =
| "textMatch"
| "screenshotMatch"
| "approved"
| "notes";
type SortableFields = Record<
SortableFieldNames,
{
label: string;
defaultDirection?: SortDirection;
}
>;
const sortableFields = {
textMatch: {
label: msg("Text Match"),
defaultDirection: "asc",
},
screenshotMatch: {
label: msg("Screenshot Match"),
defaultDirection: "desc",
},
approved: {
label: msg("Approval"),
// defaultDirection: "asc",
},
notes: {
label: msg("Comments"),
defaultDirection: "desc",
},
// url: {
// label: msg("Page URL"),
// defaultDirection: "desc",
// },
// title: {
// label: msg("Page Title"),
// defaultDirection: "desc",
// },
// timestamp: {
// label: msg("Time"),
// defaultDirection: "asc",
// },
} satisfies SortableFields;
type SortField = keyof typeof sortableFields;
export type OrderBy = {
field: SortField;
direction: SortDirection;
};
export type QaPaginationChangeDetail = {
page: number;
};
export type QaFilterChangeDetail = {
reviewed: undefined | boolean;
approved: undefined | boolean;
hasNotes: undefined | boolean;
};
export type QaSortChangeDetail = APISortQuery & { sortBy: SortableFieldNames };
/**
* @fires btrix-qa-pagination-change
* @fires btrix-qa-filter-change
* @fires btrix-qa-sort-change
*/
@localized()
@customElement("btrix-qa-page-list")
export class PageList extends TailwindElement {
@property({ type: String })
qaRunId?: string;
@property({ type: String })
itemPageId?: string;
@property({ type: Object })
pages?: APIPaginatedList<ArchivedItemQAPage>;
@property({ type: Number })
totalPages = 0;
@property({ type: Object })
orderBy: OrderBy = {
field: "screenshotMatch",
direction: "asc",
};
@property({ type: Object })
filterBy: {
reviewed?: boolean;
approved?: boolean;
hasNotes?: boolean;
} = {};
@query(".scrollContainer")
private readonly scrollContainer?: HTMLElement | null;
protected async updated(changedProperties: PropertyValues<this>) {
if (
changedProperties.has("pages") &&
changedProperties.get("pages") &&
this.pages
) {
this.scrollContainer?.scrollTo({ top: 0, left: 0 });
}
}
render() {
return html`
<div
class="z-40 mb-1 flex flex-wrap items-center gap-2 rounded-lg border bg-neutral-50 p-2"
>
${this.renderSortControl()} ${this.renderFilterControl()}
</div>
<div
class="scrollContainer relative -mx-2 overflow-y-auto overscroll-contain px-2"
>
${this.pages?.total
? html`
<div
class="sticky top-0 z-30 bg-gradient-to-b from-white to-white/85 backdrop-blur-sm"
>
<div class="mb-0.5 ml-2 border-b py-1 text-xs text-neutral-500">
${this.pages.total === this.totalPages
? msg(
str`Showing all ${this.totalPages.toLocaleString()} pages`,
)
: msg(
str`Showing ${this.pages.total.toLocaleString()} of ${this.totalPages.toLocaleString()} pages`,
)}
</div>
</div>
${repeat(
this.pages.items,
({ id }) => id,
(page: ArchivedItemQAPage) => html`
<btrix-qa-page
class="is-leaf -my-4 scroll-my-8 py-4 first-of-type:mt-0 last-of-type:mb-0"
.page=${page}
statusField=${this.orderBy.field === "notes"
? "approved"
: this.orderBy.field}
?selected=${page.id === this.itemPageId}
>
</btrix-qa-page>
`,
)}
<div class="my-2 flex justify-center">
<btrix-pagination
page=${this.pages.page}
totalCount=${this.pages.total}
size=${this.pages.pageSize}
compact
@page-change=${(e: PageChangeEvent) => {
e.stopPropagation();
this.dispatchEvent(
new CustomEvent<QaPaginationChangeDetail>(
"btrix-qa-pagination-change",
{
detail: { page: e.detail.page },
},
),
);
}}
>
</btrix-pagination>
</div>
<div
class="sticky bottom-0 z-30 h-4 bg-gradient-to-t from-white to-white/0"
></div>
`
: html`<div
class="flex flex-col items-center justify-center gap-4 py-8 text-xs text-gray-600"
>
<sl-icon name="slash-circle"></sl-icon>
${msg("No matching pages found")}
</div>`}
</div>
`;
}
private renderSortControl() {
return html`
<div class="flex w-full grow items-center md:w-fit">
<sl-select
class="label-same-line flex-1"
label=${msg("Sort by:")}
size="small"
pill
value="worstScreenshotMatch"
@sl-change=${(e: Event) => {
const { value } = e.target as SlSelect;
const detail: QaSortChangeDetail = {
sortBy: this.orderBy.field,
sortDirection: this.orderBy.direction === "asc" ? 1 : -1,
};
switch (value) {
case "bestScreenshotMatch":
detail.sortBy = "screenshotMatch";
detail.sortDirection = -1;
break;
case "worstScreenshotMatch":
detail.sortBy = "screenshotMatch";
detail.sortDirection = 1;
break;
case "bestTextMatch":
detail.sortBy = "textMatch";
detail.sortDirection = -1;
break;
case "worstTextMatch":
detail.sortBy = "textMatch";
detail.sortDirection = 1;
break;
case "approved":
detail.sortBy = "approved";
detail.sortDirection = -1;
break;
case "notApproved":
detail.sortBy = "approved";
detail.sortDirection = 1;
break;
case "comments":
detail.sortBy = "notes";
detail.sortDirection = -1;
break;
// case "url":
// detail.sortBy = "url";
// detail.sortDirection = 1;
// break;
// case "title":
// detail.sortBy = "title";
// detail.sortDirection = 1;
// break;
default:
break;
}
this.dispatchEvent(
new CustomEvent<QaSortChangeDetail>("btrix-qa-sort-change", {
detail,
}),
);
}}
>
<sl-option value="bestScreenshotMatch"
>${msg("Best Screenshot Match")}</sl-option
>
<sl-option value="worstScreenshotMatch"
>${msg("Worst Screenshot Match")}</sl-option
>
<sl-option value="bestTextMatch"
>${msg("Best Extracted Text Match")}</sl-option
>
<sl-option value="worstTextMatch"
>${msg("Worst Extracted Text Match")}</sl-option
>
<sl-option value="comments">${msg("Most Comments")}</sl-option>
<sl-option value="approved">${msg("Recently Approved")}</sl-option>
<sl-option value="notApproved">${msg("Not Approved")}</sl-option>
</sl-select>
</div>
`;
}
private renderFilterControl() {
const value = () => {
if (this.filterBy.approved) return "approved";
if (this.filterBy.approved === false) return "rejected";
if (this.filterBy.reviewed) return "reviewed";
if (this.filterBy.reviewed === false) return "notReviewed";
if (this.filterBy.hasNotes) return "hasNotes";
return "";
};
return html`
<div class="w-full">
<sl-select
class="label-same-line"
label=${msg("Approval:")}
value=${value()}
@sl-change=${(e: SlChangeEvent) => {
const { value } = e.target as SlSelect;
const detail: QaFilterChangeDetail = {
reviewed: undefined,
approved: undefined,
hasNotes: undefined,
};
switch (value) {
case "notReviewed":
detail.reviewed = false;
break;
case "reviewed":
detail.reviewed = true;
break;
case "approved":
detail.approved = true;
break;
case "rejected":
detail.approved = false;
break;
case "hasNotes":
detail.hasNotes = true;
break;
default:
break;
}
this.dispatchEvent(
new CustomEvent<QaFilterChangeDetail>("btrix-qa-filter-change", {
detail,
}),
);
}}
pill
size="small"
>
<sl-option value="">${msg("Any")}</sl-option>
<sl-option value="notReviewed">${msg("None")}</sl-option>
<sl-option value="reviewed"
>${msg("Approved, Rejected, or Commented")}</sl-option
>
<sl-option value="approved">${msg("Approved")}</sl-option>
<sl-option value="rejected">${msg("Rejected")}</sl-option>
<sl-option value="hasNotes">${msg("Commented")}</sl-option>
</sl-select>
</div>
`;
}
}