browsertrix/frontend/src/pages/org/browser-profiles-list.ts
Emma Segal-Grossman eeda4cd9ff
Persist pagination state in url (#2538)
Closes #1944 

## Changes
- Pagination stores page number in url search params, rather than
internal state, allowing going back to a specific page in a list
- Pagination navigation pushes to history stack, and listens to history
changes to be able to respond to browser history navigation
(back/forward)
- Search parameter reactive controller powers pagination component
- Pagination component allows for multiple simultaneous paginations via
custom `name` property

## Manual testing

1. Log in as any role
2. Go to one of the list views on an org with enough items in the list
to span more than one page
3. Click on one of the pages, and navigate back in your browser. The
selected page should respect this navigation and return to the initial
numbered page.
4. Navigate forward in your browser. The selected page should respect
this navigation and switch to the numbered page from the previous step.
5. Click on a non-default page, and then click on one of the items in
the list to go to its detail page. Then, using your browser's back
button, return to the list page. You should be on the same numbered page
as before.

---------

Co-authored-by: sua yoo <sua@suayoo.com>
2025-04-09 15:40:30 -04:00

485 lines
14 KiB
TypeScript

import { localized, msg, str } from "@lit/localize";
import clsx from "clsx";
import { css, nothing, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { when } from "lit/directives/when.js";
import queryString from "query-string";
import type { Profile } from "./types";
import type { SelectNewDialogEvent } from ".";
import { BtrixElement } from "@/classes/BtrixElement";
import { parsePage, type PageChangeEvent } from "@/components/ui/pagination";
import {
SortDirection,
type SortValues,
} from "@/components/ui/table/table-header-cell";
import { ClipboardController } from "@/controllers/clipboard";
import { pageHeader } from "@/layouts/pageHeader";
import type {
APIPaginatedList,
APIPaginationQuery,
APISortQuery,
} from "@/types/api";
import type { Browser } from "@/types/browser";
import { html } from "@/utils/LiteElement";
import { isArchivingDisabled } from "@/utils/orgs";
import { tw } from "@/utils/tailwind";
const INITIAL_PAGE_SIZE = 20;
/**
* Usage:
* ```ts
* <btrix-browser-profiles-list
* ></btrix-browser-profiles-list>
* ```
*/
@customElement("btrix-browser-profiles-list")
@localized()
export class BrowserProfilesList extends BtrixElement {
@property({ type: Boolean })
isCrawler = false;
@state()
browserProfiles?: APIPaginatedList<Profile>;
@state()
sort: Required<APISortQuery> = {
sortBy: "modified",
sortDirection: -1,
};
@state()
private isLoading = true;
static styles = css`
btrix-table {
grid-template-columns:
[clickable-start] minmax(30ch, 50ch) minmax(30ch, 40ch) repeat(2, 1fr)
[clickable-end] min-content;
--btrix-cell-gap: var(--sl-spacing-x-small);
--btrix-cell-padding-left: var(--sl-spacing-small);
--btrix-cell-padding-right: var(--sl-spacing-small);
}
btrix-table-body btrix-table-row:nth-of-type(n + 2) {
--btrix-border-top: 1px solid var(--sl-panel-border-color);
}
btrix-table-body btrix-table-row:first-of-type {
--btrix-border-radius-top: var(--sl-border-radius-medium);
}
btrix-table-body btrix-table-row:last-of-type {
--btrix-border-radius-bottom: var(--sl-border-radius-medium);
}
btrix-table-row {
border-top: var(--btrix-border-top, 0);
border-radius: var(--btrix-border-radius-top, 0)
var(--btrix-border-radius-to, 0) var(--btrix-border-radius-bottom, 0)
var(--btrix-border-radius-bottom, 0);
height: 2.5rem;
}
`;
protected willUpdate(
changedProperties: PropertyValues<this> & Map<string, unknown>,
) {
if (changedProperties.has("sort")) {
void this.fetchBrowserProfiles();
}
}
render() {
return html`${pageHeader({
title: msg("Browser Profiles"),
actions: this.isCrawler
? html`
<sl-button
variant="primary"
size="small"
?disabled=${isArchivingDisabled(this.org)}
@click=${() => {
this.dispatchEvent(
new CustomEvent("select-new-dialog", {
detail: "browser-profile",
}) as SelectNewDialogEvent,
);
}}
>
<sl-icon slot="prefix" name="plus-lg"></sl-icon>
${msg("New Browser Profile")}
</sl-button>
`
: undefined,
classNames: tw`mb-3`,
})}
<div class="pb-1">${this.renderTable()}</div>`;
}
private renderTable() {
const headerCells = [
{
sortBy: "name",
sortDirection: 1,
className: "pl-3",
label: msg("Name"),
},
{ sortBy: "url", sortDirection: 1, label: msg("Visited URLs") },
{ sortBy: "created", sortDirection: -1, label: msg("Created On") },
{ sortBy: "modified", sortDirection: -1, label: msg("Last Updated") },
];
const sortProps: Record<
SortValues,
{ name: string; label: string; className: string }
> = {
none: {
name: "arrow-down-up",
label: msg("Sortable"),
className: tw`text-xs opacity-0 hover:opacity-100 group-hover:opacity-100`,
},
ascending: {
name: "sort-up-alt",
label: msg("Ascending"),
className: tw`text-base`,
},
descending: {
name: "sort-down",
label: msg("Descending"),
className: tw`text-base`,
},
};
const getSortIcon = (sortValue: SortValues) => {
const { name, label, className } = sortProps[sortValue];
return html`
<sl-icon
class=${clsx(tw`ml-1 text-neutral-900 transition-opacity`, className)}
name=${name}
label=${label}
></sl-icon>
`;
};
return html`
<btrix-table class="-mx-3 overflow-x-auto px-3">
<btrix-table-head class="mb-2">
${headerCells.map(({ sortBy, sortDirection, label, className }) => {
const isSorting = sortBy === this.sort.sortBy;
const sortValue =
(isSorting && SortDirection.get(this.sort.sortDirection)) ||
"none";
// TODO implement sort render logic in table-header-cell
return html`
<btrix-table-header-cell
class="${className} group cursor-pointer rounded transition-colors hover:bg-primary-50"
ariaSort=${sortValue}
@click=${() => {
if (isSorting) {
this.sort = {
...this.sort,
sortDirection: this.sort.sortDirection * -1,
};
} else {
this.sort = {
sortBy,
sortDirection,
};
}
}}
>
${label} ${getSortIcon(sortValue)}
</btrix-table-header-cell>
`;
})}
<btrix-table-header-cell>
<span class="sr-only">${msg("Row Actions")}</span>
</btrix-table-header-cell>
</btrix-table-head>
<btrix-table-body
class=${clsx(
"relative rounded border",
this.browserProfiles == null && this.isLoading && tw`min-h-48`,
)}
>
${when(this.browserProfiles, ({ total, items }) =>
total ? html` ${items.map(this.renderItem)} ` : nothing,
)}
${when(this.isLoading, this.renderLoading)}
</btrix-table-body>
</btrix-table>
${when(this.browserProfiles, ({ total, page, pageSize }) =>
total
? html`
<footer class="mt-6 flex justify-center">
<btrix-pagination
page=${page}
totalCount=${total}
size=${pageSize}
@page-change=${async (e: PageChangeEvent) => {
void this.fetchBrowserProfiles({ page: e.detail.page });
}}
></btrix-pagination>
</footer>
`
: html`
<div class="border-b border-t py-5">
<p class="text-0-500 text-center">
${msg("No browser profiles yet.")}
</p>
</div>
`,
)}
`;
}
private readonly renderLoading = () =>
html`<div
class="absolute left-0 top-0 z-10 flex h-full w-full items-center justify-center bg-white/50 text-3xl"
>
<sl-spinner></sl-spinner>
</div>`;
private readonly renderItem = (data: Profile) => {
return html`
<btrix-table-row
class="cursor-pointer select-none transition-all focus-within:bg-neutral-50 hover:bg-neutral-50 hover:shadow-none"
>
<btrix-table-cell
class="flex-col items-center justify-center pl-3"
rowClickTarget="a"
>
<a
class="flex items-center gap-3"
href=${`${this.navigate.orgBasePath}/browser-profiles/profile/${data.id}`}
@click=${this.navigate.link}
>
<span class="truncate">${data.name}</span>
</a>
</btrix-table-cell>
<btrix-table-cell>
<div class="truncate">${data.origins[0]}</div>
${data.origins.length > 1
? html`<sl-tooltip class="invert-tooltip">
<span slot="content" class=" break-words"
>${data.origins.slice(1).join(", ")}</span
>
<btrix-badge class="ml-2">
${msg(str`+${data.origins.length - 1}`)}
</btrix-badge>
</sl-tooltip>`
: nothing}
</btrix-table-cell>
<btrix-table-cell class="whitespace-nowrap tabular-nums">
<sl-tooltip
content=${msg(str`By ${data.createdByName}`)}
?disabled=${!data.createdByName}
>
<btrix-format-date
date=${data.created}
month="2-digit"
day="2-digit"
year="numeric"
hour="2-digit"
minute="2-digit"
></btrix-format-date>
</sl-tooltip>
</btrix-table-cell>
<btrix-table-cell class="whitespace-nowrap tabular-nums">
<sl-tooltip
content=${msg(str`By ${data.modifiedByName || data.createdByName}`)}
?disabled=${!data.createdByName}
>
<btrix-format-date
date=${
// NOTE older profiles may not have "modified" data
data.modified || data.created
}
month="2-digit"
day="2-digit"
year="numeric"
hour="2-digit"
minute="2-digit"
></btrix-format-date>
</sl-tooltip>
</btrix-table-cell>
<btrix-table-cell class="p-0">
${this.renderActions(data)}
</btrix-table-cell>
</btrix-table-row>
`;
};
private renderActions(data: Profile) {
return html`
<btrix-overflow-dropdown @click=${(e: Event) => e.preventDefault()}>
<sl-menu>
<sl-menu-item
?disabled=${isArchivingDisabled(this.org)}
@click=${() => {
void this.duplicateProfile(data);
}}
>
<sl-icon slot="prefix" name="files"></sl-icon>
${msg("Duplicate Profile")}
</sl-menu-item>
<sl-divider></sl-divider>
<sl-menu-item
@click=${() => ClipboardController.copyToClipboard(data.id)}
>
<sl-icon name="copy" slot="prefix"></sl-icon>
${msg("Copy Profile ID")}
</sl-menu-item>
<sl-divider></sl-divider>
<sl-menu-item
style="--sl-color-neutral-700: var(--danger)"
@click=${() => {
void this.deleteProfile(data);
}}
>
<sl-icon slot="prefix" name="trash3"></sl-icon>
${msg("Delete Profile")}
</sl-menu-item>
</sl-menu>
</btrix-overflow-dropdown>
`;
}
private async duplicateProfile(profile: Profile) {
const url = profile.origins[0];
try {
const data = await this.createBrowser({ url });
this.notify.toast({
message: msg("Starting up browser with selected profile..."),
variant: "success",
icon: "check2-circle",
});
this.navigate.to(
`${this.navigate.orgBasePath}/browser-profiles/profile/browser/${
data.browserid
}?${queryString.stringify({
url,
name: profile.name,
description: profile.description,
profileId: profile.id,
crawlerChannel: profile.crawlerChannel,
})}`,
);
} catch (e) {
this.notify.toast({
message: msg("Sorry, couldn't create browser profile at this time."),
variant: "danger",
icon: "exclamation-octagon",
});
}
}
private async deleteProfile(profile: Profile) {
try {
const data = await this.api.fetch<Profile & { error?: boolean }>(
`/orgs/${this.orgId}/profiles/${profile.id}`,
{
method: "DELETE",
},
);
if (data.error && data.crawlconfigs) {
this.notify.toast({
message: msg(
html`Could not delete <strong>${profile.name}</strong>, in use by
<strong
>${data.crawlconfigs.map(({ name }) => name).join(", ")}</strong
>. Please remove browser profile from Workflow to continue.`,
),
variant: "warning",
icon: "exclamation-triangle",
duration: 15000,
});
} else {
this.notify.toast({
message: msg(html`Deleted <strong>${profile.name}</strong>.`),
variant: "success",
icon: "check2-circle",
id: "browser-profile-deleted-status",
});
void this.fetchBrowserProfiles();
}
} catch (e) {
this.notify.toast({
message: msg("Sorry, couldn't delete browser profile at this time."),
variant: "danger",
icon: "exclamation-octagon",
id: "browser-profile-deleted-status",
});
}
}
private async createBrowser({ url }: { url: string }) {
const params = {
url,
};
return this.api.fetch<Browser>(`/orgs/${this.orgId}/profiles/browser`, {
method: "POST",
body: JSON.stringify(params),
});
}
/**
* Fetch browser profiles and update internal state
*/
private async fetchBrowserProfiles(
params?: APIPaginationQuery,
): Promise<void> {
try {
this.isLoading = true;
const data = await this.getProfiles({
page:
params?.page ||
this.browserProfiles?.page ||
parsePage(new URLSearchParams(location.search).get("page")),
pageSize:
params?.pageSize ||
this.browserProfiles?.pageSize ||
INITIAL_PAGE_SIZE,
});
this.browserProfiles = data;
} catch (e) {
this.notify.toast({
message: msg("Sorry, couldn't retrieve browser profiles at this time."),
variant: "danger",
icon: "exclamation-octagon",
id: "browser-profile-status",
});
} finally {
this.isLoading = false;
}
}
private async getProfiles(params: APIPaginationQuery) {
const query = queryString.stringify(
{
...params,
...this.sort,
},
{
arrayFormat: "comma",
},
);
const data = await this.api.fetch<APIPaginatedList<Profile>>(
`/orgs/${this.orgId}/profiles?${query}`,
);
return data;
}
}