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:
		
							parent
							
								
									b0d1a35563
								
							
						
					
					
						commit
						eeda4cd9ff
					
				| @ -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,12 +354,23 @@ export class Pagination extends LitElement { | ||||
|   } | ||||
| 
 | ||||
|   private onPageChange(page: number) { | ||||
|     this.dispatchEvent( | ||||
|       new CustomEvent<PageChangeDetail>("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<PageChangeDetail>("page-change", { | ||||
|           detail: { page: page, pages: this.pages }, | ||||
|           composed: true, | ||||
|         }), | ||||
|       ); | ||||
|     } | ||||
|     this.page = page; | ||||
|   } | ||||
| 
 | ||||
|   private calculatePages() { | ||||
|  | ||||
							
								
								
									
										57
									
								
								frontend/src/controllers/searchParams.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								frontend/src/controllers/searchParams.ts
									
									
									
									
									
										Normal 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); | ||||
|   }; | ||||
| } | ||||
| @ -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; | ||||
|  | ||||
| @ -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), | ||||
|  | ||||
| @ -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; | ||||
|  | ||||
| @ -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( | ||||
|  | ||||
| @ -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, | ||||
|  | ||||
| @ -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, | ||||
|  | ||||
| @ -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, | ||||
|   }; | ||||
| 
 | ||||
|  | ||||
| @ -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 || | ||||
|  | ||||
| @ -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 || | ||||
|  | ||||
| @ -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 || | ||||
|  | ||||
| @ -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; | ||||
|  | ||||
| @ -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 || | ||||
|  | ||||
| @ -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 { | ||||
|  | ||||
| @ -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); | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user