mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-07 03:06:40 +00:00
✨ search, filter & paginate sites table
This commit is contained in:
@@ -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.",
|
||||||
|
|||||||
@@ -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() })
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
126
src/components/ColumnFilterButton.tsx
Normal file
126
src/components/ColumnFilterButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
Reference in New Issue
Block a user