paginate, search & filter resources by enabled

This commit is contained in:
Fred KISSIE
2026-02-04 02:20:28 +01:00
parent cda6b67bef
commit bb1a375484
6 changed files with 227 additions and 70 deletions

View File

@@ -17,7 +17,7 @@ import {
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { sql, eq, or, inArray, and, count } from "drizzle-orm"; import { sql, eq, or, inArray, and, count, ilike } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromZodError } from "zod-validation-error"; import { fromZodError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
@@ -27,19 +27,30 @@ const listResourcesParamsSchema = z.strictObject({
}); });
const listResourcesSchema = z.object({ const listResourcesSchema = z.object({
limit: z pageSize: z.coerce
.string() .number<string>() // for prettier formatting
.int()
.positive()
.optional() .optional()
.default("1000") .catch(20)
.transform(Number) .default(20),
.pipe(z.int().nonnegative()), page: z.coerce
.number<string>() // for prettier formatting
offset: z .int()
.string() .min(0)
.optional() .optional()
.default("0") .catch(1)
.transform(Number) .default(1),
.pipe(z.int().nonnegative()) query: z.string().optional(),
enabled: z
.enum(["true", "false"])
.transform((v) => v === "true")
.optional()
.catch(undefined),
authState: z
.enum(["protected", "not_protected"])
.optional()
.catch(undefined)
}); });
// (resource fields + a single joined target) // (resource fields + a single joined target)
@@ -95,7 +106,7 @@ export type ResourceWithTargets = {
}>; }>;
}; };
function queryResources(accessibleResourceIds: number[], orgId: string) { function queryResourcesBase() {
return db return db
.select({ .select({
resourceId: resources.resourceId, resourceId: resources.resourceId,
@@ -147,18 +158,12 @@ function queryResources(accessibleResourceIds: number[], orgId: string) {
.leftJoin( .leftJoin(
targetHealthCheck, targetHealthCheck,
eq(targetHealthCheck.targetId, targets.targetId) eq(targetHealthCheck.targetId, targets.targetId)
)
.where(
and(
inArray(resources.resourceId, accessibleResourceIds),
eq(resources.orgId, orgId)
)
); );
} }
export type ListResourcesResponse = { export type ListResourcesResponse = {
resources: ResourceWithTargets[]; resources: ResourceWithTargets[];
pagination: { total: number; limit: number; offset: number }; pagination: { total: number; pageSize: number; page: number };
}; };
registry.registerPath({ registry.registerPath({
@@ -190,7 +195,7 @@ export async function listResources(
) )
); );
} }
const { limit, offset } = parsedQuery.data; const { page, pageSize, authState, enabled, query } = parsedQuery.data;
const parsedParams = listResourcesParamsSchema.safeParse(req.params); const parsedParams = listResourcesParamsSchema.safeParse(req.params);
if (!parsedParams.success) { if (!parsedParams.success) {
@@ -252,14 +257,37 @@ export async function listResources(
(resource) => resource.resourceId (resource) => resource.resourceId
); );
let conditions = and(
and(
inArray(resources.resourceId, accessibleResourceIds),
eq(resources.orgId, orgId)
)
);
if (query) {
conditions = and(
conditions,
or(
ilike(resources.name, "%" + query + "%"),
ilike(resources.fullDomain, "%" + query + "%")
)
);
}
if (typeof enabled !== "undefined") {
conditions = and(conditions, eq(resources.enabled, enabled));
}
const countQuery: any = db const countQuery: any = db
.select({ count: count() }) .select({ count: count() })
.from(resources) .from(resources)
.where(inArray(resources.resourceId, accessibleResourceIds)); .where(conditions);
const baseQuery = queryResources(accessibleResourceIds, orgId); const baseQuery = queryResourcesBase();
const rows: JoinedRow[] = await baseQuery.limit(limit).offset(offset); const rows: JoinedRow[] = await baseQuery
.where(conditions)
.limit(pageSize)
.offset(pageSize * (page - 1));
// avoids TS issues with reduce/never[] // avoids TS issues with reduce/never[]
const map = new Map<number, ResourceWithTargets>(); const map = new Map<number, ResourceWithTargets>();
@@ -324,8 +352,8 @@ export async function listResources(
resources: resourcesList, resources: resourcesList,
pagination: { pagination: {
total: totalCount, total: totalCount,
limit, pageSize,
offset page
} }
}, },
success: true, success: true,

View File

@@ -16,7 +16,7 @@ import { cache } from "react";
export interface ProxyResourcesPageProps { export interface ProxyResourcesPageProps {
params: Promise<{ orgId: string }>; params: Promise<{ orgId: string }>;
searchParams: Promise<{ view?: string }>; searchParams: Promise<Record<string, string>>;
} }
export default async function ProxyResourcesPage( export default async function ProxyResourcesPage(
@@ -24,14 +24,22 @@ export default async function ProxyResourcesPage(
) { ) {
const params = await props.params; const params = await props.params;
const t = await getTranslations(); const t = await getTranslations();
const searchParams = new URLSearchParams(await props.searchParams);
let resources: ListResourcesResponse["resources"] = []; let resources: ListResourcesResponse["resources"] = [];
let pagination: ListResourcesResponse["pagination"] = {
total: 0,
page: 1,
pageSize: 20
};
try { try {
const res = await internal.get<AxiosResponse<ListResourcesResponse>>( const res = await internal.get<AxiosResponse<ListResourcesResponse>>(
`/org/${params.orgId}/resources`, `/org/${params.orgId}/resources?${searchParams.toString()}`,
await authCookieHeader() await authCookieHeader()
); );
resources = res.data.data.resources; const responseData = res.data.data;
resources = responseData.resources;
pagination = responseData.pagination;
} catch (e) {} } catch (e) {}
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = []; let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
@@ -104,9 +112,10 @@ export default async function ProxyResourcesPage(
<ProxyResourcesTable <ProxyResourcesTable
resources={resourceRows} resources={resourceRows}
orgId={params.orgId} orgId={params.orgId}
defaultSort={{ rowCount={pagination.total}
id: "name", pagination={{
desc: false pageIndex: pagination.page - 1,
pageSize: pagination.pageSize
}} }}
/> />
</OrgProvider> </OrgProvider>

View File

@@ -31,8 +31,14 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { ControlledDataTable } from "./ui/controlled-data-table";
import type { PaginationState } from "@tanstack/react-table";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { useDebouncedCallback } from "use-debounce";
import z from "zod";
import { ColumnFilterButton } from "./ColumnFilterButton";
export type TargetHealth = { export type TargetHealth = {
targetId: number; targetId: number;
@@ -117,18 +123,22 @@ function StatusIcon({
type ProxyResourcesTableProps = { type ProxyResourcesTableProps = {
resources: ResourceRow[]; resources: ResourceRow[];
orgId: string; orgId: string;
defaultSort?: { pagination: PaginationState;
id: string; rowCount: number;
desc: boolean;
};
}; };
export default function ProxyResourcesTable({ export default function ProxyResourcesTable({
resources, resources,
orgId, orgId,
defaultSort pagination,
rowCount
}: ProxyResourcesTableProps) { }: ProxyResourcesTableProps) {
const router = useRouter(); const router = useRouter();
const {
navigate: filter,
isNavigating: isFiltering,
searchParams
} = useNavigationContext();
const t = useTranslations(); const t = useTranslations();
const { env } = useEnvContext(); const { env } = useEnvContext();
@@ -140,6 +150,7 @@ export default function ProxyResourcesTable({
useState<ResourceRow | null>(); useState<ResourceRow | null>();
const [isRefreshing, startTransition] = useTransition(); const [isRefreshing, startTransition] = useTransition();
const [isNavigatingToAddPage, startNavigation] = useTransition();
const refreshData = () => { const refreshData = () => {
startTransition(() => { startTransition(() => {
@@ -236,7 +247,7 @@ export default function ProxyResourcesTable({
<ChevronDown className="h-3 w-3" /> <ChevronDown className="h-3 w-3" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[280px]"> <DropdownMenuContent align="start" className="min-w-70">
{monitoredTargets.length > 0 && ( {monitoredTargets.length > 0 && (
<> <>
{monitoredTargets.map((target) => ( {monitoredTargets.map((target) => (
@@ -456,7 +467,24 @@ export default function ProxyResourcesTable({
{ {
accessorKey: "enabled", accessorKey: "enabled",
friendlyName: t("enabled"), friendlyName: t("enabled"),
header: () => <span className="p-3">{t("enabled")}</span>, header: () => (
<ColumnFilterButton
options={[
{ value: "true", label: t("enabled") },
{ value: "false", label: t("disabled") }
]}
selectedValue={booleanSearchFilterSchema.parse(
searchParams.get("enabled")
)}
onValueChange={(value) =>
handleFilterChange("enabled", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("enabled")}
className="p-3"
/>
),
cell: ({ row }) => ( cell: ({ row }) => (
<Switch <Switch
defaultChecked={ defaultChecked={
@@ -525,6 +553,42 @@ export default function ProxyResourcesTable({
} }
]; ];
const booleanSearchFilterSchema = z
.enum(["true", "false"])
.optional()
.catch(undefined);
function handleFilterChange(
column: string,
value: string | undefined | null
) {
searchParams.delete(column);
searchParams.delete("page");
if (value) {
searchParams.set(column, value);
}
filter({
searchParams
});
}
const handlePaginationChange = (newPage: PaginationState) => {
searchParams.set("page", (newPage.pageIndex + 1).toString());
searchParams.set("pageSize", newPage.pageSize.toString());
filter({
searchParams
});
};
const handleSearchChange = useDebouncedCallback((query: string) => {
searchParams.set("query", query);
searchParams.delete("page");
filter({
searchParams
});
}, 300);
return ( return (
<> <>
{selectedResource && ( {selectedResource && (
@@ -547,21 +611,27 @@ export default function ProxyResourcesTable({
/> />
)} )}
<DataTable <ControlledDataTable
columns={proxyColumns} columns={proxyColumns}
data={resources} rows={resources}
persistPageSize="proxy-resources" tableId="proxy-resources"
searchPlaceholder={t("resourcesSearch")} searchPlaceholder={t("resourcesSearch")}
searchColumn="name" pagination={pagination}
rowCount={rowCount}
onSearch={handleSearchChange}
onPaginationChange={handlePaginationChange}
onAdd={() => onAdd={() =>
router.push(`/${orgId}/settings/resources/proxy/create`) startNavigation(() => {
router.push(
`/${orgId}/settings/resources/proxy/create`
);
})
} }
addButtonText={t("resourceAdd")} addButtonText={t("resourceAdd")}
onRefresh={refreshData} onRefresh={refreshData}
isRefreshing={isRefreshing} isRefreshing={isRefreshing || isFiltering}
defaultSort={defaultSort} isNavigatingToAddPage={isNavigatingToAddPage}
enableColumnVisibility={true} enableColumnVisibility
persistColumnVisibility="proxy-resources"
columnVisibility={{ niceId: false }} columnVisibility={{ niceId: false }}
stickyLeftColumn="name" stickyLeftColumn="name"
stickyRightColumn="actions" stickyRightColumn="actions"

View File

@@ -33,9 +33,9 @@ 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 { import {
ManualDataTable, ControlledDataTable,
type ExtendedColumnDef type ExtendedColumnDef
} from "./ui/manual-data-table"; } from "./ui/controlled-data-table";
import { ColumnFilter } from "./ColumnFilter"; import { ColumnFilter } from "./ColumnFilter";
import { ColumnFilterButton } from "./ColumnFilterButton"; import { ColumnFilterButton } from "./ColumnFilterButton";
import z from "zod"; import z from "zod";
@@ -77,6 +77,7 @@ export default function SitesTable({
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null); const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null);
const [isRefreshing, startTransition] = useTransition(); const [isRefreshing, startTransition] = useTransition();
const [isNavigatingToAddPage, startNavigation] = useTransition();
const [getSortDirection, toggleSorting] = useSortColumn(); const [getSortDirection, toggleSorting] = useSortColumn();
@@ -460,14 +461,19 @@ export default function SitesTable({
/> />
)} )}
<ManualDataTable <ControlledDataTable
columns={columns} columns={columns}
rows={sites} rows={sites}
tableId="sites-table" tableId="sites-table"
searchPlaceholder={t("searchSitesProgress")} searchPlaceholder={t("searchSitesProgress")}
pagination={pagination} pagination={pagination}
onPaginationChange={handlePaginationChange} onPaginationChange={handlePaginationChange}
onAdd={() => router.push(`/${orgId}/settings/sites/create`)} onAdd={() =>
startNavigation(() =>
router.push(`/${orgId}/settings/sites/create`)
)
}
isNavigatingToAddPage={isNavigatingToAddPage}
searchQuery={searchParams.get("query")?.toString()} searchQuery={searchParams.get("query")?.toString()}
onSearch={handleSearchChange} onSearch={handleSearchChange}
addButtonText={t("siteAdd")} addButtonText={t("siteAdd")}

View File

@@ -64,7 +64,7 @@ type DataTableFilter = {
export type DataTablePaginationUpdateFn = (newPage: PaginationState) => void; export type DataTablePaginationUpdateFn = (newPage: PaginationState) => void;
type ManualDataTableProps<TData, TValue> = { type ControlledDataTableProps<TData, TValue> = {
columns: ExtendedColumnDef<TData, TValue>[]; columns: ExtendedColumnDef<TData, TValue>[];
rows: TData[]; rows: TData[];
tableId: string; tableId: string;
@@ -72,12 +72,13 @@ type ManualDataTableProps<TData, TValue> = {
onAdd?: () => void; onAdd?: () => void;
onRefresh?: () => void; onRefresh?: () => void;
isRefreshing?: boolean; isRefreshing?: boolean;
isNavigatingToAddPage?: boolean;
searchPlaceholder?: string; searchPlaceholder?: string;
filters?: DataTableFilter[]; filters?: DataTableFilter[];
filterDisplayMode?: "label" | "calculated"; // Global filter display mode (can be overridden per filter) filterDisplayMode?: "label" | "calculated"; // Global filter display mode (can be overridden per filter)
columnVisibility?: Record<string, boolean>; columnVisibility?: Record<string, boolean>;
enableColumnVisibility?: boolean; enableColumnVisibility?: boolean;
onSearch: (input: string) => void; onSearch?: (input: string) => void;
searchQuery?: string; searchQuery?: string;
onPaginationChange: DataTablePaginationUpdateFn; onPaginationChange: DataTablePaginationUpdateFn;
stickyLeftColumn?: string; // Column ID or accessorKey for left sticky column stickyLeftColumn?: string; // Column ID or accessorKey for left sticky column
@@ -86,7 +87,7 @@ type ManualDataTableProps<TData, TValue> = {
pagination: PaginationState; pagination: PaginationState;
}; };
export function ManualDataTable<TData, TValue>({ export function ControlledDataTable<TData, TValue>({
columns, columns,
rows, rows,
addButtonText, addButtonText,
@@ -105,8 +106,9 @@ export function ManualDataTable<TData, TValue>({
searchQuery, searchQuery,
onPaginationChange, onPaginationChange,
stickyRightColumn, stickyRightColumn,
rowCount rowCount,
}: ManualDataTableProps<TData, TValue>) { isNavigatingToAddPage
}: ControlledDataTableProps<TData, TValue>) {
const t = useTranslations(); const t = useTranslations();
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]); const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
@@ -217,17 +219,20 @@ export function ManualDataTable<TData, TValue>({
<Card> <Card>
<CardHeader className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 pb-4"> <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="flex flex-row space-y-3 w-full sm:mr-2 gap-2">
<div className="relative w-full sm:max-w-sm"> {onSearch && (
<Input <div className="relative w-full sm:max-w-sm">
placeholder={searchPlaceholder} <Input
defaultValue={searchQuery} placeholder={searchPlaceholder}
onChange={(e) => defaultValue={searchQuery}
onSearch(e.currentTarget.value) onChange={(e) =>
} onSearch(e.currentTarget.value)
className="w-full pl-8" }
/> 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> <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 && ( {filters && filters.length > 0 && (
<div className="flex gap-2"> <div className="flex gap-2">
{filters.map((filter) => { {filters.map((filter) => {
@@ -326,7 +331,10 @@ export function ManualDataTable<TData, TValue>({
)} )}
{onAdd && addButtonText && ( {onAdd && addButtonText && (
<div> <div>
<Button onClick={onAdd}> <Button
onClick={onAdd}
loading={isNavigatingToAddPage}
>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
{addButtonText} {addButtonText}
</Button> </Button>

View File

@@ -0,0 +1,36 @@
import { useSearchParams, usePathname, useRouter } from "next/navigation";
import { useTransition } from "react";
export function useNavigationContext() {
const router = useRouter();
const searchParams = useSearchParams();
const path = usePathname();
const [isNavigating, startTransition] = useTransition();
function navigate({
searchParams: params,
pathname = path,
replace = false
}: {
pathname?: string;
searchParams?: URLSearchParams;
replace?: boolean;
}) {
startTransition(() => {
const fullPath = pathname + (params ? `?${params.toString()}` : "");
if (replace) {
router.replace(fullPath);
} else {
router.push(fullPath);
}
});
}
return {
pathname: path,
searchParams: new URLSearchParams(searchParams), // we want the search params to be writeable
navigate,
isNavigating
};
}