Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,16 @@ import {
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
PaginationState,
SortingState,
useReactTable,
} from '@tanstack/react-table';
import { DataTable } from 'dogma/common/components/table/DataTable';
import { Filter } from 'dogma/common/components/table/Filter';
import { PAGE_SIZES, PaginationBar } from 'dogma/common/components/table/PaginationBar';
import { useCallback, useEffect, useState } from 'react';
import { useUrlPagination } from 'dogma/common/components/table/useUrlPagination';
import { useState } from 'react';
import { useRouter } from 'next/router';

const VALID_PAGE_SIZES: Set<number> = new Set(PAGE_SIZES);

function parsePageIndex(value: string | string[] | undefined): number {
const n = Number(value);
return Number.isInteger(n) && n > 0 ? n - 1 : 0;
}

function parsePageSize(value: string | string[] | undefined): number {
const n = Number(value);
return VALID_PAGE_SIZES.has(n) ? n : 10;
}

export type DataTableClientPaginationProps<Data extends object> = {
data: Data[];
columns: ColumnDef<Data>[];
Expand All @@ -41,40 +29,7 @@ export const DataTableClientPagination = <Data extends object>({

const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [pagination, setPaginationState] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});

const setPagination = useCallback(
(updater: PaginationState | ((old: PaginationState) => PaginationState)) => {
const newState = typeof updater === 'function' ? updater(pagination) : updater;
setPaginationState(newState);
const query = { ...router.query };
if (newState.pageIndex === 0) {
delete query.page;
} else {
query.page = String(newState.pageIndex + 1);
}
if (newState.pageSize === 10) {
delete query.pageSize;
} else {
query.pageSize = String(newState.pageSize);
}
router.push({ pathname: router.pathname, query }, undefined, { shallow: true });
},
[pagination, router],
);

useEffect(() => {
if (!router.isReady) {
return;
}
setPaginationState({
pageIndex: parsePageIndex(router.query?.page),
pageSize: parsePageSize(router.query?.pageSize),
});
}, [router.isReady, router.query?.page, router.query?.pageSize]);
const { pagination, onPaginationChange } = useUrlPagination({ pageSizes: PAGE_SIZES });

const table = useReactTable({
columns: columns || [],
Expand All @@ -85,7 +40,7 @@ export const DataTableClientPagination = <Data extends object>({
onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
onPaginationChange,
manualPagination: false,
autoResetPageIndex: false,
state: {
Expand Down
113 changes: 113 additions & 0 deletions webapp/src/dogma/common/components/table/useUrlPagination.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { functionalUpdate, OnChangeFn, PaginationState, Table } from '@tanstack/react-table';
import { useRouter } from 'next/router';
import { useCallback, useEffect, useRef, useState } from 'react';

const DEFAULT_PAGE_SIZE = 10;

// Parses the 1-indexed `page` query param into react-table's 0-indexed pageIndex, falling back to the first
// page for a missing or malformed value. An out-of-range (too large) value is left as-is here and corrected by
// useClampPageIndex once the row count is known.
function parsePageIndex(value: string | string[] | undefined): number {
const n = Number(value);
return Number.isInteger(n) && n > 0 ? n - 1 : 0;
}

// Parses the `pageSize` query param, accepting it only when it is one of the offered sizes so a hand-edited URL
// cannot force a size the selector could not otherwise display.
function parsePageSize(
value: string | string[] | undefined,
pageSizes: readonly number[],
defaultPageSize: number,
): number {
const n = Number(value);
return pageSizes.includes(n) ? n : defaultPageSize;
}

export interface UseUrlPaginationOptions {
// The page sizes offered by the size selector; a `pageSize` query param outside this set is ignored. May be
// an inline array — the hook does not depend on its identity.
pageSizes: readonly number[];
// The page size used when the URL carries no (valid) `pageSize`. Should be one of `pageSizes`.
defaultPageSize?: number;
}

export interface UrlPagination {
pagination: PaginationState;
onPaginationChange: OnChangeFn<PaginationState>;
}

/**
* Mirrors a TanStack Table's pagination into the URL query (`page`, `pageSize`) so it survives navigation:
* opening a row's detail page and pressing the browser Back button restores the same page and page size
* instead of resetting to the first page. `page` is 1-indexed in the URL, and both params are omitted while at
* their defaults to keep URLs clean. Routing is shallow, so persisting the state never refetches data.
*
* Consumers must wire the returned `pagination`/`onPaginationChange` into `useReactTable` as controlled state
* and set `autoResetPageIndex: false`; otherwise an asynchronous data load would reset the restored page back
* to the first one, reintroducing the very bug this hook fixes. Because auto-reset is off, also call
* {@link useClampPageIndex} so a stale page cannot outlive a shrinking data set.
*/
export function useUrlPagination({
pageSizes,
defaultPageSize = DEFAULT_PAGE_SIZE,
}: UseUrlPaginationOptions): UrlPagination {
const router = useRouter();
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: defaultPageSize });

// Hold the latest valid sizes in a ref so the URL-sync effect does not take `pageSizes` as a dependency.
// A caller passing an inline array (a new reference every render) would otherwise re-run the effect on every
// render — and since the effect calls setPagination with a fresh object, that would be an infinite loop.
const pageSizesRef = useRef(pageSizes);
pageSizesRef.current = pageSizes;

const onPaginationChange = useCallback<OnChangeFn<PaginationState>>(
(updater) => {
const next = functionalUpdate(updater, pagination);
setPagination(next);
const query = { ...router.query };
if (next.pageIndex === 0) {
delete query.page;
} else {
query.page = String(next.pageIndex + 1);
}
if (next.pageSize === defaultPageSize) {
delete query.pageSize;
} else {
query.pageSize = String(next.pageSize);
}
router.push({ pathname: router.pathname, query }, undefined, { shallow: true });
},
[pagination, router, defaultPageSize],
);

// Sync from the URL once the router is ready and whenever the params change (including the browser Back
// button), which is what restores the page after returning from a detail page.
useEffect(() => {
if (!router.isReady) {
return;
}
setPagination({
pageIndex: parsePageIndex(router.query?.page),
pageSize: parsePageSize(router.query?.pageSize, pageSizesRef.current, defaultPageSize),
});
}, [router.isReady, router.query?.page, router.query?.pageSize, defaultPageSize]);

return { pagination, onPaginationChange };
}

/**
* Corrects the table's page index when it falls outside the available pages — e.g. a hand-edited or stale
* `?page=` URL, or a data set that shrank (a resource was deleted, or a filter narrowed the results) while a
* later page was selected. Without this, a controlled table with `autoResetPageIndex: false` keeps the stale
* index and renders an empty body under a nonsensical "Page 5 of 3". Clamps to the last available page and
* routes the change through the table so it is mirrored back into the URL by {@link useUrlPagination}.
*/
export function useClampPageIndex<Data>(table: Table<Data>): void {
const { pageIndex } = table.getState().pagination;
const pageCount = table.getPageCount();
useEffect(() => {
if (pageCount > 0 && pageIndex > pageCount - 1) {
table.setPageIndex(pageCount - 1);
}
}, [table, pageIndex, pageCount]);
}
28 changes: 21 additions & 7 deletions webapp/src/dogma/features/xds/GroupList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,14 @@ import {
} from '@tanstack/react-table';
import { useMemo, useState } from 'react';
import { DataTable } from 'dogma/features/xds/DataTable';
import { GroupDto } from 'dogma/features/xds/XdsTypes';
import { GroupDto, XDS_PAGE_SIZES } from 'dogma/features/xds/XdsTypes';
import { useClampPageIndex, useUrlPagination } from 'dogma/common/components/table/useUrlPagination';

const columnHelper = createColumnHelper<GroupDto>();

const PAGE_SIZES = [10, 20, 50, 100];

export const GroupList = ({ groups }: { groups: GroupDto[] }) => {
const [globalFilter, setGlobalFilter] = useState('');
const { pagination, onPaginationChange } = useUrlPagination({ pageSizes: XDS_PAGE_SIZES });

// The group can be deleted from within the group (group detail page), not from this list, so that deletion
// requires opening the group first.
Expand All @@ -69,14 +69,28 @@ export const GroupList = ({ groups }: { groups: GroupDto[] }) => {
const table = useReactTable({
data: groups,
columns,
state: { globalFilter },
state: { globalFilter, pagination },
onGlobalFilterChange: setGlobalFilter,
onPaginationChange,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: { pagination: { pageSize: 10 } },
// Pagination is controlled via the URL (useUrlPagination); auto-reset would discard the page restored
// from the URL as soon as the groups finish loading.
autoResetPageIndex: false,
});
// Auto-reset is off, so keep the page index within bounds when a filter narrows the list to fewer pages.
useClampPageIndex(table);

// With auto-reset disabled, filtering no longer moves back to the first page on its own, so a narrowing
// filter could otherwise strand the user on a now-empty later page. Reset explicitly instead.
const handleFilterChange = (value: string) => {
setGlobalFilter(value);
if (pagination.pageIndex !== 0) {
table.setPageIndex(0);
}
};

if (groups.length === 0) {
return <Text color="gray.500">No groups yet. Create one to get started.</Text>;
Expand All @@ -96,7 +110,7 @@ export const GroupList = ({ groups }: { groups: GroupDto[] }) => {
<Input
placeholder="Search groups"
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
onChange={(e) => handleFilterChange(e.target.value)}
/>
</InputGroup>
</HStack>
Expand Down Expand Up @@ -124,7 +138,7 @@ export const GroupList = ({ groups }: { groups: GroupDto[] }) => {
Next
</Button>
<Select size="sm" w="auto" value={pageSize} onChange={(e) => table.setPageSize(Number(e.target.value))}>
{PAGE_SIZES.map((size) => (
{XDS_PAGE_SIZES.map((size) => (
<option key={size} value={size}>
{size} / page
</option>
Expand Down
14 changes: 11 additions & 3 deletions webapp/src/dogma/features/xds/ResourceHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { FetchBaseQueryError } from '@reduxjs/toolkit/query';
import { useCallback, useMemo, useState } from 'react';
import { VscGitCommit } from 'react-icons/vsc';
import { DataTable } from 'dogma/features/xds/DataTable';
import { useClampPageIndex, useUrlPagination } from 'dogma/common/components/table/useUrlPagination';
import { Deferred } from 'dogma/common/components/Deferred';
import { Loading } from 'dogma/common/components/Loading';
import { Author } from 'dogma/common/components/Author';
Expand All @@ -57,7 +58,7 @@ import { HistoryDto } from 'dogma/features/history/HistoryDto';
import { FileDto } from 'dogma/features/file/FileDto';
import { useGetGroupHistoryQuery } from 'dogma/features/xds/xdsApiSlice';
import { useGetFileContentQuery, useGetFilesQuery } from 'dogma/features/api/apiSlice';
import { XDS_PROJECT } from 'dogma/features/xds/XdsTypes';
import { XDS_PAGE_SIZES, XDS_PROJECT } from 'dogma/features/xds/XdsTypes';

const columnHelper = createColumnHelper<HistoryDto>();

Expand Down Expand Up @@ -321,15 +322,22 @@ export const ResourceHistory = ({ group, filePath }: { group: string; filePath?:

// Memoized so the table receives a stable data reference across re-renders (react-table requires this).
const rows = useMemo(() => data || [], [data]);
const { pagination, onPaginationChange } = useUrlPagination({ pageSizes: XDS_PAGE_SIZES });
const table = useReactTable({
data: rows,
columns,
state: { pagination },
onPaginationChange,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: { pagination: { pageSize: 10 } },
// Pagination is controlled via the URL (useUrlPagination); auto-reset would discard the page restored
// from the URL as soon as the history finishes loading.
autoResetPageIndex: false,
});
// Auto-reset is off, so keep the page index within bounds if the history is truncated to fewer pages.
useClampPageIndex(table);

return (
<Deferred isLoading={isLoading} error={error}>
Expand Down Expand Up @@ -369,7 +377,7 @@ export const ResourceHistory = ({ group, filePath }: { group: string; filePath?:
value={pageSize}
onChange={(e) => table.setPageSize(Number(e.target.value))}
>
{[10, 20, 50, 100].map((size) => (
{XDS_PAGE_SIZES.map((size) => (
<option key={size} value={size}>
{size} / page
</option>
Expand Down
20 changes: 17 additions & 3 deletions webapp/src/dogma/features/xds/ResourceList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,15 @@ import { useMemo, useState } from 'react';
import { DataTable } from 'dogma/features/xds/DataTable';
import { DeleteConfirmationModal } from 'dogma/common/components/DeleteConfirmationModal';
import { Deferred } from 'dogma/common/components/Deferred';
import { useClampPageIndex, useUrlPagination } from 'dogma/common/components/table/useUrlPagination';
import { useDeleteResourceMutation, useListResourcesQuery } from 'dogma/features/xds/xdsApiSlice';
import { resourceName, XdsResourceDto, XdsResourceType, XDS_RESOURCE_META } from 'dogma/features/xds/XdsTypes';
import {
resourceName,
XdsResourceDto,
XdsResourceType,
XDS_PAGE_SIZES,
XDS_RESOURCE_META,
} from 'dogma/features/xds/XdsTypes';
import { useGroupWriteAccess } from 'dogma/features/xds/useGroupWriteAccess';
import { useAppDispatch } from 'dogma/hooks';
import { newNotification } from 'dogma/features/notification/notificationSlice';
Expand Down Expand Up @@ -124,15 +131,22 @@ export const ResourceList = ({ group, type }: { group: string; type: XdsResource

// Memoized so the table receives a stable data reference across re-renders (react-table requires this).
const resources = useMemo(() => data || [], [data]);
const { pagination, onPaginationChange } = useUrlPagination({ pageSizes: XDS_PAGE_SIZES });
const table = useReactTable({
data: resources,
columns,
state: { pagination },
onPaginationChange,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: { pagination: { pageSize: 10 } },
// Pagination is controlled via the URL (useUrlPagination); auto-reset would discard the page restored
// from the URL as soon as the resources finish loading.
autoResetPageIndex: false,
});
// Auto-reset is off, so keep the page index within bounds when a deletion shrinks the list to fewer pages.
useClampPageIndex(table);

return (
<Deferred isLoading={isLoading} error={error}>
Expand Down Expand Up @@ -188,7 +202,7 @@ export const ResourceList = ({ group, type }: { group: string; type: XdsResource
value={pageSize}
onChange={(e) => table.setPageSize(Number(e.target.value))}
>
{[10, 20, 50, 100].map((size) => (
{XDS_PAGE_SIZES.map((size) => (
<option key={size} value={size}>
{size} / page
</option>
Expand Down
4 changes: 4 additions & 0 deletions webapp/src/dogma/features/xds/XdsTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ export type XdsResourceType = 'listeners' | 'routes' | 'clusters' | 'endpoints';

export const XDS_RESOURCE_TYPES: XdsResourceType[] = ['listeners', 'routes', 'clusters', 'endpoints'];

// The page sizes offered by the paginated xDS lists (groups, resources, history). Also the set of valid
// `pageSize` URL query values; anything else falls back to the first (default) size.
export const XDS_PAGE_SIZES = [10, 20, 50, 100] as const;

export interface XdsResourceTypeMeta {
type: XdsResourceType;
// Human readable, singular label.
Expand Down
Loading
Loading