label filter column on the clients table

This commit is contained in:
Fred KISSIE
2026-05-26 23:46:56 +02:00
parent 36fbd8818c
commit facbb8f0a4
3 changed files with 162 additions and 112 deletions

View File

@@ -118,7 +118,27 @@ const listClientsSchema = z.object({
description:
"Filter by client status. Can be a comma-separated list of values. Defaults to 'active'."
})
)
),
labels: z
.preprocess((val) => {
if (val === undefined || val === null || val === "") {
return undefined;
}
if (Array.isArray(val)) {
return val;
}
// the array is returned as this
if (typeof val === "string") {
return val.split(",");
}
return undefined;
}, z.array(z.string()))
.optional()
.catch([])
.openapi({
type: "array",
description: "Filter by client labels"
})
});
function queryClientsBase() {
@@ -210,8 +230,16 @@ export async function listClients(
)
);
}
const { page, pageSize, online, query, status, sort_by, order } =
parsedQuery.data;
const {
page,
pageSize,
online,
query,
status,
sort_by,
order,
labels: labelFilter
} = parsedQuery.data;
const parsedParams = listClientsParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
@@ -298,6 +326,22 @@ export async function listClients(
conditions.push(or(...filterAggregates));
}
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) {
conditions.push(
inArray(
clients.clientId,
db
.select({ id: clientLabels.clientId })
.from(clientLabels)
.innerJoin(
labels,
eq(labels.labelId, clientLabels.labelId)
)
.where(inArray(labels.name, labelFilter))
)
);
}
if (query) {
const q = "%" + query.toLowerCase() + "%";
const queryList = [

View File

@@ -94,91 +94,94 @@ export function LabelColumnFilterButton({
}
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",
selectedValues.length === 0 && "text-muted-foreground",
className
)}
>
<div className="flex items-center gap-2 min-w-0">
<span className="shrink-0">{label}</span>
<Funnel className="size-4 flex-none shrink-0" />
{summary && (
<Badge
className={cn(
"truncate max-w-40",
selectedValues.length === 1 &&
"pl-1.5 pr-2 h-auto"
)}
variant="secondary"
>
{summary}
</Badge>
<div className="flex items-center justify-end">
<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",
selectedValues.length === 0 &&
"text-muted-foreground",
className
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className={dataTableFilterPopoverContentClassName}
align="start"
>
<Command shouldFilter={false}>
<CommandInput
placeholder={t("labelSearch")}
value={labelSearchQuery}
onValueChange={setlabelsSearchQuery}
/>
<CommandList>
<CommandEmpty>{t("labelsNotFound")}</CommandEmpty>
<CommandGroup>
{selectedValues.length > 0 && (
<CommandItem
onSelect={() => {
onSelectedValuesChange([]);
setOpen(false);
}}
className="text-muted-foreground"
>
<div className="flex items-center gap-2 min-w-0">
<span className="shrink-0">{label}</span>
<Funnel className="size-4 flex-none shrink-0" />
{summary && (
<Badge
className={cn(
"truncate max-w-40",
selectedValues.length === 1 &&
"pl-1.5 pr-2 h-auto"
)}
variant="secondary"
>
{t("accessLabelFilterClear")}
</CommandItem>
{summary}
</Badge>
)}
{labels.map((label) => (
<CommandItem
key={label.name}
value={label.name}
onSelect={() => {
toggle(label.name);
}}
className="flex items-center gap-2"
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
selectedSet.has(label.name)
? "opacity-100"
: "opacity-0"
)}
/>
<div
className="size-4 rounded-full bg-(--color) flex-none"
style={{
// @ts-expect-error css color
"--color": label.color
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className={dataTableFilterPopoverContentClassName}
align="start"
>
<Command shouldFilter={false}>
<CommandInput
placeholder={t("labelSearch")}
value={labelSearchQuery}
onValueChange={setlabelsSearchQuery}
/>
<CommandList>
<CommandEmpty>{t("labelsNotFound")}</CommandEmpty>
<CommandGroup>
{selectedValues.length > 0 && (
<CommandItem
onSelect={() => {
onSelectedValuesChange([]);
setOpen(false);
}}
/>
{label.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
className="text-muted-foreground"
>
{t("accessLabelFilterClear")}
</CommandItem>
)}
{labels.map((label) => (
<CommandItem
key={label.name}
value={label.name}
onSelect={() => {
toggle(label.name);
}}
className="flex items-center gap-2"
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
selectedSet.has(label.name)
? "opacity-100"
: "opacity-0"
)}
/>
<div
className="size-4 rounded-full bg-(--color) flex-none"
style={{
// @ts-expect-error css color
"--color": label.color
}}
/>
{label.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
);
}

View File

@@ -2,7 +2,7 @@
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { Button } from "@app/components/ui/button";
import { DataTable, ExtendedColumnDef } from "@app/components/ui/data-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import {
DropdownMenu,
DropdownMenuContent,
@@ -10,19 +10,21 @@ import {
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { cn } from "@app/lib/cn";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import type { PaginationState } from "@tanstack/react-table";
import {
ArrowRight,
ArrowUpDown,
MoreHorizontal,
CircleSlash,
ArrowDown01Icon,
ArrowRight,
ArrowUp10Icon,
ChevronsUpDownIcon,
CircleSlash,
MoreHorizontal,
PlusIcon
} from "lucide-react";
import { useTranslations } from "next-intl";
@@ -35,21 +37,15 @@ import {
useState,
useTransition
} from "react";
import { LabelBadge } from "./label-badge";
import { LabelsSelector, type SelectedLabel } from "./labels-selector";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "./ui/popover";
import { Badge } from "./ui/badge";
import type { PaginationState } from "@tanstack/react-table";
import { ControlledDataTable } from "./ui/controlled-data-table";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { useDebouncedCallback } from "use-debounce";
import z from "zod";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { ColumnFilterButton } from "./ColumnFilterButton";
import { LabelBadge } from "./label-badge";
import { LabelsSelector, type SelectedLabel } from "./labels-selector";
import { Badge } from "./ui/badge";
import { ControlledDataTable } from "./ui/controlled-data-table";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
export type ClientRow = {
id: number;
@@ -415,9 +411,15 @@ export default function MachineClientsTable({
id: "labels",
accessorKey: "labels",
header: () => (
<span className="p-3 text-end w-full inline-block">
{t("labels")}
</span>
<LabelColumnFilterButton
orgId={orgId}
selectedValues={searchParams.getAll("labels")}
onSelectedValuesChange={(value) =>
handleFilterChange("labels", value)
}
label={t("labels")}
className="p-3"
/>
),
cell: ({ row }: { row: { original: ClientRow } }) => (
<MachineClientLabelCell
@@ -510,11 +512,6 @@ export default function MachineClientsTable({
return baseColumns;
}, [hasRowsWithoutUserId, isLabelFeatureEnabled, orgId, t, searchParams]);
const booleanSearchFilterSchema = z
.enum(["true", "false"])
.optional()
.catch(undefined);
function handleFilterChange(
column: string,
value: string | null | undefined | string[]
@@ -641,7 +638,10 @@ type MachineClientLabelCellProps = {
orgId: string;
};
function MachineClientLabelCell({ client, orgId }: MachineClientLabelCellProps) {
function MachineClientLabelCell({
client,
orgId
}: MachineClientLabelCellProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
@@ -650,7 +650,10 @@ function MachineClientLabelCell({ client, orgId }: MachineClientLabelCellProps)
const labels = client.labels ?? [];
const [optimisticLabels, setOptimisticLabels] = useOptimistic(labels);
function toggleClientLabel(label: SelectedLabel, action: "attach" | "detach") {
function toggleClientLabel(
label: SelectedLabel,
action: "attach" | "detach"
) {
startTransition(async () => {
try {
if (action === "attach") {