From eeda4cd9ffc8e5599c6f815d150d0320aeca9302 Mon Sep 17 00:00:00 2001 From: Emma Segal-Grossman Date: Wed, 9 Apr 2025 15:40:30 -0400 Subject: [PATCH] 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 --- frontend/src/components/ui/pagination.ts | 71 ++++++++++++++++--- frontend/src/controllers/searchParams.ts | 57 +++++++++++++++ .../crawl-pending-exclusions.ts | 4 +- .../collections/collection-items-dialog.ts | 16 +++-- .../crawl-workflows/queue-exclusion-table.ts | 4 +- frontend/src/index.ts | 1 - frontend/src/pages/crawls.ts | 7 +- .../pages/org/archived-item-detail/ui/qa.ts | 7 +- frontend/src/pages/org/archived-items.ts | 4 +- .../src/pages/org/browser-profiles-list.ts | 7 +- frontend/src/pages/org/collection-detail.ts | 11 ++- frontend/src/pages/org/collections-list.ts | 7 +- frontend/src/pages/org/dashboard.ts | 4 +- frontend/src/pages/org/workflows-list.ts | 7 +- frontend/src/utils/localize.ts | 4 ++ frontend/src/utils/weakCache.ts | 2 +- 16 files changed, 177 insertions(+), 36 deletions(-) create mode 100644 frontend/src/controllers/searchParams.ts 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);