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={{