diff --git a/messages/en-US.json b/messages/en-US.json index f2affe11..8fffe02d 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -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.", diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index 9c25897e..1cc54fab 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -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() }) diff --git a/src/components/ColumnFilter.tsx b/src/components/ColumnFilter.tsx index a856984e..3e7b585b 100644 --- a/src/components/ColumnFilter.tsx +++ b/src/components/ColumnFilter.tsx @@ -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({ >
- - {selectedOption - ? selectedOption.label - : placeholder} - + + {selectedOption && ( + + {selectedOption + ? selectedOption.label + : placeholder} + + )}
- + diff --git a/src/components/ColumnFilterButton.tsx b/src/components/ColumnFilterButton.tsx new file mode 100644 index 00000000..7d17066c --- /dev/null +++ b/src/components/ColumnFilterButton.tsx @@ -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 ( + + + + + + + + + {emptyMessage} + + {/* Clear filter option */} + {selectedValue && ( + { + onValueChange(undefined); + setOpen(false); + }} + className="text-muted-foreground" + > + Clear filter + + )} + {options.map((option) => ( + { + onValueChange( + selectedValue === option.value + ? undefined + : option.value + ); + setOpen(false); + }} + > + + {option.label} + + ))} + + + + + + ); +} diff --git a/src/components/OrgSelector.tsx b/src/components/OrgSelector.tsx index b2939a90..e139e43a 100644 --- a/src/components/OrgSelector.tsx +++ b/src/components/OrgSelector.tsx @@ -83,7 +83,7 @@ export function OrgSelector({ diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index f99da889..5076149f 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -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 {t("online")}; + return ( + + 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={{