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 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<string>() // 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<string>() // 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<number, ResourceWithTargets>();
@@ -324,8 +352,8 @@ export async function listResources(
resources: resourcesList,
pagination: {
total: totalCount,
limit,
offset
pageSize,
page
}
},
success: true,

View File

@@ -16,7 +16,7 @@ import { cache } from "react";
export interface ProxyResourcesPageProps {
params: Promise<{ orgId: string }>;
searchParams: Promise<{ view?: string }>;
searchParams: Promise<Record<string, string>>;
}
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<AxiosResponse<ListResourcesResponse>>(
`/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(
<ProxyResourcesTable
resources={resourceRows}
orgId={params.orgId}
defaultSort={{
id: "name",
desc: false
rowCount={pagination.total}
pagination={{
pageIndex: pagination.page - 1,
pageSize: pagination.pageSize
}}
/>
</OrgProvider>

View File

@@ -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<ResourceRow | null>();
const [isRefreshing, startTransition] = useTransition();
const [isNavigatingToAddPage, startNavigation] = useTransition();
const refreshData = () => {
startTransition(() => {
@@ -236,7 +247,7 @@ export default function ProxyResourcesTable({
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[280px]">
<DropdownMenuContent align="start" className="min-w-70">
{monitoredTargets.length > 0 && (
<>
{monitoredTargets.map((target) => (
@@ -456,7 +467,24 @@ export default function ProxyResourcesTable({
{
accessorKey: "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 }) => (
<Switch
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 (
<>
{selectedResource && (
@@ -547,21 +611,27 @@ export default function ProxyResourcesTable({
/>
)}
<DataTable
<ControlledDataTable
columns={proxyColumns}
data={resources}
persistPageSize="proxy-resources"
rows={resources}
tableId="proxy-resources"
searchPlaceholder={t("resourcesSearch")}
searchColumn="name"
pagination={pagination}
rowCount={rowCount}
onSearch={handleSearchChange}
onPaginationChange={handlePaginationChange}
onAdd={() =>
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"

View File

@@ -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<SiteRow | null>(null);
const [isRefreshing, startTransition] = useTransition();
const [isNavigatingToAddPage, startNavigation] = useTransition();
const [getSortDirection, toggleSorting] = useSortColumn();
@@ -460,14 +461,19 @@ export default function SitesTable({
/>
)}
<ManualDataTable
<ControlledDataTable
columns={columns}
rows={sites}
tableId="sites-table"
searchPlaceholder={t("searchSitesProgress")}
pagination={pagination}
onPaginationChange={handlePaginationChange}
onAdd={() => 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")}

View File

@@ -64,7 +64,7 @@ type DataTableFilter = {
export type DataTablePaginationUpdateFn = (newPage: PaginationState) => void;
type ManualDataTableProps<TData, TValue> = {
type ControlledDataTableProps<TData, TValue> = {
columns: ExtendedColumnDef<TData, TValue>[];
rows: TData[];
tableId: string;
@@ -72,12 +72,13 @@ type ManualDataTableProps<TData, TValue> = {
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<string, boolean>;
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<TData, TValue> = {
pagination: PaginationState;
};
export function ManualDataTable<TData, TValue>({
export function ControlledDataTable<TData, TValue>({
columns,
rows,
addButtonText,
@@ -105,8 +106,9 @@ export function ManualDataTable<TData, TValue>({
searchQuery,
onPaginationChange,
stickyRightColumn,
rowCount
}: ManualDataTableProps<TData, TValue>) {
rowCount,
isNavigatingToAddPage
}: ControlledDataTableProps<TData, TValue>) {
const t = useTranslations();
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
@@ -217,17 +219,20 @@ export function ManualDataTable<TData, TValue>({
<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>
{onSearch && (
<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) => {
@@ -326,7 +331,10 @@ export function ManualDataTable<TData, TValue>({
)}
{onAdd && addButtonText && (
<div>
<Button onClick={onAdd}>
<Button
onClick={onAdd}
loading={isNavigatingToAddPage}
>
<Plus className="mr-2 h-4 w-4" />
{addButtonText}
</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
};
}