From bb1a375484a1a4eccd117d97eb25eded67a2ab5a Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 4 Feb 2026 02:20:28 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20paginate,=20search=20&=20filter=20r?= =?UTF-8?q?esources=20by=20enabled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/resource/listResources.ts | 80 +++++++++----- .../[orgId]/settings/resources/proxy/page.tsx | 21 +++- src/components/ProxyResourcesTable.tsx | 104 +++++++++++++++--- src/components/SitesTable.tsx | 14 ++- ...ta-table.tsx => controlled-data-table.tsx} | 42 ++++--- src/hooks/useNavigationContext.ts | 36 ++++++ 6 files changed, 227 insertions(+), 70 deletions(-) rename src/components/ui/{manual-data-table.tsx => controlled-data-table.tsx} (96%) create mode 100644 src/hooks/useNavigationContext.ts diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index c17e65a4..a60d27e6 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -17,7 +17,7 @@ import { import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; 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 { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; @@ -27,19 +27,30 @@ const listResourcesParamsSchema = z.strictObject({ }); const listResourcesSchema = z.object({ - limit: z - .string() + pageSize: z.coerce + .number() // for prettier formatting + .int() + .positive() .optional() - .default("1000") - .transform(Number) - .pipe(z.int().nonnegative()), - - 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), + 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) @@ -95,7 +106,7 @@ export type ResourceWithTargets = { }>; }; -function queryResources(accessibleResourceIds: number[], orgId: string) { +function queryResourcesBase() { return db .select({ resourceId: resources.resourceId, @@ -147,18 +158,12 @@ function queryResources(accessibleResourceIds: number[], orgId: string) { .leftJoin( targetHealthCheck, eq(targetHealthCheck.targetId, targets.targetId) - ) - .where( - and( - inArray(resources.resourceId, accessibleResourceIds), - eq(resources.orgId, orgId) - ) ); } export type ListResourcesResponse = { resources: ResourceWithTargets[]; - pagination: { total: number; limit: number; offset: number }; + pagination: { total: number; pageSize: number; page: number }; }; 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); if (!parsedParams.success) { @@ -252,14 +257,37 @@ export async function listResources( (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 .select({ count: count() }) .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[] const map = new Map(); @@ -324,8 +352,8 @@ export async function listResources( resources: resourcesList, pagination: { total: totalCount, - limit, - offset + pageSize, + page } }, success: true, diff --git a/src/app/[orgId]/settings/resources/proxy/page.tsx b/src/app/[orgId]/settings/resources/proxy/page.tsx index 408a9352..57505c53 100644 --- a/src/app/[orgId]/settings/resources/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/page.tsx @@ -16,7 +16,7 @@ import { cache } from "react"; export interface ProxyResourcesPageProps { params: Promise<{ orgId: string }>; - searchParams: Promise<{ view?: string }>; + searchParams: Promise>; } export default async function ProxyResourcesPage( @@ -24,14 +24,22 @@ export default async function ProxyResourcesPage( ) { const params = await props.params; const t = await getTranslations(); + const searchParams = new URLSearchParams(await props.searchParams); let resources: ListResourcesResponse["resources"] = []; + let pagination: ListResourcesResponse["pagination"] = { + total: 0, + page: 1, + pageSize: 20 + }; try { const res = await internal.get>( - `/org/${params.orgId}/resources`, + `/org/${params.orgId}/resources?${searchParams.toString()}`, await authCookieHeader() ); - resources = res.data.data.resources; + const responseData = res.data.data; + resources = responseData.resources; + pagination = responseData.pagination; } catch (e) {} let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = []; @@ -104,9 +112,10 @@ export default async function ProxyResourcesPage( diff --git a/src/components/ProxyResourcesTable.tsx b/src/components/ProxyResourcesTable.tsx index 69b180c4..20eabc4d 100644 --- a/src/components/ProxyResourcesTable.tsx +++ b/src/components/ProxyResourcesTable.tsx @@ -31,8 +31,14 @@ 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 { 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 = { targetId: number; @@ -117,18 +123,22 @@ function StatusIcon({ type ProxyResourcesTableProps = { resources: ResourceRow[]; orgId: string; - defaultSort?: { - id: string; - desc: boolean; - }; + pagination: PaginationState; + rowCount: number; }; export default function ProxyResourcesTable({ resources, orgId, - defaultSort + pagination, + rowCount }: ProxyResourcesTableProps) { const router = useRouter(); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams + } = useNavigationContext(); const t = useTranslations(); const { env } = useEnvContext(); @@ -140,6 +150,7 @@ export default function ProxyResourcesTable({ useState(); const [isRefreshing, startTransition] = useTransition(); + const [isNavigatingToAddPage, startNavigation] = useTransition(); const refreshData = () => { startTransition(() => { @@ -236,7 +247,7 @@ export default function ProxyResourcesTable({ - + {monitoredTargets.length > 0 && ( <> {monitoredTargets.map((target) => ( @@ -456,7 +467,24 @@ export default function ProxyResourcesTable({ { accessorKey: "enabled", friendlyName: t("enabled"), - header: () => {t("enabled")}, + header: () => ( + + handleFilterChange("enabled", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("enabled")} + className="p-3" + /> + ), cell: ({ row }) => ( { + 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 ( <> {selectedResource && ( @@ -547,21 +611,27 @@ export default function ProxyResourcesTable({ /> )} - - router.push(`/${orgId}/settings/resources/proxy/create`) + startNavigation(() => { + router.push( + `/${orgId}/settings/resources/proxy/create` + ); + }) } addButtonText={t("resourceAdd")} onRefresh={refreshData} - isRefreshing={isRefreshing} - defaultSort={defaultSort} - enableColumnVisibility={true} - persistColumnVisibility="proxy-resources" + isRefreshing={isRefreshing || isFiltering} + isNavigatingToAddPage={isNavigatingToAddPage} + enableColumnVisibility columnVisibility={{ niceId: false }} stickyLeftColumn="name" stickyRightColumn="actions" diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 5076149f..76117776 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -33,9 +33,9 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useState, useTransition } from "react"; import { useDebouncedCallback } from "use-debounce"; import { - ManualDataTable, + ControlledDataTable, type ExtendedColumnDef -} from "./ui/manual-data-table"; +} from "./ui/controlled-data-table"; import { ColumnFilter } from "./ColumnFilter"; import { ColumnFilterButton } from "./ColumnFilterButton"; import z from "zod"; @@ -77,6 +77,7 @@ export default function SitesTable({ const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedSite, setSelectedSite] = useState(null); const [isRefreshing, startTransition] = useTransition(); + const [isNavigatingToAddPage, startNavigation] = useTransition(); const [getSortDirection, toggleSorting] = useSortColumn(); @@ -460,14 +461,19 @@ export default function SitesTable({ /> )} - router.push(`/${orgId}/settings/sites/create`)} + onAdd={() => + startNavigation(() => + router.push(`/${orgId}/settings/sites/create`) + ) + } + isNavigatingToAddPage={isNavigatingToAddPage} searchQuery={searchParams.get("query")?.toString()} onSearch={handleSearchChange} addButtonText={t("siteAdd")} diff --git a/src/components/ui/manual-data-table.tsx b/src/components/ui/controlled-data-table.tsx similarity index 96% rename from src/components/ui/manual-data-table.tsx rename to src/components/ui/controlled-data-table.tsx index 8653d465..c6fb505c 100644 --- a/src/components/ui/manual-data-table.tsx +++ b/src/components/ui/controlled-data-table.tsx @@ -64,7 +64,7 @@ type DataTableFilter = { export type DataTablePaginationUpdateFn = (newPage: PaginationState) => void; -type ManualDataTableProps = { +type ControlledDataTableProps = { columns: ExtendedColumnDef[]; rows: TData[]; tableId: string; @@ -72,12 +72,13 @@ type ManualDataTableProps = { onAdd?: () => void; onRefresh?: () => void; isRefreshing?: boolean; + isNavigatingToAddPage?: boolean; searchPlaceholder?: string; filters?: DataTableFilter[]; filterDisplayMode?: "label" | "calculated"; // Global filter display mode (can be overridden per filter) columnVisibility?: Record; enableColumnVisibility?: boolean; - onSearch: (input: string) => void; + onSearch?: (input: string) => void; searchQuery?: string; onPaginationChange: DataTablePaginationUpdateFn; stickyLeftColumn?: string; // Column ID or accessorKey for left sticky column @@ -86,7 +87,7 @@ type ManualDataTableProps = { pagination: PaginationState; }; -export function ManualDataTable({ +export function ControlledDataTable({ columns, rows, addButtonText, @@ -105,8 +106,9 @@ export function ManualDataTable({ searchQuery, onPaginationChange, stickyRightColumn, - rowCount -}: ManualDataTableProps) { + rowCount, + isNavigatingToAddPage +}: ControlledDataTableProps) { const t = useTranslations(); const [columnFilters, setColumnFilters] = useState([]); @@ -217,17 +219,20 @@ export function ManualDataTable({
-
- - onSearch(e.currentTarget.value) - } - className="w-full pl-8" - /> - -
+ {onSearch && ( +
+ + onSearch(e.currentTarget.value) + } + className="w-full pl-8" + /> + +
+ )} + {filters && filters.length > 0 && (
{filters.map((filter) => { @@ -326,7 +331,10 @@ export function ManualDataTable({ )} {onAdd && addButtonText && (
- diff --git a/src/hooks/useNavigationContext.ts b/src/hooks/useNavigationContext.ts new file mode 100644 index 00000000..71b7c552 --- /dev/null +++ b/src/hooks/useNavigationContext.ts @@ -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 + }; +}