Skip to content

Commit eeda4cd

Browse files
emma-sgSuaYoo
andauthored
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>
1 parent b0d1a35 commit eeda4cd

File tree

16 files changed

+177
-36
lines changed

16 files changed

+177
-36
lines changed

frontend/src/components/ui/pagination.ts

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,19 @@ import { classMap } from "lit/directives/class-map.js";
66
import { ifDefined } from "lit/directives/if-defined.js";
77
import { when } from "lit/directives/when.js";
88

9+
import { SearchParamsController } from "@/controllers/searchParams";
910
import { srOnly } from "@/utils/css";
1011
import chevronLeft from "~assets/icons/chevron-left.svg";
1112
import chevronRight from "~assets/icons/chevron-right.svg";
1213

14+
export const parsePage = (value: string | undefined | null) => {
15+
const page = parseInt(value || "1");
16+
if (!Number.isFinite(page)) {
17+
throw new Error("couldn't parse page value from search");
18+
}
19+
return page;
20+
};
21+
1322
type PageChangeDetail = {
1423
page: number;
1524
pages: number;
@@ -19,9 +28,19 @@ export type PageChangeEvent = CustomEvent<PageChangeDetail>;
1928
/**
2029
* Pagination
2130
*
31+
* Persists via a search param in the URL. Defaults to `page`, but can be set with the `name` attribute.
32+
*
2233
* Usage example:
2334
* ```ts
24-
* <btrix-pagination totalCount="11" @page-change=${this.console.log}>
35+
* <btrix-pagination totalCount="11" @page-change=${console.log}>
36+
* </btrix-pagination>
37+
* ```
38+
*
39+
* You can have multiple paginations on one page by setting different names:
40+
* ```ts
41+
* <btrix-pagination name="page-a" totalCount="11" @page-change=${console.log}>
42+
* </btrix-pagination>
43+
* <btrix-pagination name="page-b" totalCount="2" @page-change=${console.log}>
2544
* </btrix-pagination>
2645
* ```
2746
*
@@ -120,9 +139,25 @@ export class Pagination extends LitElement {
120139
`,
121140
];
122141

123-
@property({ type: Number })
142+
searchParams = new SearchParamsController(this, (params) => {
143+
const page = parsePage(params.get(this.name));
144+
if (this.page !== page) {
145+
this.dispatchEvent(
146+
new CustomEvent<PageChangeDetail>("page-change", {
147+
detail: { page: page, pages: this.pages },
148+
composed: true,
149+
}),
150+
);
151+
this.page = page;
152+
}
153+
});
154+
155+
@state()
124156
page = 1;
125157

158+
@property({ type: String })
159+
name = "page";
160+
126161
@property({ type: Number })
127162
totalCount = 0;
128163

@@ -148,6 +183,15 @@ export class Pagination extends LitElement {
148183
this.calculatePages();
149184
}
150185

186+
const parsedPage = parseFloat(
187+
this.searchParams.searchParams.get(this.name) ?? "1",
188+
);
189+
if (parsedPage != this.page) {
190+
const page = parsePage(this.searchParams.searchParams.get(this.name));
191+
const constrainedPage = Math.max(1, Math.min(this.pages, page));
192+
this.onPageChange(constrainedPage);
193+
}
194+
151195
if (changedProperties.get("page") && this.page) {
152196
this.inputValue = `${this.page}`;
153197
}
@@ -310,12 +354,23 @@ export class Pagination extends LitElement {
310354
}
311355

312356
private onPageChange(page: number) {
313-
this.dispatchEvent(
314-
new CustomEvent<PageChangeDetail>("page-change", {
315-
detail: { page: page, pages: this.pages },
316-
composed: true,
317-
}),
318-
);
357+
if (this.page !== page) {
358+
this.searchParams.set((params) => {
359+
if (page === 1) {
360+
params.delete(this.name);
361+
} else {
362+
params.set(this.name, page.toString());
363+
}
364+
return params;
365+
});
366+
this.dispatchEvent(
367+
new CustomEvent<PageChangeDetail>("page-change", {
368+
detail: { page: page, pages: this.pages },
369+
composed: true,
370+
}),
371+
);
372+
}
373+
this.page = page;
319374
}
320375

321376
private calculatePages() {
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import type { ReactiveController, ReactiveControllerHost } from "lit";
2+
3+
export class SearchParamsController implements ReactiveController {
4+
private readonly host: ReactiveControllerHost;
5+
private readonly changeHandler?: (
6+
searchParams: URLSearchParams,
7+
prevParams: URLSearchParams,
8+
) => void;
9+
private prevParams = new URLSearchParams(location.search);
10+
11+
public get searchParams() {
12+
return new URLSearchParams(location.search);
13+
}
14+
15+
public set(
16+
update: URLSearchParams | ((prev: URLSearchParams) => URLSearchParams),
17+
options: { replace?: boolean; data?: unknown } = { replace: false },
18+
) {
19+
this.prevParams = new URLSearchParams(this.searchParams);
20+
const url = new URL(location.toString());
21+
url.search =
22+
typeof update === "function"
23+
? update(this.searchParams).toString()
24+
: update.toString();
25+
26+
if (options.replace) {
27+
history.replaceState(options.data, "", url);
28+
} else {
29+
history.pushState(options.data, "", url);
30+
}
31+
}
32+
33+
constructor(
34+
host: ReactiveControllerHost,
35+
onChange?: (
36+
searchParams: URLSearchParams,
37+
prevParams: URLSearchParams,
38+
) => void,
39+
) {
40+
this.host = host;
41+
host.addController(this);
42+
this.changeHandler = onChange;
43+
}
44+
45+
hostConnected(): void {
46+
window.addEventListener("popstate", this.onPopState);
47+
}
48+
49+
hostDisconnected(): void {
50+
window.removeEventListener("popstate", this.onPopState);
51+
}
52+
53+
private readonly onPopState = (_e: PopStateEvent) => {
54+
this.changeHandler?.(this.searchParams, this.prevParams);
55+
this.prevParams = new URLSearchParams(this.searchParams);
56+
};
57+
}

frontend/src/features/archived-items/crawl-pending-exclusions.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { html } from "lit";
33
import { customElement, property, state } from "lit/decorators.js";
44

55
import { BtrixElement } from "@/classes/BtrixElement";
6-
import { type PageChangeEvent } from "@/components/ui/pagination";
6+
import { parsePage, type PageChangeEvent } from "@/components/ui/pagination";
77

88
type URLs = string[];
99

@@ -24,7 +24,7 @@ export class CrawlPendingExclusions extends BtrixElement {
2424
matchedURLs: URLs | null = null;
2525

2626
@state()
27-
private page = 1;
27+
private page = parsePage(new URLSearchParams(location.search).get("page"));
2828

2929
private get pageSize() {
3030
return 10;

frontend/src/features/collections/collection-items-dialog.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import type {
1717

1818
import { BtrixElement } from "@/classes/BtrixElement";
1919
import type { Dialog } from "@/components/ui/dialog";
20-
import type { PageChangeEvent } from "@/components/ui/pagination";
20+
import { parsePage, type PageChangeEvent } from "@/components/ui/pagination";
2121
import { type CheckboxChangeEventDetail } from "@/features/archived-items/archived-item-list";
2222
import type {
2323
FilterBy,
@@ -694,18 +694,24 @@ export class CollectionItemsDialog extends BtrixElement {
694694
}
695695

696696
private async initSelection() {
697-
void this.fetchCrawls({ page: 1, pageSize: DEFAULT_PAGE_SIZE });
698-
void this.fetchUploads({ page: 1, pageSize: DEFAULT_PAGE_SIZE });
697+
void this.fetchCrawls({
698+
page: parsePage(new URLSearchParams(location.search).get("page")),
699+
pageSize: DEFAULT_PAGE_SIZE,
700+
});
701+
void this.fetchUploads({
702+
page: parsePage(new URLSearchParams(location.search).get("page")),
703+
pageSize: DEFAULT_PAGE_SIZE,
704+
});
699705
void this.fetchSearchValues();
700706

701707
const [crawls, uploads] = await Promise.all([
702708
this.getCrawls({
703-
page: 1,
709+
page: parsePage(new URLSearchParams(location.search).get("page")),
704710
pageSize: COLLECTION_ITEMS_MAX,
705711
collectionId: this.collectionId,
706712
}).then(({ items }) => items),
707713
this.getUploads({
708-
page: 1,
714+
page: parsePage(new URLSearchParams(location.search).get("page")),
709715
pageSize: COLLECTION_ITEMS_MAX,
710716
collectionId: this.collectionId,
711717
}).then(({ items }) => items),

frontend/src/features/crawl-workflows/queue-exclusion-table.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import RegexColorize from "regex-colorize";
1111
import type { Exclusion } from "./queue-exclusion-form";
1212

1313
import { TailwindElement } from "@/classes/TailwindElement";
14-
import { type PageChangeEvent } from "@/components/ui/pagination";
14+
import { parsePage, type PageChangeEvent } from "@/components/ui/pagination";
1515
import type { SeedConfig } from "@/pages/org/types";
1616
import { regexEscape, regexUnescape } from "@/utils/string";
1717
import { tw } from "@/utils/tailwind";
@@ -90,7 +90,7 @@ export class QueueExclusionTable extends TailwindElement {
9090
private results: Exclusion[] = [];
9191

9292
@state()
93-
private page = 1;
93+
private page = parsePage(new URLSearchParams(location.search).get("page"));
9494

9595
@state()
9696
private exclusionToRemove?: string;

frontend/src/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,6 @@ export class App extends BtrixElement {
191191
willUpdate(changedProperties: Map<string, unknown>) {
192192
if (changedProperties.has("settings")) {
193193
AppStateService.updateSettings(this.settings || null);
194-
195194
}
196195
if (changedProperties.has("viewState")) {
197196
this.handleViewStateChange(

frontend/src/pages/crawls.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { when } from "lit/directives/when.js";
66
import queryString from "query-string";
77

88
import { BtrixElement } from "@/classes/BtrixElement";
9-
import type { PageChangeEvent } from "@/components/ui/pagination";
9+
import { parsePage, type PageChangeEvent } from "@/components/ui/pagination";
1010
import needLogin from "@/decorators/needLogin";
1111
import { CrawlStatus } from "@/features/archived-items/crawl-status";
1212
import type { APIPaginatedList, APIPaginationQuery } from "@/types/api";
@@ -354,7 +354,10 @@ export class Crawls extends BtrixElement {
354354
{
355355
...this.filterBy,
356356
...queryParams,
357-
page: queryParams?.page || this.crawls?.page || 1,
357+
page:
358+
queryParams?.page ||
359+
this.crawls?.page ||
360+
parsePage(new URLSearchParams(location.search).get("page")),
358361
pageSize: queryParams?.pageSize || this.crawls?.pageSize || 100,
359362
sortBy: this.orderBy.field,
360363
sortDirection: this.orderBy.direction === "desc" ? -1 : 1,

frontend/src/pages/org/archived-item-detail/ui/qa.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import queryString from "query-string";
1515

1616
import { BtrixElement } from "@/classes/BtrixElement";
1717
import { type Dialog } from "@/components/ui/dialog";
18-
import type { PageChangeEvent } from "@/components/ui/pagination";
18+
import { parsePage, type PageChangeEvent } from "@/components/ui/pagination";
1919
import { ClipboardController } from "@/controllers/clipboard";
2020
import { iconFor as iconForPageReview } from "@/features/qa/page-list/helpers";
2121
import * as pageApproval from "@/features/qa/page-list/helpers/approval";
@@ -892,7 +892,10 @@ export class ArchivedItemDetailQA extends BtrixElement {
892892
}
893893

894894
this.pages = await this.getPages({
895-
page: params?.page ?? this.pages?.page ?? 1,
895+
page:
896+
params?.page ??
897+
this.pages?.page ??
898+
parsePage(new URLSearchParams(location.search).get("page")),
896899
pageSize: params?.pageSize ?? this.pages?.pageSize ?? 10,
897900
sortBy,
898901
sortDirection,

frontend/src/pages/org/archived-items.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import queryString from "query-string";
1111
import type { ArchivedItem, Crawl, Workflow } from "./types";
1212

1313
import { BtrixElement } from "@/classes/BtrixElement";
14-
import type { PageChangeEvent } from "@/components/ui/pagination";
14+
import { parsePage, type PageChangeEvent } from "@/components/ui/pagination";
1515
import { ClipboardController } from "@/controllers/clipboard";
1616
import { CrawlStatus } from "@/features/archived-items/crawl-status";
1717
import { pageHeader } from "@/layouts/pageHeader";
@@ -92,7 +92,7 @@ export class CrawlsList extends BtrixElement {
9292

9393
@state()
9494
private pagination: Required<APIPaginationQuery> = {
95-
page: 1,
95+
page: parsePage(new URLSearchParams(location.search).get("page")),
9696
pageSize: INITIAL_PAGE_SIZE,
9797
};
9898

frontend/src/pages/org/browser-profiles-list.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import type { Profile } from "./types";
1010
import type { SelectNewDialogEvent } from ".";
1111

1212
import { BtrixElement } from "@/classes/BtrixElement";
13-
import type { PageChangeEvent } from "@/components/ui/pagination";
13+
import { parsePage, type PageChangeEvent } from "@/components/ui/pagination";
1414
import {
1515
SortDirection,
1616
type SortValues,
@@ -441,7 +441,10 @@ export class BrowserProfilesList extends BtrixElement {
441441
try {
442442
this.isLoading = true;
443443
const data = await this.getProfiles({
444-
page: params?.page || this.browserProfiles?.page || 1,
444+
page:
445+
params?.page ||
446+
this.browserProfiles?.page ||
447+
parsePage(new URLSearchParams(location.search).get("page")),
445448
pageSize:
446449
params?.pageSize ||
447450
this.browserProfiles?.pageSize ||

0 commit comments

Comments
 (0)