List web captures in Collection (#1024)

- Adds tab for "Web Captures" in Collection detail view
- Move Collection description under Replay section
- Fixes app reloading when clicking into a Collection
- Standardizes Web Capture list headers from "Finished -> "Created Date"
This commit is contained in:
sua yoo 2023-08-01 09:14:27 -07:00 committed by GitHub
parent 06cf9c7cc3
commit 54e2b2c703
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 284 additions and 78 deletions

View File

@ -129,6 +129,7 @@ export class Button extends LitElement {
raised: this.raised, raised: this.raised,
})} })}
?disabled=${this.disabled} ?disabled=${this.disabled}
href=${ifDefined(this.href)}
aria-label=${ifDefined(this.label)} aria-label=${ifDefined(this.label)}
@click=${this.handleClick} @click=${this.handleClick}
> >

View File

@ -226,9 +226,8 @@ export class CrawlListItem extends LitElement {
return html`<a return html`<a
class="item row" class="item row"
role="button" role="button"
href="${this.baseUrl || `/orgs/${this.crawl?.oid}/artifacts/${typePath}`}/${ href="${this.baseUrl ||
this.crawl?.id `/orgs/${this.crawl?.oid}/artifacts/${typePath}`}/${this.crawl?.id}"
}"
@click=${async (e: MouseEvent) => { @click=${async (e: MouseEvent) => {
e.preventDefault(); e.preventDefault();
await this.updateComplete; await this.updateComplete;
@ -521,15 +520,9 @@ export class CrawlList extends LitElement {
<div class="col"> <div class="col">
<slot name="idCol">${msg("Name")}</slot> <slot name="idCol">${msg("Name")}</slot>
</div> </div>
<div class="col"> <div class="col">${msg("Date Created")}</div>
${this.artifactType === "upload" ? msg("Uploaded") : msg("Finished")}
</div>
<div class="col">${msg("Size")}</div> <div class="col">${msg("Size")}</div>
<div class="col"> <div class="col">${msg("Created By")}</div>
${this.artifactType === "upload"
? msg("Uploaded By")
: msg("Started By")}
</div>
<div class="col action"> <div class="col action">
<span class="srOnly">${msg("Actions")}</span> <span class="srOnly">${msg("Actions")}</span>
</div> </div>

View File

@ -8,7 +8,9 @@ export class NotFound extends LitElement {
} }
render() { render() {
return html` return html`
<div class="text-xl text-gray-400">${msg("Page not found")}</div> <div class="text-xl text-gray-400 text-center">
${msg("Page not found")}
</div>
`; `;
} }
} }

View File

@ -1,14 +1,25 @@
import { state, property } from "lit/decorators.js"; import { state, property } from "lit/decorators.js";
import { msg, localized, str } from "@lit/localize"; import { msg, localized, str } from "@lit/localize";
import { choose } from "lit/directives/choose.js";
import { when } from "lit/directives/when.js"; import { when } from "lit/directives/when.js";
import { guard } from "lit/directives/guard.js"; import { guard } from "lit/directives/guard.js";
import queryString from "query-string";
import type { AuthState } from "../../utils/AuthService"; import type { AuthState } from "../../utils/AuthService";
import LiteElement, { html } from "../../utils/LiteElement"; import LiteElement, { html } from "../../utils/LiteElement";
import type { Collection } from "../../types/collection"; import type { Collection } from "../../types/collection";
import type { IntersectEvent } from "../../components/observable"; import type {
APIPaginatedList,
APIPaginationQuery,
APISortQuery,
} from "../../types/api";
import type { Crawl, CrawlState, Upload } from "../../types/crawler";
import type { PageChangeEvent } from "../../components/pagination";
const ABORT_REASON_THROTTLE = "throttled";
const DESCRIPTION_MAX_HEIGHT_PX = 200; const DESCRIPTION_MAX_HEIGHT_PX = 200;
const TABS = ["replay", "web-captures"] as const;
export type Tab = (typeof TABS)[number];
@localized() @localized()
export class CollectionDetail extends LiteElement { export class CollectionDetail extends LiteElement {
@ -21,26 +32,46 @@ export class CollectionDetail extends LiteElement {
@property({ type: String }) @property({ type: String })
collectionId!: string; collectionId!: string;
@property({ type: String })
resourceTab?: Tab = TABS[0];
@property({ type: Boolean }) @property({ type: Boolean })
isCrawler?: boolean; isCrawler?: boolean;
@state() @state()
private collection?: Collection; private collection?: Collection;
@state()
private webCaptures?: APIPaginatedList;
@state() @state()
private openDialogName?: "delete"; private openDialogName?: "delete";
@state()
private isDialogVisible: boolean = false;
@state() @state()
private isDescriptionExpanded = false; private isDescriptionExpanded = false;
// Use to cancel requests
private getWebCapturesController: AbortController | null = null;
private readonly tabLabels: Record<Tab, { icon: any; text: string }> = {
replay: {
icon: { name: "link-replay", library: "app" },
text: msg("Replay"),
},
"web-captures": {
icon: { name: "list-ul", library: "default" },
text: msg("Web Captures"),
},
};
protected async willUpdate(changedProperties: Map<string, any>) { protected async willUpdate(changedProperties: Map<string, any>) {
if (changedProperties.has("orgId")) { if (changedProperties.has("orgId")) {
this.collection = undefined; this.collection = undefined;
this.fetchCollection(); this.fetchCollection();
} }
if (changedProperties.has("collectionId")) {
this.fetchWebCaptures();
}
} }
protected async updated(changedProperties: Map<string, any>) { protected async updated(changedProperties: Map<string, any>) {
@ -51,25 +82,31 @@ export class CollectionDetail extends LiteElement {
render() { render() {
return html`${this.renderHeader()} return html`${this.renderHeader()}
<header class="md:flex justify-between items-end pb-3 border-b"> <header class="md:flex items-center gap-2 pb-3 mb-3 border-b">
<h2 <h1
class="flex-1 min-w-0 text-xl font-semibold leading-10 truncate mr-2" class="flex-1 min-w-0 text-xl font-semibold leading-7 truncate mb-2 md:mb-0"
> >
${this.collection?.name || html`<sl-skeleton></sl-skeleton>`} ${this.collection?.name ||
</h2> html`<sl-skeleton class="w-96"></sl-skeleton>`}
</h1>
${when(this.isCrawler, this.renderActions)} ${when(this.isCrawler, this.renderActions)}
</header> </header>
<div class="my-7">${this.renderDescription()}</div> <div class="mb-3">${this.renderTabs()}</div>
${when(
this.collection?.resources.length, ${choose(
() => html`<div>${this.renderReplay()}</div>` this.resourceTab,
[
["replay", this.renderOverview],
["web-captures", this.renderWebCaptures],
],
() => html`<btrix-not-found></btrix-not-found>`
)} )}
<btrix-dialog <btrix-dialog
label=${msg("Delete Collection?")} label=${msg("Delete Collection?")}
?open=${this.openDialogName === "delete"} ?open=${this.openDialogName === "delete"}
@sl-request-close=${() => (this.openDialogName = undefined)} @sl-request-close=${() => (this.openDialogName = undefined)}
@sl-after-hide=${() => (this.isDialogVisible = false)}
> >
${msg( ${msg(
html`Are you sure you want to delete html`Are you sure you want to delete
@ -95,7 +132,7 @@ export class CollectionDetail extends LiteElement {
} }
private renderHeader = () => html` private renderHeader = () => html`
<nav class="mb-5"> <nav class="mb-7">
<a <a
class="text-gray-600 hover:text-gray-800 text-sm font-medium" class="text-gray-600 hover:text-gray-800 text-sm font-medium"
href=${`/orgs/${this.orgId}/collections`} href=${`/orgs/${this.orgId}/collections`}
@ -109,6 +146,31 @@ export class CollectionDetail extends LiteElement {
</nav> </nav>
`; `;
private renderTabs = () => {
return html`
<nav class="flex gap-2">
${TABS.map((tabName) => {
const isSelected = tabName === this.resourceTab;
return html`
<btrix-button
variant=${isSelected ? "primary" : "neutral"}
?raised=${isSelected}
aria-selected="${isSelected}"
href=${`/orgs/${this.orgId}/collections/view/${this.collectionId}/${tabName}`}
@click=${this.navLink}
>
<sl-icon
name=${this.tabLabels[tabName].icon.name}
library=${this.tabLabels[tabName].icon.library}
></sl-icon>
${this.tabLabels[tabName].text}</btrix-button
>
`;
})}
</nav>
`;
};
private renderActions = () => { private renderActions = () => {
const authToken = this.authState!.headers.Authorization.split(" ")[1]; const authToken = this.authState!.headers.Authorization.split(" ")[1];
@ -157,9 +219,9 @@ export class CollectionDetail extends LiteElement {
return html` return html`
<section> <section>
<header class="flex items-center justify-between"> <header class="flex items-center justify-between">
<h3 class="text-lg font-semibold leading-none h-8 min-h-fit mb-1"> <h2 class="text-lg font-semibold leading-none h-8 min-h-fit mb-1">
${msg("Description")} ${msg("Description")}
</h3> </h2>
${when( ${when(
this.isCrawler, this.isCrawler,
() => () =>
@ -222,17 +284,97 @@ export class CollectionDetail extends LiteElement {
`; `;
} }
private renderOverview = () => html`
${this.renderReplay()}
<div class="my-7">${this.renderDescription()}</div>
`;
private renderWebCaptures = () => html`<section>
${when(
this.webCaptures,
() => {
const { items, page, total, pageSize } = this.webCaptures!;
const hasItems = items.length;
return html`
<section>
${hasItems ? this.renderWebCaptureList() : this.renderEmptyState()}
</section>
${when(
hasItems || page > 1,
() => html`
<footer class="mt-6 flex justify-center">
<btrix-pagination
page=${page}
totalCount=${total}
size=${pageSize}
@page-change=${async (e: PageChangeEvent) => {
await this.fetchWebCaptures({
page: e.detail.page,
});
// Scroll to top of list
// TODO once deep-linking is implemented, scroll to top of pushstate
this.scrollIntoView({ behavior: "smooth" });
}}
></btrix-pagination>
</footer>
`
)}
`;
},
() => html`
<div class="w-full flex items-center justify-center my-12 text-2xl">
<sl-spinner></sl-spinner>
</div>
`
)}
</section>`;
private renderWebCaptureList() {
if (!this.webCaptures) return;
return html`
<btrix-crawl-list
baseUrl=${`/orgs/${this.orgId}/collections/view/${this.collectionId}/artifact`}
>
${this.webCaptures.items.map(this.renderWebCaptureItem)}
</btrix-crawl-list>
`;
}
private renderEmptyState() {
if (this.webCaptures?.page && this.webCaptures?.page > 1) {
return html`
<div class="border-t border-b py-5">
<p class="text-center text-neutral-500">
${msg("Could not find page.")}
</p>
</div>
`;
}
return html`
<div class="border-t border-b py-5">
<p class="text-center text-neutral-500">
${msg("No matching web captures found.")}
</p>
</div>
`;
}
private renderWebCaptureItem = (wc: Crawl | Upload) =>
html`
<btrix-crawl-list-item .crawl=${wc}>
<div slot="menuTrigger" role="none"></div>
</btrix-crawl-list-item>
`;
private renderReplay() { private renderReplay() {
const replaySource = `/api/orgs/${this.orgId}/collections/${this.collectionId}/replay.json`; const replaySource = `/api/orgs/${this.orgId}/collections/${this.collectionId}/replay.json`;
const headers = this.authState?.headers; const headers = this.authState?.headers;
const config = JSON.stringify({ headers }); const config = JSON.stringify({ headers });
return html`<section> return html`<section>
<header class="flex items-center justify-between">
<h3 class="text-lg font-semibold leading-none h-8 min-h-fit mb-1">
${msg("Replay")}
</h3>
</header>
<main> <main>
<div class="aspect-4/3 border rounded-lg overflow-hidden"> <div class="aspect-4/3 border rounded-lg overflow-hidden">
${guard( ${guard(
@ -332,5 +474,50 @@ export class CollectionDetail extends LiteElement {
return data; return data;
} }
/**
* Fetch web captures and update internal state
*/
private async fetchWebCaptures(params?: APIPaginationQuery): Promise<void> {
this.cancelInProgressGetWebCaptures();
try {
this.webCaptures = await this.getWebCaptures();
} catch (e: any) {
if (e === ABORT_REASON_THROTTLE) {
console.debug("Fetch web captures aborted to throttle");
} else {
this.notify({
message: msg("Sorry, couldn't retrieve web captures at this time."),
variant: "danger",
icon: "exclamation-octagon",
});
}
}
}
private cancelInProgressGetWebCaptures() {
if (this.getWebCapturesController) {
this.getWebCapturesController.abort(ABORT_REASON_THROTTLE);
this.getWebCapturesController = null;
}
}
private async getWebCaptures(
params?: Partial<{
state: CrawlState[];
}> &
APIPaginationQuery &
APISortQuery
): Promise<APIPaginatedList> {
const query = queryString.stringify(params || {}, {
arrayFormat: "comma",
});
const data: APIPaginatedList = await this.apiFetch(
`/orgs/${this.orgId}/all-crawls?collectionId=${this.collectionId}&${query}`,
this.authState!
);
return data;
}
} }
customElements.define("btrix-collection-detail", CollectionDetail); customElements.define("btrix-collection-detail", CollectionDetail);

View File

@ -370,7 +370,7 @@ export class CollectionsList extends LiteElement {
<div class="col-span-1 text-xs pl-3">${msg("Collection Name")}</div> <div class="col-span-1 text-xs pl-3">${msg("Collection Name")}</div>
<div class="col-span-1 text-xs">${msg("Top 3 Tags")}</div> <div class="col-span-1 text-xs">${msg("Top 3 Tags")}</div>
<div class="col-span-1 text-xs">${msg("Last Updated")}</div> <div class="col-span-1 text-xs">${msg("Last Updated")}</div>
<div class="col-span-1 text-xs">${msg("Total Crawls")}</div> <div class="col-span-1 text-xs">${msg("Web Captures")}</div>
<div class="col-span-2 text-xs">${msg("Total Pages")}</div> <div class="col-span-2 text-xs">${msg("Total Pages")}</div>
</div> </div>
</header> </header>
@ -447,6 +447,7 @@ export class CollectionsList extends LiteElement {
<a <a
href=${`/orgs/${this.orgId}/collections/view/${col.id}`} href=${`/orgs/${this.orgId}/collections/view/${col.id}`}
class="block text-primary hover:text-indigo-500" class="block text-primary hover:text-indigo-500"
@click=${this.navLink}
> >
${col.name} ${col.name}
</a> </a>
@ -473,8 +474,10 @@ export class CollectionsList extends LiteElement {
class="col-span-1 truncate text-xs text-neutral-500 font-monostyle" class="col-span-1 truncate text-xs text-neutral-500 font-monostyle"
> >
${col.crawlCount === 1 ${col.crawlCount === 1
? msg("1 crawl") ? msg("1 capture")
: msg(str`${this.numberFormatter.format(col.crawlCount)} crawls`)} : msg(
str`${this.numberFormatter.format(col.crawlCount)} captures`
)}
</div> </div>
<div <div
class="col-span-1 truncate text-xs text-neutral-500 font-monostyle" class="col-span-1 truncate text-xs text-neutral-500 font-monostyle"

View File

@ -40,7 +40,7 @@ export class CollectionsNew extends LiteElement {
} }
private renderHeader = () => html` private renderHeader = () => html`
<nav class="mb-5"> <nav class="mb-7">
<a <a
class="text-gray-600 hover:text-gray-800 text-sm font-medium" class="text-gray-600 hover:text-gray-800 text-sm font-medium"
href=${`/orgs/${this.orgId}/collections`} href=${`/orgs/${this.orgId}/collections`}
@ -69,9 +69,7 @@ export class CollectionsNew extends LiteElement {
); );
this.notify({ this.notify({
message: msg( message: msg(str`Successfully created "${data.name}" Collection.`),
str`Successfully created "${data.name}" Collection.`
),
variant: "success", variant: "success",
icon: "check2-circle", icon: "check2-circle",
duration: 8000, duration: 8000,

View File

@ -230,32 +230,37 @@ export class CrawlDetail extends LiteElement {
// TODO abstract into breadcrumbs // TODO abstract into breadcrumbs
const isWorkflowArtifact = this.crawlsBaseUrl.includes("/workflows/"); const isWorkflowArtifact = this.crawlsBaseUrl.includes("/workflows/");
const isCollectionArtifact = this.crawlsBaseUrl.includes("/collections/");
let label = msg("Back to All Crawls");
if (isWorkflowArtifact) {
label = msg("Back to Crawl Workflow");
} else if (isCollectionArtifact) {
label = msg("Back to Collection");
} else if (this.crawl?.type === "upload") {
label = msg("Back to All Uploads");
}
return html` return html`
<div class="mb-7"> <div class="mb-7">
<a <a
class="text-neutral-500 hover:text-neutral-600 text-sm font-medium" class="text-neutral-500 hover:text-neutral-600 text-sm font-medium"
href="${this.crawlsBaseUrl}?artifactType=${this.crawl?.type}" href="${this.crawlsBaseUrl}${isWorkflowArtifact ||
isCollectionArtifact
? ""
: `?artifactType=${this.crawl?.type}`}"
@click=${this.navLink} @click=${this.navLink}
> >
<sl-icon <sl-icon
name="arrow-left" name="arrow-left"
class="inline-block align-middle" class="inline-block align-middle"
></sl-icon> ></sl-icon>
<span class="inline-block align-middle" <span class="inline-block align-middle">${label}</span>
>${isWorkflowArtifact
? msg("Back to Crawl Workflow")
: this.crawl?.type === "upload"
? msg("Back to All Uploads")
: msg("Back to All Crawls")}</span
>
</a> </a>
</div> </div>
<div class="mb-4">${this.renderHeader()}</div> <div class="mb-4">${this.renderHeader()}</div>
<hr class="mb-4" />
<main> <main>
<section class="grid grid-cols-6 gap-4"> <section class="grid grid-cols-6 gap-4">
<div class="col-span-6 md:col-span-1">${this.renderNav()}</div> <div class="col-span-6 md:col-span-1">${this.renderNav()}</div>
@ -384,8 +389,10 @@ export class CrawlDetail extends LiteElement {
private renderHeader() { private renderHeader() {
return html` return html`
<header class="md:flex justify-between items-end"> <header class="md:flex items-center gap-2 pb-3 mb-3 border-b">
<h1 class="text-xl font-semibold mb-4 md:mb-0 md:mr-2"> <h1
class="flex-1 min-w-0 text-xl font-semibold leading-7 truncate mb-2 md:mb-0"
>
${this.renderName()} ${this.renderName()}
</h1> </h1>
<div <div

View File

@ -249,8 +249,10 @@ export class CrawlsList extends LiteElement {
return html` return html`
<main> <main>
<header class="contents"> <header class="contents">
<div class="flex justify-between w-full pb-4 mb-3 border-b"> <div class="md:flex items-center gap-2 pb-3 mb-3 border-b">
<h1 class="text-xl font-semibold h-8"> <h1
class="flex-1 min-w-0 text-xl font-semibold leading-7 truncate mb-2 md:mb-0"
>
${msg("All Archived Data")} ${msg("All Archived Data")}
</h1> </h1>
${when( ${when(

View File

@ -30,6 +30,7 @@ import type {
UserRoleChangeEvent, UserRoleChangeEvent,
OrgRemoveMemberEvent, OrgRemoveMemberEvent,
} from "./settings"; } from "./settings";
import type { Tab as CollectionTab } from "./collection-detail";
export type OrgTab = export type OrgTab =
| "crawls" | "crawls"
@ -45,6 +46,7 @@ type Params = {
browserId?: string; browserId?: string;
artifactId?: string; artifactId?: string;
resourceId?: string; resourceId?: string;
resourceTab?: string;
artifactType?: Crawl["type"]; artifactType?: Crawl["type"];
}; };
@ -238,7 +240,9 @@ export class Org extends LiteElement {
const crawlsAPIBaseUrl = `/orgs/${this.orgId}/crawls`; const crawlsAPIBaseUrl = `/orgs/${this.orgId}/crawls`;
const crawlsBaseUrl = `/orgs/${this.orgId}/artifacts/crawls`; const crawlsBaseUrl = `/orgs/${this.orgId}/artifacts/crawls`;
const artifactType = this.orgPath.includes("/artifacts/upload") ? "upload" : "crawl"; const artifactType = this.orgPath.includes("/artifacts/upload")
? "upload"
: "crawl";
if (this.params.crawlOrWorkflowId) { if (this.params.crawlOrWorkflowId) {
return html` <btrix-crawl-detail return html` <btrix-crawl-detail
@ -345,34 +349,43 @@ export class Org extends LiteElement {
private renderCollections() { private renderCollections() {
if (this.params.resourceId) { if (this.params.resourceId) {
if (this.orgPath.includes(`/edit/${this.params.resourceId}`)) { if (this.orgPath.includes(`/edit/${this.params.resourceId}`)) {
return html`<div class="lg:px-5"> return html`<btrix-collection-edit
<btrix-collection-edit
.authState=${this.authState!}
orgId=${this.orgId!}
collectionId=${this.params.resourceId}
?isCrawler=${this.isCrawler}
></btrix-collection-edit>
</div>`;
}
return html`<div class="lg:px-5">
<btrix-collection-detail
.authState=${this.authState!} .authState=${this.authState!}
orgId=${this.orgId!} orgId=${this.orgId!}
collectionId=${this.params.resourceId} collectionId=${this.params.resourceId}
?isCrawler=${this.isCrawler} ?isCrawler=${this.isCrawler}
></btrix-collection-detail> ></btrix-collection-edit>`;
</div>`; }
if (this.params.artifactId) {
const crawlsAPIBaseUrl = `/orgs/${this.orgId}/crawls`;
// TODO abstract into breadcrumbs
const crawlsBaseUrl = `/orgs/${this.orgId}/collections/view/${this.params.resourceId}/web-captures`;
return html` <btrix-crawl-detail
.authState=${this.authState!}
crawlId=${this.params.artifactId}
crawlsAPIBaseUrl=${crawlsAPIBaseUrl}
crawlsBaseUrl=${crawlsBaseUrl}
?isCrawler=${this.isCrawler}
></btrix-crawl-detail>`;
}
return html`<btrix-collection-detail
.authState=${this.authState!}
orgId=${this.orgId!}
collectionId=${this.params.resourceId}
resourceTab=${(this.params.resourceTab as CollectionTab) || "replay"}
?isCrawler=${this.isCrawler}
></btrix-collection-detail>`;
} }
if (this.orgPath.endsWith("/new")) { if (this.orgPath.endsWith("/new")) {
return html`<div class="lg:px-5"> return html`<btrix-collections-new
<btrix-collections-new .authState=${this.authState!}
.authState=${this.authState!} orgId=${this.orgId!}
orgId=${this.orgId!} ?isCrawler=${this.isCrawler}
?isCrawler=${this.isCrawler} ></btrix-collections-new>`;
></btrix-collections-new>
</div>`;
} }
return html`<btrix-collections-list return html`<btrix-collections-list

View File

@ -14,7 +14,7 @@ export const ROUTES = {
"/orgs/:orgId/:orgTab", "/orgs/:orgId/:orgTab",
// Optional segments: // Optional segments:
"(/new)", "(/new)",
"(/view/:resourceId)", "(/view/:resourceId(/:resourceTab))",
"(/edit/:resourceId)", "(/edit/:resourceId)",
"(/crawls)", "(/crawls)",
"(/crawl/:crawlOrWorkflowId)", "(/crawl/:crawlOrWorkflowId)",