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

@@ -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
};
}