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", "actionViewLogs": "View Logs",
"noneSelected": "None selected", "noneSelected": "None selected",
"orgNotFound2": "No organizations found.", "orgNotFound2": "No organizations found.",
"searchProgress": "Search...", "searchPlaceholder": "Search...",
"emptySearchOptions": "No options found",
"create": "Create", "create": "Create",
"orgs": "Organizations", "orgs": "Organizations",
"loginError": "An unexpected error occurred. Please try again.", "loginError": "An unexpected error occurred. Please try again.",

View File

@@ -103,7 +103,12 @@ const listSitesSchema = z.object({
.enum(["megabytesIn", "megabytesOut"]) .enum(["megabytesIn", "megabytesOut"])
.optional() .optional()
.catch(undefined), .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() { 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); const parsedParams = listSitesParamsSchema.safeParse(req.params);
if (!parsedParams.success) { if (!parsedParams.success) {
@@ -215,6 +219,9 @@ export async function listSites(
.where(eq(sites.orgId, orgId)); .where(eq(sites.orgId, orgId));
} }
const { pageSize, page, query, sort_by, order, online } =
parsedQuery.data;
const accessibleSiteIds = accessibleSites.map((site) => site.siteId); const accessibleSiteIds = accessibleSites.map((site) => site.siteId);
const baseQuery = querySitesBase(); 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 const countQuery = db
.select({ count: count() }) .select({ count: count() })

View File

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

View File

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