🚧 POC: pagination in sites table

This commit is contained in:
Fred KISSIE
2026-01-29 05:07:27 +01:00
parent c89c1a03da
commit 01a2820390
6 changed files with 217 additions and 153 deletions

View File

@@ -74,18 +74,20 @@ const listSitesParamsSchema = z.strictObject({
});
const listSitesSchema = z.object({
limit: z
.string()
pageSize: z.coerce
.number<string>() // 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<string>() // 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<ReturnType<typeof querySites>>[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,

View File

@@ -9,19 +9,30 @@ import { getTranslations } from "next-intl/server";
type SitesPageProps = {
params: Promise<{ orgId: string }>;
searchParams: Promise<Record<string, string>>;
};
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<AxiosResponse<ListSitesResponse>>(
`/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) {
<SitesBanner />
<SitesTable sites={siteRows} orgId={params.orgId} />
<SitesTable
sites={siteRows}
orgId={params.orgId}
pagination={{
pageCount: Math.ceil(
pagination.total / pagination.pageSize
),
pageIndex: pagination.page - 1,
pageSize: pagination.pageSize
}}
/>
</>
);
}

View File

@@ -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<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
createSite?: () => void;
onRefresh?: () => void;
isRefreshing?: boolean;
columnVisibility?: Record<string, boolean>;
enableColumnVisibility?: boolean;
}
export function SitesDataTable<TData, TValue>({
columns,
data,
createSite,
onRefresh,
isRefreshing,
columnVisibility,
enableColumnVisibility
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
<DataTable
columns={columns}
data={data}
persistPageSize="sites-table"
title={t("sites")}
searchPlaceholder={t("searchSitesProgress")}
searchColumn="name"
onAdd={createSite}
addButtonText={t("siteAdd")}
onRefresh={onRefresh}
isRefreshing={isRefreshing}
defaultSort={{
id: "name",
desc: false
}}
columnVisibility={columnVisibility}
enableColumnVisibility={enableColumnVisibility}
stickyLeftColumn="name"
stickyRightColumn="actions"
/>
);
}

View File

@@ -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<SiteRow | null>(null);
const [rows, setRows] = useState<SiteRow[]>(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) {
</div>
}
buttonText={t("siteConfirmDelete")}
onConfirm={async () => deleteSite(selectedSite!.id)}
onConfirm={async () =>
startTransition(() => deleteSite(selectedSite!.id))
}
string={selectedSite.name}
title={t("siteDelete")}
/>
)}
<SitesDataTable
<DataTable
columns={columns}
data={rows}
createSite={() =>
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"
/>
</>
);

View File

@@ -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<TData, TValue> = {
columns: ExtendedColumnDef<TData, TValue>[];
data: TData[];
@@ -178,6 +187,11 @@ type DataTableProps<TData, TValue> = {
defaultPageSize?: number;
columnVisibility?: Record<string, boolean>;
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<TData, TValue>({
columnVisibility: defaultColumnVisibility,
enableColumnVisibility = false,
persistColumnVisibility = false,
manualFiltering = false,
pagination: paginationState,
stickyLeftColumn,
onSearch,
searchValue,
onPaginationChange,
stickyRightColumn
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
@@ -248,22 +267,25 @@ export function DataTable<TData, TValue>({
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
initialColumnVisibility
);
const [pagination, setPagination] = useState<PaginationState>({
const [_pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: pageSize
});
const pagination = paginationState ?? _pagination;
const [activeTab, setActiveTab] = useState<string>(
defaultTab || tabs?.[0]?.id || ""
);
const [activeFilters, setActiveFilters] = useState<Record<string, (string | number | boolean)[]>>(
() => {
const initial: Record<string, (string | number | boolean)[]> = {};
filters?.forEach((filter) => {
initial[filter.id] = filter.defaultValues || [];
});
return initial;
}
);
const [activeFilters, setActiveFilters] = useState<
Record<string, (string | number | boolean)[]>
>(() => {
const initial: Record<string, (string | number | boolean)[]> = {};
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<TData, TValue>({
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<TData, TValue>({
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<TData, TValue>({
// 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<TData, TValue>({
<div className="relative w-full sm:max-w-sm">
<Input
placeholder={searchPlaceholder}
value={globalFilter ?? ""}
onChange={(e) =>
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"
/>
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
@@ -490,13 +523,17 @@ export function DataTable<TData, TValue>({
{filters && filters.length > 0 && (
<div className="flex gap-2">
{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 (
<DropdownMenu key={filter.id}>
<DropdownMenuTrigger asChild>
@@ -507,37 +544,54 @@ export function DataTable<TData, TValue>({
>
<Filter className="h-4 w-4 mr-2" />
{displayText}
{displayMode === "label" && hasActiveFilters && (
<span className="ml-2 bg-muted text-foreground rounded-full px-2 py-0.5 text-xs">
{selectedValues.length}
</span>
)}
{displayMode === "label" &&
hasActiveFilters && (
<span className="ml-2 bg-muted text-foreground rounded-full px-2 py-0.5 text-xs">
{
selectedValues.length
}
</span>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
<DropdownMenuContent
align="start"
className="w-48"
>
<DropdownMenuLabel>
{filter.label}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{filter.options.map((option) => {
const isChecked = selectedValues.includes(option.value);
return (
<DropdownMenuCheckboxItem
key={option.id}
checked={isChecked}
onCheckedChange={(checked) =>
handleFilterChange(
filter.id,
option.value,
{filter.options.map(
(option) => {
const isChecked =
selectedValues.includes(
option.value
);
return (
<DropdownMenuCheckboxItem
key={option.id}
checked={
isChecked
}
onCheckedChange={(
checked
)
}
onSelect={(e) => e.preventDefault()}
>
{option.label}
</DropdownMenuCheckboxItem>
);
})}
) =>
handleFilterChange(
filter.id,
option.value,
checked
)
}
onSelect={(e) =>
e.preventDefault()
}
>
{option.label}
</DropdownMenuCheckboxItem>
);
}
)}
</DropdownMenuContent>
</DropdownMenu>
);

View File

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