🚧 wip: pagination and search work

This commit is contained in:
Fred KISSIE
2026-01-30 05:39:01 +01:00
parent b04385a340
commit 89695df012
6 changed files with 667 additions and 62 deletions

View File

@@ -222,15 +222,24 @@ export async function listSites(
const accessibleSiteIds = accessibleSites.map((site) => site.siteId); const accessibleSiteIds = accessibleSites.map((site) => site.siteId);
const baseQuery = querySites(orgId, accessibleSiteIds, query); const baseQuery = querySites(orgId, accessibleSiteIds, query);
let conditions = and(
inArray(sites.siteId, accessibleSiteIds),
eq(sites.orgId, orgId)
);
if (query) {
conditions = and(
conditions,
or(
ilike(sites.name, "%" + query + "%"),
ilike(sites.niceId, "%" + query + "%")
)
);
}
const countQuery = db const countQuery = db
.select({ count: count() }) .select({ count: count() })
.from(sites) .from(sites)
.where( .where(conditions);
and(
inArray(sites.siteId, accessibleSiteIds),
eq(sites.orgId, orgId)
)
);
const sitesList = await baseQuery const sitesList = await baseQuery
.limit(pageSize) .limit(pageSize)

View File

@@ -81,10 +81,8 @@ export default async function SitesPage(props: SitesPageProps) {
<SitesTable <SitesTable
sites={siteRows} sites={siteRows}
orgId={params.orgId} orgId={params.orgId}
rowCount={pagination.total}
pagination={{ pagination={{
pageCount: Math.ceil(
pagination.total / pagination.pageSize
),
pageIndex: pagination.page - 1, pageIndex: pagination.page - 1,
pageSize: pagination.pageSize pageSize: pagination.pageSize
}} }}

View File

@@ -4,11 +4,6 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { Badge } from "@app/components/ui/badge"; import { Badge } from "@app/components/ui/badge";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import {
DataTable,
ExtendedColumnDef,
type DataTablePaginationState
} from "@app/components/ui/data-table";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -33,6 +28,10 @@ import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { useDebouncedCallback } from "use-debounce"; import { useDebouncedCallback } from "use-debounce";
import {
ManualDataTable,
type ExtendedColumnDef
} from "./ui/manual-data-table";
export type SiteRow = { export type SiteRow = {
id: number; id: number;
@@ -53,14 +52,16 @@ export type SiteRow = {
type SitesTableProps = { type SitesTableProps = {
sites: SiteRow[]; sites: SiteRow[];
pagination: DataTablePaginationState; pagination: PaginationState;
orgId: string; orgId: string;
rowCount: number;
}; };
export default function SitesTable({ export default function SitesTable({
sites, sites,
orgId, orgId,
pagination pagination,
rowCount
}: SitesTableProps) { }: SitesTableProps) {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -427,14 +428,18 @@ export default function SitesTable({
startTransition(() => router.push(`${pathname}?${sp.toString()}`)); startTransition(() => router.push(`${pathname}?${sp.toString()}`));
}; };
// const = useDebouncedCallback()
const handleSearchChange = useDebouncedCallback((query: string) => { const handleSearchChange = useDebouncedCallback((query: string) => {
const sp = new URLSearchParams(searchParams); const sp = new URLSearchParams(searchParams);
sp.set("query", query); sp.set("query", query);
sp.delete("page");
startTransition(() => router.push(`${pathname}?${sp.toString()}`)); startTransition(() => router.push(`${pathname}?${sp.toString()}`));
}, 300); }, 300);
console.log({
pagination,
rowCount
});
return ( return (
<> <>
{selectedSite && ( {selectedSite && (
@@ -459,13 +464,11 @@ export default function SitesTable({
/> />
)} )}
<DataTable <ManualDataTable
columns={columns} columns={columns}
data={sites} rows={sites}
persistPageSize="sites-table" tableId="sites-table"
title={t("sites")}
searchPlaceholder={t("searchSitesProgress")} searchPlaceholder={t("searchSitesProgress")}
manualFiltering
pagination={pagination} pagination={pagination}
onPaginationChange={handlePaginationChange} onPaginationChange={handlePaginationChange}
onAdd={() => router.push(`/${orgId}/settings/sites/create`)} onAdd={() => router.push(`/${orgId}/settings/sites/create`)}
@@ -474,10 +477,7 @@ export default function SitesTable({
addButtonText={t("siteAdd")} addButtonText={t("siteAdd")}
onRefresh={() => startTransition(refreshData)} onRefresh={() => startTransition(refreshData)}
isRefreshing={isRefreshing} isRefreshing={isRefreshing}
defaultSort={{ rowCount={rowCount}
id: "name",
desc: false
}}
columnVisibility={{ columnVisibility={{
niceId: false, niceId: false,
nice: false, nice: false,

View File

@@ -13,7 +13,10 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { formatFingerprintInfo, formatPlatform } from "@app/lib/formatDeviceFingerprint"; import {
formatFingerprintInfo,
formatPlatform
} from "@app/lib/formatDeviceFingerprint";
import { import {
ArrowRight, ArrowRight,
ArrowUpDown, ArrowUpDown,
@@ -188,8 +191,12 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
try { try {
// Fetch approvalId for this client using clientId query parameter // Fetch approvalId for this client using clientId query parameter
const approvalsRes = await api.get<{ const approvalsRes = await api.get<{
data: { approvals: Array<{ approvalId: number; clientId: number }> }; data: {
}>(`/org/${clientRow.orgId}/approvals?approvalState=pending&clientId=${clientRow.id}`); approvals: Array<{ approvalId: number; clientId: number }>;
};
}>(
`/org/${clientRow.orgId}/approvals?approvalState=pending&clientId=${clientRow.id}`
);
const approval = approvalsRes.data.data.approvals[0]; const approval = approvalsRes.data.data.approvals[0];
@@ -202,9 +209,12 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
return; return;
} }
await api.put(`/org/${clientRow.orgId}/approvals/${approval.approvalId}`, { await api.put(
decision: "approved" `/org/${clientRow.orgId}/approvals/${approval.approvalId}`,
}); {
decision: "approved"
}
);
toast({ toast({
title: t("accessApprovalUpdated"), title: t("accessApprovalUpdated"),
@@ -230,8 +240,12 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
try { try {
// Fetch approvalId for this client using clientId query parameter // Fetch approvalId for this client using clientId query parameter
const approvalsRes = await api.get<{ const approvalsRes = await api.get<{
data: { approvals: Array<{ approvalId: number; clientId: number }> }; data: {
}>(`/org/${clientRow.orgId}/approvals?approvalState=pending&clientId=${clientRow.id}`); approvals: Array<{ approvalId: number; clientId: number }>;
};
}>(
`/org/${clientRow.orgId}/approvals?approvalState=pending&clientId=${clientRow.id}`
);
const approval = approvalsRes.data.data.approvals[0]; const approval = approvalsRes.data.data.approvals[0];
@@ -244,9 +258,12 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
return; return;
} }
await api.put(`/org/${clientRow.orgId}/approvals/${approval.approvalId}`, { await api.put(
decision: "denied" `/org/${clientRow.orgId}/approvals/${approval.approvalId}`,
}); {
decision: "denied"
}
);
toast({ toast({
title: t("accessApprovalUpdated"), title: t("accessApprovalUpdated"),
@@ -398,7 +415,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
}, },
{ {
accessorKey: "online", accessorKey: "online",
friendlyName: t("connectivity"), friendlyName: t("online"),
header: ({ column }) => { header: ({ column }) => {
return ( return (
<Button <Button
@@ -548,20 +565,25 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
{clientRow.approvalState === "pending" && build !== "oss" && ( {clientRow.approvalState === "pending" &&
<> build !== "oss" && (
<DropdownMenuItem <>
onClick={() => approveDevice(clientRow)} <DropdownMenuItem
> onClick={() =>
<span>{t("approve")}</span> approveDevice(clientRow)
</DropdownMenuItem> }
<DropdownMenuItem >
onClick={() => denyDevice(clientRow)} <span>{t("approve")}</span>
> </DropdownMenuItem>
<span>{t("deny")}</span> <DropdownMenuItem
</DropdownMenuItem> onClick={() =>
</> denyDevice(clientRow)
)} }
>
<span>{t("deny")}</span>
</DropdownMenuItem>
</>
)}
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
if (clientRow.archived) { if (clientRow.archived) {
@@ -653,7 +675,10 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
]; ];
if (build === "oss") { if (build === "oss") {
return allOptions.filter((option) => option.value !== "pending" && option.value !== "denied"); return allOptions.filter(
(option) =>
option.value !== "pending" && option.value !== "denied"
);
} }
return allOptions; return allOptions;
@@ -717,7 +742,11 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
const rowArchived = row.archived; const rowArchived = row.archived;
const rowBlocked = row.blocked; const rowBlocked = row.blocked;
const approvalState = row.approvalState; const approvalState = row.approvalState;
const isActive = !rowArchived && !rowBlocked && approvalState !== "pending" && approvalState !== "denied"; const isActive =
!rowArchived &&
!rowBlocked &&
approvalState !== "pending" &&
approvalState !== "denied";
if (selectedValues.includes("active") && isActive) if (selectedValues.includes("active") && isActive)
return true; return true;

View File

@@ -320,6 +320,11 @@ export function DataTable<TData, TValue>({
return result; return result;
}, [data, tabs, activeTab, filters, activeFilters]); }, [data, tabs, activeTab, filters, activeFilters]);
console.log({
pagination,
paginationState
});
const table = useReactTable({ const table = useReactTable({
data: filteredData, data: filteredData,
columns, columns,
@@ -339,13 +344,10 @@ export function DataTable<TData, TValue>({
} }
: setPagination, : setPagination,
manualFiltering, manualFiltering,
manualPagination: !!paginationState, manualPagination: Boolean(paginationState),
pageCount: paginationState?.pageCount, pageCount: paginationState?.pageCount,
initialState: { initialState: {
pagination: { pagination,
pageSize: pageSize,
pageIndex: 0
},
columnVisibility: initialColumnVisibility columnVisibility: initialColumnVisibility
}, },
state: { state: {

View File

@@ -0,0 +1,567 @@
"use client";
import {
ColumnDef,
ColumnFiltersState,
flexRender,
getCoreRowModel,
PaginationState,
useReactTable
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@/components/ui/table";
import { DataTablePagination } from "@app/components/DataTablePagination";
import { Button } from "@app/components/ui/button";
import { Card, CardContent, CardHeader } from "@app/components/ui/card";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { Input } from "@app/components/ui/input";
import { useStoredColumnVisibility } from "@app/hooks/useStoredColumnVisibility";
import { Columns, Filter, Plus, RefreshCw, Search } from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
// Extended ColumnDef type that includes optional friendlyName for column visibility dropdown
export type ExtendedColumnDef<TData, TValue = unknown> = ColumnDef<
TData,
TValue
> & {
friendlyName?: string;
};
type FilterOption = {
id: string;
label: string;
value: string | number | boolean;
};
type DataTableFilter = {
id: string;
label: string;
options: FilterOption[];
multiSelect?: 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 DataTablePaginationUpdateFn = (newPage: PaginationState) => void;
type ManualDataTableProps<TData, TValue> = {
columns: ExtendedColumnDef<TData, TValue>[];
rows: TData[];
tableId: string;
addButtonText?: string;
onAdd?: () => void;
onRefresh?: () => void;
isRefreshing?: boolean;
searchPlaceholder?: string;
filters?: DataTableFilter[];
filterDisplayMode?: "label" | "calculated"; // Global filter display mode (can be overridden per filter)
columnVisibility?: Record<string, boolean>;
enableColumnVisibility?: boolean;
onSearch: (input: string) => void;
searchQuery?: string;
onPaginationChange: DataTablePaginationUpdateFn;
stickyLeftColumn?: string; // Column ID or accessorKey for left sticky column
stickyRightColumn?: string; // Column ID or accessorKey for right sticky column (typically "actions")
rowCount: number;
pagination: PaginationState;
};
export function ManualDataTable<TData, TValue>({
columns,
rows,
addButtonText,
onAdd,
onRefresh,
isRefreshing,
searchPlaceholder = "Search...",
filters,
filterDisplayMode = "label",
columnVisibility: defaultColumnVisibility,
enableColumnVisibility = false,
tableId,
pagination,
stickyLeftColumn,
onSearch,
searchQuery,
onPaginationChange,
stickyRightColumn,
rowCount
}: ManualDataTableProps<TData, TValue>) {
const t = useTranslations();
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useStoredColumnVisibility(
tableId,
defaultColumnVisibility
);
// TODO: filters
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;
});
console.log({
pagination
});
const table = useReactTable({
data: rows,
columns,
getCoreRowModel: getCoreRowModel(),
// getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: (state) => {
const newState =
typeof state === "function" ? state(pagination) : state;
onPaginationChange(newState);
},
manualFiltering: true,
manualPagination: true,
// pageCount: pagination.pageCount,
rowCount,
state: {
columnFilters,
columnVisibility,
pagination
}
});
// 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;
}
const selectedOptions = filter.options.filter((option) =>
selectedValues.includes(option.value)
);
if (selectedOptions.length === 0) {
return filter.label;
}
if (selectedOptions.length === 1) {
return selectedOptions[0].label;
}
// Multiple selections: always join with "and"
return selectedOptions.map((opt) => opt.label).join(" and ");
};
// Helper function to check if a column should be sticky
const isStickyColumn = (
columnId: string | undefined,
accessorKey: string | undefined,
position: "left" | "right"
): boolean => {
if (position === "left" && stickyLeftColumn) {
return (
columnId === stickyLeftColumn ||
accessorKey === stickyLeftColumn
);
}
if (position === "right" && stickyRightColumn) {
return (
columnId === stickyRightColumn ||
accessorKey === stickyRightColumn
);
}
return false;
};
// Get sticky column classes
const getStickyClasses = (
columnId: string | undefined,
accessorKey: string | undefined
): string => {
if (isStickyColumn(columnId, accessorKey, "left")) {
return "md:sticky md:left-0 z-10 bg-card [mask-image:linear-gradient(to_left,transparent_0%,black_20px)]";
}
if (isStickyColumn(columnId, accessorKey, "right")) {
return "sticky right-0 z-10 w-auto min-w-fit bg-card [mask-image:linear-gradient(to_right,transparent_0%,black_20px)]";
}
return "";
};
return (
<div className="container mx-auto max-w-12xl">
<Card>
<CardHeader className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 pb-4">
<div className="flex flex-row space-y-3 w-full sm:mr-2 gap-2">
<div className="relative w-full sm:max-w-sm">
<Input
placeholder={searchPlaceholder}
defaultValue={searchQuery}
onChange={(e) =>
onSearch(e.currentTarget.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" />
</div>
{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;
return (
<DropdownMenu key={filter.id}>
<DropdownMenuTrigger asChild>
<Button
variant={"outline"}
size="sm"
className="h-9"
>
<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>
)}
</Button>
</DropdownMenuTrigger>
<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,
// checked
// )
}}
onSelect={(e) =>
e.preventDefault()
}
>
{option.label}
</DropdownMenuCheckboxItem>
);
}
)}
</DropdownMenuContent>
</DropdownMenu>
);
})}
</div>
)}
</div>
<div className="flex items-center gap-2 sm:justify-end">
{onRefresh && (
<div>
<Button
variant="outline"
onClick={onRefresh}
disabled={isRefreshing}
>
<RefreshCw
className={`mr-0 sm:mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
/>
<span className="hidden sm:inline">
{t("refresh")}
</span>
</Button>
</div>
)}
{onAdd && addButtonText && (
<div>
<Button onClick={onAdd}>
<Plus className="mr-2 h-4 w-4" />
{addButtonText}
</Button>
</div>
)}
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const columnId = header.column.id;
const accessorKey = (
header.column.columnDef as any
).accessorKey as string | undefined;
const stickyClasses =
getStickyClasses(
columnId,
accessorKey
);
const isRightSticky =
isStickyColumn(
columnId,
accessorKey,
"right"
);
const hasHideableColumns =
enableColumnVisibility &&
table
.getAllColumns()
.some((col) =>
col.getCanHide()
);
return (
<TableHead
key={header.id}
className={`whitespace-nowrap ${stickyClasses}`}
>
{header.isPlaceholder ? null : isRightSticky &&
hasHideableColumns ? (
<div className="flex flex-col items-end pr-3">
<DropdownMenu>
<DropdownMenuTrigger
asChild
>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 mb-1"
>
<Columns className="h-4 w-4" />
<span className="sr-only">
{t(
"columns"
) ||
"Columns"}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-48"
>
<DropdownMenuLabel>
{t(
"toggleColumns"
) ||
"Toggle columns"}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{table
.getAllColumns()
.filter(
(
column
) =>
column.getCanHide()
)
.map(
(
column
) => {
const columnDef =
column.columnDef as any;
const friendlyName =
columnDef.friendlyName;
const displayName =
friendlyName ||
(typeof columnDef.header ===
"string"
? columnDef.header
: column.id);
return (
<DropdownMenuCheckboxItem
key={
column.id
}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(
value
) =>
column.toggleVisibility(
!!value
)
}
onSelect={(
e
) =>
e.preventDefault()
}
>
{
displayName
}
</DropdownMenuCheckboxItem>
);
}
)}
</DropdownMenuContent>
</DropdownMenu>
<div className="h-0 opacity-0 pointer-events-none overflow-hidden">
{flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
</div>
</div>
) : (
flexRender(
header.column
.columnDef
.header,
header.getContext()
)
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={
row.getIsSelected() &&
"selected"
}
>
{row
.getVisibleCells()
.map((cell) => {
const columnId =
cell.column.id;
const accessorKey = (
cell.column
.columnDef as any
).accessorKey as
| string
| undefined;
const stickyClasses =
getStickyClasses(
columnId,
accessorKey
);
const isRightSticky =
isStickyColumn(
columnId,
accessorKey,
"right"
);
return (
<TableCell
key={cell.id}
className={`whitespace-nowrap ${stickyClasses} ${isRightSticky ? "text-right" : ""}`}
>
{flexRender(
cell.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
);
})}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="mt-4">
{rowCount > 0 && (
<DataTablePagination
table={table}
totalCount={rowCount}
onPageSizeChange={(pageSize) =>
onPaginationChange({
...pagination,
pageSize
})
}
onPageChange={(pageIndex) => {
onPaginationChange({
...pagination,
pageIndex
});
}}
isServerPagination
pageSize={pagination.pageSize}
pageIndex={pagination.pageIndex}
/>
)}
</div>
</CardContent>
</Card>
</div>
);
}