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
+47 -3
View File
@@ -118,7 +118,27 @@ const listClientsSchema = z.object({
description: description:
"Filter by client status. Can be a comma-separated list of values. Defaults to 'active'." "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() { function queryClientsBase() {
@@ -210,8 +230,16 @@ export async function listClients(
) )
); );
} }
const { page, pageSize, online, query, status, sort_by, order } = const {
parsedQuery.data; page,
pageSize,
online,
query,
status,
sort_by,
order,
labels: labelFilter
} = parsedQuery.data;
const parsedParams = listClientsParamsSchema.safeParse(req.params); const parsedParams = listClientsParamsSchema.safeParse(req.params);
if (!parsedParams.success) { if (!parsedParams.success) {
@@ -298,6 +326,22 @@ export async function listClients(
conditions.push(or(...filterAggregates)); 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) { if (query) {
const q = "%" + query.toLowerCase() + "%"; const q = "%" + query.toLowerCase() + "%";
const queryList = [ const queryList = [
+85 -82
View File
@@ -94,91 +94,94 @@ export function LabelColumnFilterButton({
} }
return ( return (
<Popover open={open} onOpenChange={setOpen}> <div className="flex items-center justify-end">
<PopoverTrigger asChild> <Popover open={open} onOpenChange={setOpen}>
<Button <PopoverTrigger asChild>
variant="ghost" <Button
role="combobox" variant="ghost"
aria-expanded={open} role="combobox"
className={cn( aria-expanded={open}
"justify-between text-sm h-8 px-2", className={cn(
selectedValues.length === 0 && "text-muted-foreground", "justify-between text-sm h-8 px-2",
className 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> >
</Button> <div className="flex items-center gap-2 min-w-0">
</PopoverTrigger> <span className="shrink-0">{label}</span>
<PopoverContent <Funnel className="size-4 flex-none shrink-0" />
className={dataTableFilterPopoverContentClassName} {summary && (
align="start" <Badge
> className={cn(
<Command shouldFilter={false}> "truncate max-w-40",
<CommandInput selectedValues.length === 1 &&
placeholder={t("labelSearch")} "pl-1.5 pr-2 h-auto"
value={labelSearchQuery} )}
onValueChange={setlabelsSearchQuery} variant="secondary"
/>
<CommandList>
<CommandEmpty>{t("labelsNotFound")}</CommandEmpty>
<CommandGroup>
{selectedValues.length > 0 && (
<CommandItem
onSelect={() => {
onSelectedValuesChange([]);
setOpen(false);
}}
className="text-muted-foreground"
> >
{t("accessLabelFilterClear")} {summary}
</CommandItem> </Badge>
)} )}
{labels.map((label) => ( </div>
<CommandItem </Button>
key={label.name} </PopoverTrigger>
value={label.name} <PopoverContent
onSelect={() => { className={dataTableFilterPopoverContentClassName}
toggle(label.name); align="start"
}} >
className="flex items-center gap-2" <Command shouldFilter={false}>
> <CommandInput
<CheckIcon placeholder={t("labelSearch")}
className={cn( value={labelSearchQuery}
"mr-2 h-4 w-4", onValueChange={setlabelsSearchQuery}
selectedSet.has(label.name) />
? "opacity-100" <CommandList>
: "opacity-0" <CommandEmpty>{t("labelsNotFound")}</CommandEmpty>
)} <CommandGroup>
/> {selectedValues.length > 0 && (
<div <CommandItem
className="size-4 rounded-full bg-(--color) flex-none" onSelect={() => {
style={{ onSelectedValuesChange([]);
// @ts-expect-error css color setOpen(false);
"--color": label.color
}} }}
/> className="text-muted-foreground"
{label.name} >
</CommandItem> {t("accessLabelFilterClear")}
))} </CommandItem>
</CommandGroup> )}
</CommandList> {labels.map((label) => (
</Command> <CommandItem
</PopoverContent> key={label.name}
</Popover> 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>
); );
} }
+30 -27
View File
@@ -2,7 +2,7 @@
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { Button } from "@app/components/ui/button"; 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 { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -10,19 +10,21 @@ import {
DropdownMenuTrigger DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu"; } from "@app/components/ui/dropdown-menu";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import type { PaginationState } from "@tanstack/react-table";
import { import {
ArrowRight,
ArrowUpDown,
MoreHorizontal,
CircleSlash,
ArrowDown01Icon, ArrowDown01Icon,
ArrowRight,
ArrowUp10Icon, ArrowUp10Icon,
ChevronsUpDownIcon, ChevronsUpDownIcon,
CircleSlash,
MoreHorizontal,
PlusIcon PlusIcon
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@@ -35,21 +37,15 @@ import {
useState, useState,
useTransition useTransition
} from "react"; } 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 { useDebouncedCallback } from "use-debounce";
import z from "zod"; import z from "zod";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { ColumnFilterButton } from "./ColumnFilterButton"; 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 = { export type ClientRow = {
id: number; id: number;
@@ -415,9 +411,15 @@ export default function MachineClientsTable({
id: "labels", id: "labels",
accessorKey: "labels", accessorKey: "labels",
header: () => ( header: () => (
<span className="p-3 text-end w-full inline-block"> <LabelColumnFilterButton
{t("labels")} orgId={orgId}
</span> selectedValues={searchParams.getAll("labels")}
onSelectedValuesChange={(value) =>
handleFilterChange("labels", value)
}
label={t("labels")}
className="p-3"
/>
), ),
cell: ({ row }: { row: { original: ClientRow } }) => ( cell: ({ row }: { row: { original: ClientRow } }) => (
<MachineClientLabelCell <MachineClientLabelCell
@@ -510,11 +512,6 @@ export default function MachineClientsTable({
return baseColumns; return baseColumns;
}, [hasRowsWithoutUserId, isLabelFeatureEnabled, orgId, t, searchParams]); }, [hasRowsWithoutUserId, isLabelFeatureEnabled, orgId, t, searchParams]);
const booleanSearchFilterSchema = z
.enum(["true", "false"])
.optional()
.catch(undefined);
function handleFilterChange( function handleFilterChange(
column: string, column: string,
value: string | null | undefined | string[] value: string | null | undefined | string[]
@@ -641,7 +638,10 @@ type MachineClientLabelCellProps = {
orgId: string; orgId: string;
}; };
function MachineClientLabelCell({ client, orgId }: MachineClientLabelCellProps) { function MachineClientLabelCell({
client,
orgId
}: MachineClientLabelCellProps) {
const t = useTranslations(); const t = useTranslations();
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [isPopoverOpen, setIsPopoverOpen] = useState(false);
@@ -650,7 +650,10 @@ function MachineClientLabelCell({ client, orgId }: MachineClientLabelCellProps)
const labels = client.labels ?? []; const labels = client.labels ?? [];
const [optimisticLabels, setOptimisticLabels] = useOptimistic(labels); const [optimisticLabels, setOptimisticLabels] = useOptimistic(labels);
function toggleClientLabel(label: SelectedLabel, action: "attach" | "detach") { function toggleClientLabel(
label: SelectedLabel,
action: "attach" | "detach"
) {
startTransition(async () => { startTransition(async () => {
try { try {
if (action === "attach") { if (action === "attach") {