mirror of
https://github.com/tale/headplane.git
synced 2026-08-28 16:07:07 +00:00
feat: add machine filtering functionality with dropdowns for user, tag, status, and route
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
import { ChevronDown, X } from "lucide-react";
|
||||
import type { JSX } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
|
||||
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "~/components/menu";
|
||||
import type { User } from "~/types/User";
|
||||
import cn from "~/utils/cn";
|
||||
import type { PopulatedNode } from "~/utils/node-info";
|
||||
import { getUserDisplayName } from "~/utils/user";
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: "online", label: "Online" },
|
||||
{ value: "offline", label: "Offline" },
|
||||
{ value: "expired", label: "Expired" },
|
||||
] as const;
|
||||
|
||||
const ROUTE_OPTIONS = [
|
||||
{ value: "exit-node", label: "Exit node" },
|
||||
{ value: "subnet", label: "Subnet router" },
|
||||
] as const;
|
||||
|
||||
function FilterDropdown({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | null;
|
||||
options: readonly { value: string; label: string }[];
|
||||
onChange: (value: string | null) => void;
|
||||
}): JSX.Element {
|
||||
const activeOption = value !== null ? options.find((o) => o.value === value) : null;
|
||||
const isActive = activeOption !== null;
|
||||
|
||||
return (
|
||||
<Menu>
|
||||
<MenuTrigger
|
||||
className={cn(
|
||||
"px-3 py-1.5 rounded-full text-sm font-medium",
|
||||
"border transition-colors",
|
||||
"flex items-center gap-1.5",
|
||||
isActive
|
||||
? "border-indigo-300 bg-indigo-50 text-indigo-700 dark:border-indigo-700 dark:bg-indigo-950/50 dark:text-indigo-300"
|
||||
: "border-mist-200 dark:border-mist-700 text-mist-700 dark:text-mist-300 hover:border-mist-300 dark:hover:border-mist-600",
|
||||
)}
|
||||
>
|
||||
{isActive ? activeOption!.label : label}
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</MenuTrigger>
|
||||
<MenuContent>
|
||||
{options.map((option) => (
|
||||
<MenuItem
|
||||
key={option.value}
|
||||
onClick={() => onChange(value === option.value ? null : option.value)}
|
||||
>
|
||||
{option.value === value ? (
|
||||
<span className="font-medium text-indigo-600 dark:text-indigo-400">
|
||||
{option.label}
|
||||
</span>
|
||||
) : (
|
||||
option.label
|
||||
)}
|
||||
</MenuItem>
|
||||
))}
|
||||
{isActive && (
|
||||
<>
|
||||
<MenuSeparator />
|
||||
<MenuItem onClick={() => onChange(null)}>Clear filter</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
interface MachineFiltersProps {
|
||||
users: User[];
|
||||
populatedNodes: PopulatedNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the filter dropdowns (User, Tag, Status, Route) and a "Clear filters"
|
||||
* button as a React Fragment so they slot directly into the parent flex row.
|
||||
* Filter state is stored in URL search params for bookmarkability.
|
||||
*/
|
||||
export function MachineFilters({ users, populatedNodes }: MachineFiltersProps): JSX.Element {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const filterUser = searchParams.get("user");
|
||||
const filterTag = searchParams.get("tag");
|
||||
const filterStatus = searchParams.get("status");
|
||||
const filterRoute = searchParams.get("route");
|
||||
|
||||
const hasActiveFilters =
|
||||
filterUser !== null || filterTag !== null || filterStatus !== null || filterRoute !== null;
|
||||
|
||||
const setParam = (key: string, value: string | null) => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (value === null) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.set(key, value);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams();
|
||||
const q = prev.get("q");
|
||||
if (q) next.set("q", q);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const tagOwnedExists = populatedNodes.some((n) => !n.user);
|
||||
const userOptions = [
|
||||
...(tagOwnedExists ? [{ value: "tag-owned", label: "Tag-owned" }] : []),
|
||||
...users.map((u) => ({ value: u.id, label: getUserDisplayName(u) })),
|
||||
];
|
||||
|
||||
const tagOptions = Array.from(new Set(populatedNodes.flatMap((n) => n.tags)))
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.map((tag) => ({ value: tag, label: tag }));
|
||||
|
||||
return (
|
||||
<>
|
||||
{userOptions.length > 0 && (
|
||||
<FilterDropdown
|
||||
label="User"
|
||||
onChange={(v) => setParam("user", v)}
|
||||
options={userOptions}
|
||||
value={filterUser}
|
||||
/>
|
||||
)}
|
||||
{tagOptions.length > 0 && (
|
||||
<FilterDropdown
|
||||
label="Tag"
|
||||
onChange={(v) => setParam("tag", v)}
|
||||
options={tagOptions}
|
||||
value={filterTag}
|
||||
/>
|
||||
)}
|
||||
<FilterDropdown
|
||||
label="Status"
|
||||
onChange={(v) => setParam("status", v)}
|
||||
options={STATUS_OPTIONS}
|
||||
value={filterStatus}
|
||||
/>
|
||||
<FilterDropdown
|
||||
label="Route"
|
||||
onChange={(v) => setParam("route", v)}
|
||||
options={ROUTE_OPTIONS}
|
||||
value={filterRoute}
|
||||
/>
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium",
|
||||
"border border-mist-200 dark:border-mist-700",
|
||||
"text-mist-600 dark:text-mist-400",
|
||||
"hover:border-mist-300 dark:hover:border-mist-600",
|
||||
)}
|
||||
onClick={clearFilters}
|
||||
type="button"
|
||||
>
|
||||
Clear filters
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user