search, filter & paginate sites table

This commit is contained in:
Fred KISSIE
2026-01-31 03:02:39 +01:00
parent 066305b095
commit cda6b67bef
6 changed files with 223 additions and 37 deletions

View File

@@ -1164,7 +1164,8 @@
"actionViewLogs": "View Logs",
"noneSelected": "None selected",
"orgNotFound2": "No organizations found.",
"searchProgress": "Search...",
"searchPlaceholder": "Search...",
"emptySearchOptions": "No options found",
"create": "Create",
"orgs": "Organizations",
"loginError": "An unexpected error occurred. Please try again.",

View File

@@ -103,7 +103,12 @@ const listSitesSchema = z.object({
.enum(["megabytesIn", "megabytesOut"])
.optional()
.catch(undefined),
order: z.enum(["asc", "desc"]).optional().default("asc").catch("asc")
order: z.enum(["asc", "desc"]).optional().default("asc").catch("asc"),
online: z
.enum(["true", "false"])
.transform((v) => v === "true")
.optional()
.catch(undefined)
});
function querySitesBase() {
@@ -172,7 +177,6 @@ export async function listSites(
)
);
}
const { pageSize, page, query, sort_by, order } = parsedQuery.data;
const parsedParams = listSitesParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
@@ -215,6 +219,9 @@ export async function listSites(
.where(eq(sites.orgId, orgId));
}
const { pageSize, page, query, sort_by, order, online } =
parsedQuery.data;
const accessibleSiteIds = accessibleSites.map((site) => site.siteId);
const baseQuery = querySitesBase();
@@ -231,6 +238,9 @@ export async function listSites(
)
);
}
if (typeof online !== "undefined") {
conditions = and(conditions, eq(sites.online, online));
}
const countQuery = db
.select({ count: count() })

View File

@@ -15,6 +15,7 @@ import {
} from "@app/components/ui/command";
import { CheckIcon, ChevronDownIcon, Filter } from "lucide-react";
import { cn } from "@app/lib/cn";
import { Badge } from "./ui/badge";
interface FilterOption {
value: string;
@@ -61,16 +62,19 @@ export function ColumnFilter({
>
<div className="flex items-center gap-2">
<Filter className="h-4 w-4" />
<span className="truncate">
{selectedOption
? selectedOption.label
: placeholder}
</span>
{selectedOption && (
<Badge className="truncate" variant="secondary">
{selectedOption
? selectedOption.label
: placeholder}
</Badge>
)}
</div>
<ChevronDownIcon className="h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-[200px]" align="start">
<PopoverContent className="p-0 w-50" align="start">
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandList>

View File

@@ -0,0 +1,126 @@
import { useState } from "react";
import { Button } from "@app/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@app/components/ui/command";
import { CheckIcon, ChevronDownIcon, Funnel } from "lucide-react";
import { cn } from "@app/lib/cn";
import { Badge } from "./ui/badge";
interface FilterOption {
value: string;
label: string;
}
interface ColumnFilterButtonProps {
options: FilterOption[];
selectedValue?: string;
onValueChange: (value: string | undefined) => void;
placeholder?: string;
searchPlaceholder?: string;
emptyMessage?: string;
className?: string;
label: string;
}
export function ColumnFilterButton({
options,
selectedValue,
onValueChange,
placeholder,
searchPlaceholder = "Search...",
emptyMessage = "No options found",
className,
label
}: ColumnFilterButtonProps) {
const [open, setOpen] = useState(false);
const selectedOption = options.find(
(option) => option.value === selectedValue
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
role="combobox"
aria-expanded={open}
className={cn(
"justify-between text-sm h-8 px-2",
!selectedValue && "text-muted-foreground",
className
)}
>
<div className="flex items-center gap-2">
{label}
<Funnel className="size-4 flex-none" />
{selectedOption && (
<Badge className="truncate" variant="secondary">
{selectedOption.label}
</Badge>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-50" align="start">
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandList>
<CommandEmpty>{emptyMessage}</CommandEmpty>
<CommandGroup>
{/* Clear filter option */}
{selectedValue && (
<CommandItem
onSelect={() => {
onValueChange(undefined);
setOpen(false);
}}
className="text-muted-foreground"
>
Clear filter
</CommandItem>
)}
{options.map((option) => (
<CommandItem
key={option.value}
value={option.label}
onSelect={() => {
onValueChange(
selectedValue === option.value
? undefined
: option.value
);
setOpen(false);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
selectedValue === option.value
? "opacity-100"
: "opacity-0"
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -83,7 +83,7 @@ export function OrgSelector({
<PopoverContent className="w-[320px] p-0" align="start">
<Command className="rounded-lg">
<CommandInput
placeholder={t("searchProgress")}
placeholder={t("searchPlaceholder")}
className="border-0 focus:ring-0"
/>
<CommandEmpty className="py-6 text-center">

View File

@@ -24,6 +24,7 @@ import {
ArrowUp10Icon,
ArrowUpRight,
ChevronsUpDownIcon,
Funnel,
MoreHorizontal
} from "lucide-react";
import { useTranslations } from "next-intl";
@@ -35,6 +36,9 @@ import {
ManualDataTable,
type ExtendedColumnDef
} from "./ui/manual-data-table";
import { ColumnFilter } from "./ColumnFilter";
import { ColumnFilterButton } from "./ColumnFilterButton";
import z from "zod";
export type SiteRow = {
id: number;
@@ -79,33 +83,57 @@ export default function SitesTable({
const api = createApiClient(useEnvContext());
const t = useTranslations();
const refreshData = async () => {
try {
router.refresh();
} catch (error) {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive"
});
}
};
const booleanSearchFilterSchema = z
.enum(["true", "false"])
.optional()
.catch(undefined);
const deleteSite = (siteId: number) => {
api.delete(`/site/${siteId}`)
.catch((e) => {
console.error(t("siteErrorDelete"), e);
toast({
variant: "destructive",
title: t("siteErrorDelete"),
description: formatAxiosError(e, t("siteErrorDelete"))
});
})
.then(() => {
function handleFilterChange(
column: string,
value: string | undefined | null
) {
const sp = new URLSearchParams(searchParams);
sp.delete(column);
sp.delete("page");
if (value) {
sp.set(column, value);
}
startTransition(() => router.push(`${pathname}?${sp.toString()}`));
}
function refreshData() {
startTransition(async () => {
try {
router.refresh();
setIsDeleteModalOpen(false);
});
};
} catch (error) {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive"
});
}
});
}
function deleteSite(siteId: number) {
startTransition(async () => {
await api
.delete(`/site/${siteId}`)
.catch((e) => {
console.error(t("siteErrorDelete"), e);
toast({
variant: "destructive",
title: t("siteErrorDelete"),
description: formatAxiosError(e, t("siteErrorDelete"))
});
})
.then(() => {
router.refresh();
setIsDeleteModalOpen(false);
});
});
}
const dataInOrder = getSortDirection("megabytesIn");
const dataOutOrder = getSortDirection("megabytesOut");
@@ -134,7 +162,24 @@ export default function SitesTable({
accessorKey: "online",
friendlyName: t("online"),
header: () => {
return <span className="p-3">{t("online")}</span>;
return (
<ColumnFilterButton
options={[
{ value: "true", label: t("online") },
{ value: "false", label: t("offline") }
]}
selectedValue={booleanSearchFilterSchema.parse(
searchParams.get("online")
)}
onValueChange={(value) =>
handleFilterChange("online", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("online")}
className="p-3"
/>
);
},
cell: ({ row }) => {
const originalRow = row.original;
@@ -426,7 +471,7 @@ export default function SitesTable({
searchQuery={searchParams.get("query")?.toString()}
onSearch={handleSearchChange}
addButtonText={t("siteAdd")}
onRefresh={() => startTransition(refreshData)}
onRefresh={refreshData}
isRefreshing={isRefreshing}
rowCount={rowCount}
columnVisibility={{