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>
This commit is contained in:
Emma Segal-Grossman 2025-04-09 15:40:30 -04:00 committed by GitHub
parent b0d1a35563
commit eeda4cd9ff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 177 additions and 36 deletions

View File

@ -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<PageChangeDetail>;
/**
* Pagination
*
* Persists via a search param in the URL. Defaults to `page`, but can be set with the `name` attribute.
*
* Usage example:
* ```ts
* <btrix-pagination totalCount="11" @page-change=${this.console.log}>
* <btrix-pagination totalCount="11" @page-change=${console.log}>
* </btrix-pagination>
* ```
*
* You can have multiple paginations on one page by setting different names:
* ```ts
* <btrix-pagination name="page-a" totalCount="11" @page-change=${console.log}>
* </btrix-pagination>
* <btrix-pagination name="page-b" totalCount="2" @page-change=${console.log}>
* </btrix-pagination>
* ```
*
@ -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<PageChangeDetail>("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,6 +354,15 @@ export class Pagination extends LitElement {
}
private onPageChange(page: number) {
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<PageChangeDetail>("page-change", {
detail: { page: page, pages: this.pages },
@ -317,6 +370,8 @@ export class Pagination extends LitElement {
}),
);
}
this.page = page;
}
private calculatePages() {
if (this.totalCount && this.size) {

View File

@ -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);
};
}

View File

@ -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;

View File

@ -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),

View File

@ -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;

View File

@ -191,7 +191,6 @@ export class App extends BtrixElement {
willUpdate(changedProperties: Map<string, unknown>) {
if (changedProperties.has("settings")) {
AppStateService.updateSettings(this.settings || null);
}
if (changedProperties.has("viewState")) {
this.handleViewStateChange(

View File

@ -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,

View File

@ -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,

View File

@ -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<APIPaginationQuery> = {
page: 1,
page: parsePage(new URLSearchParams(location.search).get("page")),
pageSize: INITIAL_PAGE_SIZE,
};

View File

@ -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 ||

View File

@ -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 ||

View File

@ -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 ||

View File

@ -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;

View File

@ -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 ||

View File

@ -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 {

View File

@ -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);