mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-08 13:38:33 +02:00
✨ label filter column on the clients table
This commit is contained in:
@@ -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 = [
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
|||||||
Reference in New Issue
Block a user