From 01a2820390847edec61898203687092ab6d6c5bf Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 29 Jan 2026 05:07:27 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=A7=20POC:=20pagination=20in=20sites?= =?UTF-8?q?=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/site/listSites.ts | 34 ++--- src/app/[orgId]/settings/sites/page.tsx | 27 +++- src/components/SitesDataTable.tsx | 50 -------- src/components/SitesTable.tsx | 66 +++++++--- src/components/ui/data-table.tsx | 158 ++++++++++++++++-------- src/lib/queries.ts | 35 +++--- 6 files changed, 217 insertions(+), 153 deletions(-) delete mode 100644 src/components/SitesDataTable.tsx diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index 68fa05b1..dab79c8d 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -74,18 +74,20 @@ const listSitesParamsSchema = z.strictObject({ }); const listSitesSchema = z.object({ - limit: z - .string() + pageSize: z.coerce + .number() // for prettier formatting + .int() + .positive() .optional() - .default("1") - .transform(Number) - .pipe(z.int().positive()), - offset: z - .string() + .catch(20) + .default(20), + page: z.coerce + .number() // for prettier formatting + .int() + .min(0) .optional() - .default("0") - .transform(Number) - .pipe(z.int().nonnegative()) + .catch(1) + .default(1) }); function querySites(orgId: string, accessibleSiteIds: number[]) { @@ -130,7 +132,7 @@ type SiteWithUpdateAvailable = Awaited>[0] & { export type ListSitesResponse = { sites: SiteWithUpdateAvailable[]; - pagination: { total: number; limit: number; offset: number; }; + pagination: { total: number; pageSize: number; page: number }; }; registry.registerPath({ @@ -160,7 +162,7 @@ export async function listSites( ) ); } - const { limit, offset } = parsedQuery.data; + const { pageSize, page } = parsedQuery.data; const parsedParams = listSitesParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -216,7 +218,9 @@ export async function listSites( ) ); - const sitesList = await baseQuery.limit(limit).offset(offset); + const sitesList = await baseQuery + .limit(pageSize) + .offset(pageSize * (page - 1)); const totalCountResult = await countQuery; const totalCount = totalCountResult[0].count; @@ -267,8 +271,8 @@ export async function listSites( sites: sitesWithUpdates, pagination: { total: totalCount, - limit, - offset + pageSize, + page } }, success: true, diff --git a/src/app/[orgId]/settings/sites/page.tsx b/src/app/[orgId]/settings/sites/page.tsx index 877eb594..69bb599c 100644 --- a/src/app/[orgId]/settings/sites/page.tsx +++ b/src/app/[orgId]/settings/sites/page.tsx @@ -9,19 +9,30 @@ import { getTranslations } from "next-intl/server"; type SitesPageProps = { params: Promise<{ orgId: string }>; + searchParams: Promise>; }; export const dynamic = "force-dynamic"; export default async function SitesPage(props: SitesPageProps) { const params = await props.params; + + const searchParams = new URLSearchParams(await props.searchParams); + let sites: ListSitesResponse["sites"] = []; + let pagination: ListSitesResponse["pagination"] = { + total: 0, + page: 1, + pageSize: 20 + }; try { const res = await internal.get>( - `/org/${params.orgId}/sites`, + `/org/${params.orgId}/sites?${searchParams.toString()}`, await authCookieHeader() ); - sites = res.data.data.sites; + const responseData = res.data.data; + sites = responseData.sites; + pagination = responseData.pagination; } catch (e) {} const t = await getTranslations(); @@ -67,7 +78,17 @@ export default async function SitesPage(props: SitesPageProps) { - + ); } diff --git a/src/components/SitesDataTable.tsx b/src/components/SitesDataTable.tsx deleted file mode 100644 index 125f4d59..00000000 --- a/src/components/SitesDataTable.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"use client"; - -import { ColumnDef } from "@tanstack/react-table"; -import { DataTable } from "@app/components/ui/data-table"; -import { useTranslations } from "next-intl"; - -interface DataTableProps { - columns: ColumnDef[]; - data: TData[]; - createSite?: () => void; - onRefresh?: () => void; - isRefreshing?: boolean; - columnVisibility?: Record; - enableColumnVisibility?: boolean; -} - -export function SitesDataTable({ - columns, - data, - createSite, - onRefresh, - isRefreshing, - columnVisibility, - enableColumnVisibility -}: DataTableProps) { - const t = useTranslations(); - - return ( - - ); -} diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 58c2366b..497715b1 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -1,10 +1,14 @@ "use client"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; -import { SitesDataTable } from "@app/components/SitesDataTable"; + import { Badge } from "@app/components/ui/badge"; import { Button } from "@app/components/ui/button"; -import { ExtendedColumnDef } from "@app/components/ui/data-table"; +import { + DataTable, + ExtendedColumnDef, + type DataTablePaginationState +} from "@app/components/ui/data-table"; import { DropdownMenu, DropdownMenuContent, @@ -26,7 +30,7 @@ import { } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; -import { useRouter } from "next/navigation"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useEffect, useState, useTransition } from "react"; export type SiteRow = { @@ -48,15 +52,21 @@ export type SiteRow = { type SitesTableProps = { sites: SiteRow[]; + pagination: DataTablePaginationState; orgId: string; }; -export default function SitesTable({ sites, orgId }: SitesTableProps) { +export default function SitesTable({ + sites, + orgId, + pagination +}: SitesTableProps) { const router = useRouter(); + const searchParams = useSearchParams(); + const pathname = usePathname(); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedSite, setSelectedSite] = useState(null); - const [rows, setRows] = useState(sites); const [isRefreshing, startTransition] = useTransition(); const api = createApiClient(useEnvContext()); @@ -87,10 +97,6 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { .then(() => { router.refresh(); setIsDeleteModalOpen(false); - - const newRows = rows.filter((row) => row.id !== siteId); - - setRows(newRows); }); }; @@ -413,6 +419,11 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { } ]; + console.log({ + sites, + pagination + }); + return ( <> {selectedSite && ( @@ -429,27 +440,50 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { } buttonText={t("siteConfirmDelete")} - onConfirm={async () => deleteSite(selectedSite!.id)} + onConfirm={async () => + startTransition(() => deleteSite(selectedSite!.id)) + } string={selectedSite.name} title={t("siteDelete")} /> )} - - router.push(`/${orgId}/settings/sites/create`) - } + data={sites} + persistPageSize="sites-table" + title={t("sites")} + searchPlaceholder={t("searchSitesProgress")} + manualFiltering + pagination={pagination} + onPaginationChange={(newPage) => { + console.log({ + newPage + }); + const sp = new URLSearchParams(searchParams); + sp.set("page", (newPage.pageIndex + 1).toString()); + sp.set("pageSize", newPage.pageSize.toString()); + startTransition(() => + router.push(`${pathname}?${sp.toString()}`) + ); + }} + onAdd={() => router.push(`/${orgId}/settings/sites/create`)} + addButtonText={t("siteAdd")} onRefresh={() => startTransition(refreshData)} isRefreshing={isRefreshing} + defaultSort={{ + id: "name", + desc: false + }} columnVisibility={{ niceId: false, nice: false, exitNode: false, address: false }} - enableColumnVisibility={true} + enableColumnVisibility + stickyLeftColumn="name" + stickyRightColumn="actions" /> ); diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx index af61bb53..bb350577 100644 --- a/src/components/ui/data-table.tsx +++ b/src/components/ui/data-table.tsx @@ -151,11 +151,20 @@ type DataTableFilter = { label: string; options: FilterOption[]; multiSelect?: boolean; - filterFn: (row: any, selectedValues: (string | number | boolean)[]) => boolean; + filterFn: ( + row: any, + selectedValues: (string | number | boolean)[] + ) => boolean; defaultValues?: (string | number | boolean)[]; displayMode?: "label" | "calculated"; // How to display the filter button text }; +export type DataTablePaginationState = PaginationState & { + pageCount: number; +}; + +export type DataTablePaginationUpdateFn = (newPage: PaginationState) => void; + type DataTableProps = { columns: ExtendedColumnDef[]; data: TData[]; @@ -178,6 +187,11 @@ type DataTableProps = { defaultPageSize?: number; columnVisibility?: Record; enableColumnVisibility?: boolean; + manualFiltering?: boolean; + onSearch?: (input: string) => void; + searchValue?: string; + pagination?: DataTablePaginationState; + onPaginationChange?: DataTablePaginationUpdateFn; persistColumnVisibility?: boolean | string; stickyLeftColumn?: string; // Column ID or accessorKey for left sticky column stickyRightColumn?: string; // Column ID or accessorKey for right sticky column (typically "actions") @@ -203,7 +217,12 @@ export function DataTable({ columnVisibility: defaultColumnVisibility, enableColumnVisibility = false, persistColumnVisibility = false, + manualFiltering = false, + pagination: paginationState, stickyLeftColumn, + onSearch, + searchValue, + onPaginationChange, stickyRightColumn }: DataTableProps) { const t = useTranslations(); @@ -248,22 +267,25 @@ export function DataTable({ const [columnVisibility, setColumnVisibility] = useState( initialColumnVisibility ); - const [pagination, setPagination] = useState({ + const [_pagination, setPagination] = useState({ pageIndex: 0, pageSize: pageSize }); + + const pagination = paginationState ?? _pagination; + const [activeTab, setActiveTab] = useState( defaultTab || tabs?.[0]?.id || "" ); - const [activeFilters, setActiveFilters] = useState>( - () => { - const initial: Record = {}; - filters?.forEach((filter) => { - initial[filter.id] = filter.defaultValues || []; - }); - return initial; - } - ); + const [activeFilters, setActiveFilters] = useState< + Record + >(() => { + const initial: Record = {}; + filters?.forEach((filter) => { + initial[filter.id] = filter.defaultValues || []; + }); + return initial; + }); // Track initial values to avoid storing defaults on first render const initialPageSize = useRef(pageSize); @@ -309,7 +331,16 @@ export function DataTable({ getFilteredRowModel: getFilteredRowModel(), onGlobalFilterChange: setGlobalFilter, onColumnVisibilityChange: setColumnVisibility, - onPaginationChange: setPagination, + onPaginationChange: onPaginationChange + ? (state) => { + const newState = + typeof state === "function" ? state(pagination) : state; + onPaginationChange(newState); + } + : setPagination, + manualFiltering, + manualPagination: !!paginationState, + pageCount: paginationState?.pageCount, initialState: { pagination: { pageSize: pageSize, @@ -368,11 +399,11 @@ export function DataTable({ setActiveFilters((prev) => { const currentValues = prev[filterId] || []; const filter = filters?.find((f) => f.id === filterId); - + if (!filter) return prev; let newValues: (string | number | boolean)[]; - + if (filter.multiSelect) { // Multi-select: add or remove the value if (checked) { @@ -397,7 +428,7 @@ export function DataTable({ // Calculate display text for a filter based on selected values const getFilterDisplayText = (filter: DataTableFilter): string => { const selectedValues = activeFilters[filter.id] || []; - + if (selectedValues.length === 0) { return filter.label; } @@ -477,12 +508,14 @@ export function DataTable({
- table.setGlobalFilter( - String(e.target.value) - ) - } + value={searchValue ?? globalFilter ?? ""} + onChange={(e) => { + onSearch + ? onSearch(e.currentTarget.value) + : table.setGlobalFilter( + String(e.target.value) + ); + }} className="w-full pl-8" /> @@ -490,13 +523,17 @@ export function DataTable({ {filters && filters.length > 0 && (
{filters.map((filter) => { - const selectedValues = activeFilters[filter.id] || []; - const hasActiveFilters = selectedValues.length > 0; - const displayMode = filter.displayMode || filterDisplayMode; - const displayText = displayMode === "calculated" - ? getFilterDisplayText(filter) - : filter.label; - + const selectedValues = + activeFilters[filter.id] || []; + const hasActiveFilters = + selectedValues.length > 0; + const displayMode = + filter.displayMode || filterDisplayMode; + const displayText = + displayMode === "calculated" + ? getFilterDisplayText(filter) + : filter.label; + return ( @@ -507,37 +544,54 @@ export function DataTable({ > {displayText} - {displayMode === "label" && hasActiveFilters && ( - - {selectedValues.length} - - )} + {displayMode === "label" && + hasActiveFilters && ( + + { + selectedValues.length + } + + )} - + {filter.label} - {filter.options.map((option) => { - const isChecked = selectedValues.includes(option.value); - return ( - - handleFilterChange( - filter.id, - option.value, + {filter.options.map( + (option) => { + const isChecked = + selectedValues.includes( + option.value + ); + return ( + e.preventDefault()} - > - {option.label} - - ); - })} + ) => + handleFilterChange( + filter.id, + option.value, + checked + ) + } + onSelect={(e) => + e.preventDefault() + } + > + {option.label} + + ); + } + )} ); diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 45746ed3..6c8e67c0 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -113,7 +113,7 @@ export const orgQueries = { return res.data.data.clients; } }), - users: ({ orgId }: { orgId: string; }) => + users: ({ orgId }: { orgId: string }) => queryOptions({ queryKey: ["ORG", orgId, "USERS"] as const, queryFn: async ({ signal, meta }) => { @@ -124,7 +124,7 @@ export const orgQueries = { return res.data.data.users; } }), - roles: ({ orgId }: { orgId: string; }) => + roles: ({ orgId }: { orgId: string }) => queryOptions({ queryKey: ["ORG", orgId, "ROLES"] as const, queryFn: async ({ signal, meta }) => { @@ -136,7 +136,7 @@ export const orgQueries = { } }), - sites: ({ orgId }: { orgId: string; }) => + sites: ({ orgId }: { orgId: string }) => queryOptions({ queryKey: ["ORG", orgId, "SITES"] as const, queryFn: async ({ signal, meta }) => { @@ -147,7 +147,7 @@ export const orgQueries = { } }), - domains: ({ orgId }: { orgId: string; }) => + domains: ({ orgId }: { orgId: string }) => queryOptions({ queryKey: ["ORG", orgId, "DOMAINS"] as const, queryFn: async ({ signal, meta }) => { @@ -169,7 +169,7 @@ export const orgQueries = { queryFn: async ({ signal, meta }) => { const res = await meta!.api.get< AxiosResponse<{ - idps: { idpId: number; name: string; }[]; + idps: { idpId: number; name: string }[]; }> >( build === "saas" || useOrgOnlyIdp @@ -188,19 +188,20 @@ export const logAnalyticsFiltersSchema = z.object({ .refine((val) => !isNaN(Date.parse(val)), { error: "timeStart must be a valid ISO date string" }) - .optional().catch(undefined), + .optional() + .catch(undefined), timeEnd: z .string() .refine((val) => !isNaN(Date.parse(val)), { error: "timeEnd must be a valid ISO date string" }) - .optional().catch(undefined), + .optional() + .catch(undefined), resourceId: z.coerce.number().optional().catch(undefined) }); export type LogAnalyticsFilters = z.TypeOf; - export const logQueries = { requestAnalytics: ({ orgId, @@ -230,7 +231,7 @@ export const logQueries = { }; export const resourceQueries = { - resourceUsers: ({ resourceId }: { resourceId: number; }) => + resourceUsers: ({ resourceId }: { resourceId: number }) => queryOptions({ queryKey: ["RESOURCES", resourceId, "USERS"] as const, queryFn: async ({ signal, meta }) => { @@ -240,7 +241,7 @@ export const resourceQueries = { return res.data.data.users; } }), - resourceRoles: ({ resourceId }: { resourceId: number; }) => + resourceRoles: ({ resourceId }: { resourceId: number }) => queryOptions({ queryKey: ["RESOURCES", resourceId, "ROLES"] as const, queryFn: async ({ signal, meta }) => { @@ -251,7 +252,7 @@ export const resourceQueries = { return res.data.data.roles; } }), - siteResourceUsers: ({ siteResourceId }: { siteResourceId: number; }) => + siteResourceUsers: ({ siteResourceId }: { siteResourceId: number }) => queryOptions({ queryKey: ["SITE_RESOURCES", siteResourceId, "USERS"] as const, queryFn: async ({ signal, meta }) => { @@ -261,7 +262,7 @@ export const resourceQueries = { return res.data.data.users; } }), - siteResourceRoles: ({ siteResourceId }: { siteResourceId: number; }) => + siteResourceRoles: ({ siteResourceId }: { siteResourceId: number }) => queryOptions({ queryKey: ["SITE_RESOURCES", siteResourceId, "ROLES"] as const, queryFn: async ({ signal, meta }) => { @@ -272,7 +273,7 @@ export const resourceQueries = { return res.data.data.roles; } }), - siteResourceClients: ({ siteResourceId }: { siteResourceId: number; }) => + siteResourceClients: ({ siteResourceId }: { siteResourceId: number }) => queryOptions({ queryKey: ["SITE_RESOURCES", siteResourceId, "CLIENTS"] as const, queryFn: async ({ signal, meta }) => { @@ -283,7 +284,7 @@ export const resourceQueries = { return res.data.data.clients; } }), - resourceTargets: ({ resourceId }: { resourceId: number; }) => + resourceTargets: ({ resourceId }: { resourceId: number }) => queryOptions({ queryKey: ["RESOURCES", resourceId, "TARGETS"] as const, queryFn: async ({ signal, meta }) => { @@ -294,7 +295,7 @@ export const resourceQueries = { return res.data.data.targets; } }), - resourceWhitelist: ({ resourceId }: { resourceId: number; }) => + resourceWhitelist: ({ resourceId }: { resourceId: number }) => queryOptions({ queryKey: ["RESOURCES", resourceId, "WHITELISTS"] as const, queryFn: async ({ signal, meta }) => { @@ -367,7 +368,7 @@ export const approvalQueries = { } const res = await meta!.api.get< - AxiosResponse<{ approvals: ApprovalItem[]; }> + AxiosResponse<{ approvals: ApprovalItem[] }> >(`/org/${orgId}/approvals?${sp.toString()}`, { signal }); @@ -379,7 +380,7 @@ export const approvalQueries = { queryKey: ["APPROVALS", orgId, "COUNT", "pending"] as const, queryFn: async ({ signal, meta }) => { const res = await meta!.api.get< - AxiosResponse<{ count: number; }> + AxiosResponse<{ count: number }> >(`/org/${orgId}/approvals/count?approvalState=pending`, { signal });