diff --git a/frontend/src/components/ui/pagination.ts b/frontend/src/components/ui/pagination.ts index c192ec74..af57c7b3 100644 --- a/frontend/src/components/ui/pagination.ts +++ b/frontend/src/components/ui/pagination.ts @@ -6,10 +6,19 @@ import { classMap } from "lit/directives/class-map.js"; import { ifDefined } from "lit/directives/if-defined.js"; import { when } from "lit/directives/when.js"; +import { SearchParamsController } from "@/controllers/searchParams"; import { srOnly } from "@/utils/css"; import chevronLeft from "~assets/icons/chevron-left.svg"; import chevronRight from "~assets/icons/chevron-right.svg"; +export const parsePage = (value: string | undefined | null) => { + const page = parseInt(value || "1"); + if (!Number.isFinite(page)) { + throw new Error("couldn't parse page value from search"); + } + return page; +}; + type PageChangeDetail = { page: number; pages: number; @@ -19,9 +28,19 @@ export type PageChangeEvent = CustomEvent; /** * Pagination * + * Persists via a search param in the URL. Defaults to `page`, but can be set with the `name` attribute. + * * Usage example: * ```ts - * + * + * + * ``` + * + * You can have multiple paginations on one page by setting different names: + * ```ts + * + * + * * * ``` * @@ -120,9 +139,25 @@ export class Pagination extends LitElement { `, ]; - @property({ type: Number }) + searchParams = new SearchParamsController(this, (params) => { + const page = parsePage(params.get(this.name)); + if (this.page !== page) { + this.dispatchEvent( + new CustomEvent("page-change", { + detail: { page: page, pages: this.pages }, + composed: true, + }), + ); + this.page = page; + } + }); + + @state() page = 1; + @property({ type: String }) + name = "page"; + @property({ type: Number }) totalCount = 0; @@ -148,6 +183,15 @@ export class Pagination extends LitElement { this.calculatePages(); } + const parsedPage = parseFloat( + this.searchParams.searchParams.get(this.name) ?? "1", + ); + if (parsedPage != this.page) { + const page = parsePage(this.searchParams.searchParams.get(this.name)); + const constrainedPage = Math.max(1, Math.min(this.pages, page)); + this.onPageChange(constrainedPage); + } + if (changedProperties.get("page") && this.page) { this.inputValue = `${this.page}`; } @@ -310,12 +354,23 @@ export class Pagination extends LitElement { } private onPageChange(page: number) { - this.dispatchEvent( - new CustomEvent("page-change", { - detail: { page: page, pages: this.pages }, - composed: true, - }), - ); + if (this.page !== page) { + this.searchParams.set((params) => { + if (page === 1) { + params.delete(this.name); + } else { + params.set(this.name, page.toString()); + } + return params; + }); + this.dispatchEvent( + new CustomEvent("page-change", { + detail: { page: page, pages: this.pages }, + composed: true, + }), + ); + } + this.page = page; } private calculatePages() { diff --git a/frontend/src/controllers/searchParams.ts b/frontend/src/controllers/searchParams.ts new file mode 100644 index 00000000..a930ff71 --- /dev/null +++ b/frontend/src/controllers/searchParams.ts @@ -0,0 +1,57 @@ +import type { ReactiveController, ReactiveControllerHost } from "lit"; + +export class SearchParamsController implements ReactiveController { + private readonly host: ReactiveControllerHost; + private readonly changeHandler?: ( + searchParams: URLSearchParams, + prevParams: URLSearchParams, + ) => void; + private prevParams = new URLSearchParams(location.search); + + public get searchParams() { + return new URLSearchParams(location.search); + } + + public set( + update: URLSearchParams | ((prev: URLSearchParams) => URLSearchParams), + options: { replace?: boolean; data?: unknown } = { replace: false }, + ) { + this.prevParams = new URLSearchParams(this.searchParams); + const url = new URL(location.toString()); + url.search = + typeof update === "function" + ? update(this.searchParams).toString() + : update.toString(); + + if (options.replace) { + history.replaceState(options.data, "", url); + } else { + history.pushState(options.data, "", url); + } + } + + constructor( + host: ReactiveControllerHost, + onChange?: ( + searchParams: URLSearchParams, + prevParams: URLSearchParams, + ) => void, + ) { + this.host = host; + host.addController(this); + this.changeHandler = onChange; + } + + hostConnected(): void { + window.addEventListener("popstate", this.onPopState); + } + + hostDisconnected(): void { + window.removeEventListener("popstate", this.onPopState); + } + + private readonly onPopState = (_e: PopStateEvent) => { + this.changeHandler?.(this.searchParams, this.prevParams); + this.prevParams = new URLSearchParams(this.searchParams); + }; +} diff --git a/frontend/src/features/archived-items/crawl-pending-exclusions.ts b/frontend/src/features/archived-items/crawl-pending-exclusions.ts index 30b5eb48..3a464689 100644 --- a/frontend/src/features/archived-items/crawl-pending-exclusions.ts +++ b/frontend/src/features/archived-items/crawl-pending-exclusions.ts @@ -3,7 +3,7 @@ import { html } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { BtrixElement } from "@/classes/BtrixElement"; -import { type PageChangeEvent } from "@/components/ui/pagination"; +import { parsePage, type PageChangeEvent } from "@/components/ui/pagination"; type URLs = string[]; @@ -24,7 +24,7 @@ export class CrawlPendingExclusions extends BtrixElement { matchedURLs: URLs | null = null; @state() - private page = 1; + private page = parsePage(new URLSearchParams(location.search).get("page")); private get pageSize() { return 10; diff --git a/frontend/src/features/collections/collection-items-dialog.ts b/frontend/src/features/collections/collection-items-dialog.ts index cb2ad9eb..7e98fe6c 100644 --- a/frontend/src/features/collections/collection-items-dialog.ts +++ b/frontend/src/features/collections/collection-items-dialog.ts @@ -17,7 +17,7 @@ import type { import { BtrixElement } from "@/classes/BtrixElement"; import type { Dialog } from "@/components/ui/dialog"; -import type { PageChangeEvent } from "@/components/ui/pagination"; +import { parsePage, type PageChangeEvent } from "@/components/ui/pagination"; import { type CheckboxChangeEventDetail } from "@/features/archived-items/archived-item-list"; import type { FilterBy, @@ -694,18 +694,24 @@ export class CollectionItemsDialog extends BtrixElement { } private async initSelection() { - void this.fetchCrawls({ page: 1, pageSize: DEFAULT_PAGE_SIZE }); - void this.fetchUploads({ page: 1, pageSize: DEFAULT_PAGE_SIZE }); + void this.fetchCrawls({ + page: parsePage(new URLSearchParams(location.search).get("page")), + pageSize: DEFAULT_PAGE_SIZE, + }); + void this.fetchUploads({ + page: parsePage(new URLSearchParams(location.search).get("page")), + pageSize: DEFAULT_PAGE_SIZE, + }); void this.fetchSearchValues(); const [crawls, uploads] = await Promise.all([ this.getCrawls({ - page: 1, + page: parsePage(new URLSearchParams(location.search).get("page")), pageSize: COLLECTION_ITEMS_MAX, collectionId: this.collectionId, }).then(({ items }) => items), this.getUploads({ - page: 1, + page: parsePage(new URLSearchParams(location.search).get("page")), pageSize: COLLECTION_ITEMS_MAX, collectionId: this.collectionId, }).then(({ items }) => items), diff --git a/frontend/src/features/crawl-workflows/queue-exclusion-table.ts b/frontend/src/features/crawl-workflows/queue-exclusion-table.ts index 2490d37f..bbde5dc2 100644 --- a/frontend/src/features/crawl-workflows/queue-exclusion-table.ts +++ b/frontend/src/features/crawl-workflows/queue-exclusion-table.ts @@ -11,7 +11,7 @@ import RegexColorize from "regex-colorize"; import type { Exclusion } from "./queue-exclusion-form"; import { TailwindElement } from "@/classes/TailwindElement"; -import { type PageChangeEvent } from "@/components/ui/pagination"; +import { parsePage, type PageChangeEvent } from "@/components/ui/pagination"; import type { SeedConfig } from "@/pages/org/types"; import { regexEscape, regexUnescape } from "@/utils/string"; import { tw } from "@/utils/tailwind"; @@ -90,7 +90,7 @@ export class QueueExclusionTable extends TailwindElement { private results: Exclusion[] = []; @state() - private page = 1; + private page = parsePage(new URLSearchParams(location.search).get("page")); @state() private exclusionToRemove?: string; diff --git a/frontend/src/index.ts b/frontend/src/index.ts index 07c7b2c2..6a52cee3 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -191,7 +191,6 @@ export class App extends BtrixElement { willUpdate(changedProperties: Map) { if (changedProperties.has("settings")) { AppStateService.updateSettings(this.settings || null); - } if (changedProperties.has("viewState")) { this.handleViewStateChange( diff --git a/frontend/src/pages/crawls.ts b/frontend/src/pages/crawls.ts index 1ac147fa..002d5de9 100644 --- a/frontend/src/pages/crawls.ts +++ b/frontend/src/pages/crawls.ts @@ -6,7 +6,7 @@ import { when } from "lit/directives/when.js"; import queryString from "query-string"; import { BtrixElement } from "@/classes/BtrixElement"; -import type { PageChangeEvent } from "@/components/ui/pagination"; +import { parsePage, type PageChangeEvent } from "@/components/ui/pagination"; import needLogin from "@/decorators/needLogin"; import { CrawlStatus } from "@/features/archived-items/crawl-status"; import type { APIPaginatedList, APIPaginationQuery } from "@/types/api"; @@ -354,7 +354,10 @@ export class Crawls extends BtrixElement { { ...this.filterBy, ...queryParams, - page: queryParams?.page || this.crawls?.page || 1, + page: + queryParams?.page || + this.crawls?.page || + parsePage(new URLSearchParams(location.search).get("page")), pageSize: queryParams?.pageSize || this.crawls?.pageSize || 100, sortBy: this.orderBy.field, sortDirection: this.orderBy.direction === "desc" ? -1 : 1, diff --git a/frontend/src/pages/org/archived-item-detail/ui/qa.ts b/frontend/src/pages/org/archived-item-detail/ui/qa.ts index b21e5ce0..cbc3300e 100644 --- a/frontend/src/pages/org/archived-item-detail/ui/qa.ts +++ b/frontend/src/pages/org/archived-item-detail/ui/qa.ts @@ -15,7 +15,7 @@ import queryString from "query-string"; import { BtrixElement } from "@/classes/BtrixElement"; import { type Dialog } from "@/components/ui/dialog"; -import type { PageChangeEvent } from "@/components/ui/pagination"; +import { parsePage, type PageChangeEvent } from "@/components/ui/pagination"; import { ClipboardController } from "@/controllers/clipboard"; import { iconFor as iconForPageReview } from "@/features/qa/page-list/helpers"; import * as pageApproval from "@/features/qa/page-list/helpers/approval"; @@ -892,7 +892,10 @@ export class ArchivedItemDetailQA extends BtrixElement { } this.pages = await this.getPages({ - page: params?.page ?? this.pages?.page ?? 1, + page: + params?.page ?? + this.pages?.page ?? + parsePage(new URLSearchParams(location.search).get("page")), pageSize: params?.pageSize ?? this.pages?.pageSize ?? 10, sortBy, sortDirection, diff --git a/frontend/src/pages/org/archived-items.ts b/frontend/src/pages/org/archived-items.ts index 9a7c6fd8..ca07b608 100644 --- a/frontend/src/pages/org/archived-items.ts +++ b/frontend/src/pages/org/archived-items.ts @@ -11,7 +11,7 @@ import queryString from "query-string"; import type { ArchivedItem, Crawl, Workflow } from "./types"; import { BtrixElement } from "@/classes/BtrixElement"; -import type { PageChangeEvent } from "@/components/ui/pagination"; +import { parsePage, type PageChangeEvent } from "@/components/ui/pagination"; import { ClipboardController } from "@/controllers/clipboard"; import { CrawlStatus } from "@/features/archived-items/crawl-status"; import { pageHeader } from "@/layouts/pageHeader"; @@ -92,7 +92,7 @@ export class CrawlsList extends BtrixElement { @state() private pagination: Required = { - page: 1, + page: parsePage(new URLSearchParams(location.search).get("page")), pageSize: INITIAL_PAGE_SIZE, }; diff --git a/frontend/src/pages/org/browser-profiles-list.ts b/frontend/src/pages/org/browser-profiles-list.ts index 0c2f1366..1bc58477 100644 --- a/frontend/src/pages/org/browser-profiles-list.ts +++ b/frontend/src/pages/org/browser-profiles-list.ts @@ -10,7 +10,7 @@ import type { Profile } from "./types"; import type { SelectNewDialogEvent } from "."; import { BtrixElement } from "@/classes/BtrixElement"; -import type { PageChangeEvent } from "@/components/ui/pagination"; +import { parsePage, type PageChangeEvent } from "@/components/ui/pagination"; import { SortDirection, type SortValues, @@ -441,7 +441,10 @@ export class BrowserProfilesList extends BtrixElement { try { this.isLoading = true; const data = await this.getProfiles({ - page: params?.page || this.browserProfiles?.page || 1, + page: + params?.page || + this.browserProfiles?.page || + parsePage(new URLSearchParams(location.search).get("page")), pageSize: params?.pageSize || this.browserProfiles?.pageSize || diff --git a/frontend/src/pages/org/collection-detail.ts b/frontend/src/pages/org/collection-detail.ts index 02d67334..9f2132b6 100644 --- a/frontend/src/pages/org/collection-detail.ts +++ b/frontend/src/pages/org/collection-detail.ts @@ -12,7 +12,7 @@ import type { Embed as ReplayWebPage } from "replaywebpage"; import { BtrixElement } from "@/classes/BtrixElement"; import type { MarkdownEditor } from "@/components/ui/markdown-editor"; -import type { PageChangeEvent } from "@/components/ui/pagination"; +import { parsePage, type PageChangeEvent } from "@/components/ui/pagination"; import { viewStateContext, type ViewStateContext } from "@/context/view-state"; import { ClipboardController } from "@/controllers/clipboard"; import type { EditDialogTab } from "@/features/collections/collection-edit-dialog"; @@ -129,7 +129,9 @@ export class CollectionDetail extends BtrixElement { ) { if (changedProperties.has("collectionId")) { void this.fetchCollection(); - void this.fetchArchivedItems({ page: 1 }); + void this.fetchArchivedItems({ + page: parsePage(new URLSearchParams(location.search).get("page")), + }); } if (changedProperties.has("collectionTab") && this.collectionTab === null) { this.collectionTab = Tab.Replay; @@ -1033,7 +1035,10 @@ export class CollectionDetail extends BtrixElement { const query = queryString.stringify( { ...params, - page: params?.page || this.archivedItems?.page || 1, + page: + params?.page || + this.archivedItems?.page || + parsePage(new URLSearchParams(location.search).get("page")), pageSize: params?.pageSize || this.archivedItems?.pageSize || diff --git a/frontend/src/pages/org/collections-list.ts b/frontend/src/pages/org/collections-list.ts index 13d682de..e8a788dc 100644 --- a/frontend/src/pages/org/collections-list.ts +++ b/frontend/src/pages/org/collections-list.ts @@ -12,7 +12,7 @@ import queryString from "query-string"; import type { SelectNewDialogEvent } from "."; import { BtrixElement } from "@/classes/BtrixElement"; -import type { PageChangeEvent } from "@/components/ui/pagination"; +import { parsePage, type PageChangeEvent } from "@/components/ui/pagination"; import { ClipboardController } from "@/controllers/clipboard"; import type { CollectionSavedEvent } from "@/features/collections/collection-create-dialog"; import { SelectCollectionAccess } from "@/features/collections/select-collection-access"; @@ -757,7 +757,10 @@ export class CollectionsList extends BtrixElement { const query = queryString.stringify( { ...this.filterBy, - page: queryParams?.page || this.collections?.page || 1, + page: + queryParams?.page || + this.collections?.page || + parsePage(new URLSearchParams(location.search).get("page")), pageSize: queryParams?.pageSize || this.collections?.pageSize || diff --git a/frontend/src/pages/org/dashboard.ts b/frontend/src/pages/org/dashboard.ts index db7bd18b..3354cec1 100644 --- a/frontend/src/pages/org/dashboard.ts +++ b/frontend/src/pages/org/dashboard.ts @@ -14,7 +14,7 @@ import queryString from "query-string"; import type { SelectNewDialogEvent } from "."; import { BtrixElement } from "@/classes/BtrixElement"; -import { type PageChangeEvent } from "@/components/ui/pagination"; +import { parsePage, type PageChangeEvent } from "@/components/ui/pagination"; import { type CollectionSavedEvent } from "@/features/collections/collection-edit-dialog"; import { pageHeading } from "@/layouts/page"; import { pageHeader } from "@/layouts/pageHeader"; @@ -70,7 +70,7 @@ export class Dashboard extends BtrixElement { collectionsView = CollectionGridView.Public; @state() - collectionPage = 1; + collectionPage = parsePage(new URLSearchParams(location.search).get("page")); // Used for busting cache when updating visible collection cacheBust = 0; diff --git a/frontend/src/pages/org/workflows-list.ts b/frontend/src/pages/org/workflows-list.ts index 2892707b..b77e9c73 100644 --- a/frontend/src/pages/org/workflows-list.ts +++ b/frontend/src/pages/org/workflows-list.ts @@ -19,7 +19,7 @@ import { } from "./types"; import { BtrixElement } from "@/classes/BtrixElement"; -import type { PageChangeEvent } from "@/components/ui/pagination"; +import { parsePage, type PageChangeEvent } from "@/components/ui/pagination"; import { type SelectEvent } from "@/components/ui/search-combobox"; import { ClipboardController } from "@/controllers/clipboard"; import type { SelectJobTypeEvent } from "@/features/crawl-workflows/new-workflow-dialog"; @@ -748,7 +748,10 @@ export class WorkflowsList extends BtrixElement { const query = queryString.stringify( { ...this.filterBy, - page: queryParams?.page || this.workflows?.page || 1, + page: + queryParams?.page || + this.workflows?.page || + parsePage(new URLSearchParams(location.search).get("page")), pageSize: queryParams?.pageSize || this.workflows?.pageSize || diff --git a/frontend/src/utils/localize.ts b/frontend/src/utils/localize.ts index f6f2a6e2..ab4e0df1 100644 --- a/frontend/src/utils/localize.ts +++ b/frontend/src/utils/localize.ts @@ -88,6 +88,7 @@ const numberFormatter = cached( mergeLocales(lang, useNavigatorLocales, navigatorLocales), options, ), + { cacheConstructor: Map }, ); /** @@ -108,6 +109,7 @@ const dateFormatter = cached( mergeLocales(lang, useNavigatorLocales, navigatorLocales), options, ), + { cacheConstructor: Map }, ); /** @@ -128,6 +130,7 @@ const durationFormatter = cached( mergeLocales(lang, useNavigatorLocales, navigatorLocales), options, ), + { cacheConstructor: Map }, ); const pluralFormatter = cached( @@ -141,6 +144,7 @@ const pluralFormatter = cached( mergeLocales(lang, useNavigatorLocales, navigatorLocales), options, ), + { cacheConstructor: Map }, ); export class Localize { diff --git a/frontend/src/utils/weakCache.ts b/frontend/src/utils/weakCache.ts index b2429527..066e8e90 100644 --- a/frontend/src/utils/weakCache.ts +++ b/frontend/src/utils/weakCache.ts @@ -102,7 +102,7 @@ export function cached< ); } if (cache.has(k)) { - return cache.get(k)!; + return cache.get(k) as Result; } else { const v = fn(...args); cache.set(k, v);