🚧 wip: label selector

This commit is contained in:
Fred KISSIE
2026-05-08 20:06:42 +02:00
parent 39b09b7f3f
commit e61ef2ca2a
5 changed files with 242 additions and 6 deletions

View File

@@ -50,6 +50,9 @@ import {
ControlledDataTable,
type ExtendedColumnDef
} from "./ui/controlled-data-table";
import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { LabelsSelector } from "./labels-selector";
export type SiteRow = {
id: number;
@@ -67,6 +70,11 @@ export type SiteRow = {
exitNodeEndpoint?: string;
remoteExitNodeId?: string;
resourceCount: number;
labels?: Array<{
labelId: number;
name: string;
color: string;
}>;
};
type SitesTableProps = {
@@ -368,7 +376,7 @@ export default function SitesTable({
variant="ghost"
size="sm"
onClick={() => setResourcesDialogSite(siteRow)}
className="flex h-8 items-center gap-2 px-0 font-normal"
className="flex h-8 items-center gap-2 px-2 font-normal"
>
<span className="text-sm tabular-nums">
{siteRow.resourceCount} {t("resources")}
@@ -450,11 +458,41 @@ export default function SitesTable({
);
}
},
// The label feature should be added to the tiers
{
accessorKey: "labels",
header: () => <span className="p-3">{t("labels")}</span>,
cell: ({ row }) => {
return <></>;
const labels = row.original.labels ?? [];
return (
<div className="inline-flex flex-wrap items-center justify-end w-full">
<Popover>
<PopoverTrigger asChild>
<Button
size="icon"
variant="outline"
className="p-1 size-auto rounded-full"
title={t("addLabels")}
>
<span className="sr-only">
{t("addLabels")}
</span>
<PlusIcon className="size-3" />
</Button>
</PopoverTrigger>
<PopoverContent
align="center"
className="p-0 w-full"
>
<LabelsSelector
orgId={orgId}
selectedLabels={[]}
onSelectionChange={() => {}}
/>
</PopoverContent>
</Popover>
</div>
);
}
},
{
@@ -616,11 +654,11 @@ export default function SitesTable({
title={t("siteDelete")}
/>
<SiteLabelsDialog
{/* <SiteLabelsDialog
isOpen={isLabelsDialogOpen}
setIsOpen={setIsLabelsDialogOpen}
site={selectedSite}
/>
/> */}
</>
)}

View File

@@ -0,0 +1,126 @@
import { orgQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "./ui/command";
import { Checkbox } from "./ui/checkbox";
import { useTranslations } from "next-intl";
import { useDebounce } from "use-debounce";
import { type Selectedsite, SiteOnlineStatus } from "./site-selector";
type SelectedLabel = {
name: string;
color: string;
labelId: number;
};
export type LabelsSelectorProps = {
orgId: string;
selectedLabels: SelectedLabel[];
onSelectionChange: (sites: SelectedLabel[]) => void;
};
export function LabelsSelector({
orgId,
selectedLabels,
onSelectionChange
}: LabelsSelectorProps) {
const t = useTranslations();
const [labelSearchQuery, setlabelsSearchQuery] = useState("");
const [debouncedQuery] = useDebounce(labelSearchQuery, 150);
const { data: labels = [] } = useQuery(
orgQueries.labels({
orgId,
query: debouncedQuery,
perPage: 10
})
);
const labelsShown = useMemo(() => {
const base = [...labels];
if (debouncedQuery.trim().length === 0 && selectedLabels.length > 0) {
const selectedNotInBase = selectedLabels.filter(
(sel) => !base.some((s) => s.labelId === sel.labelId)
);
return [...selectedNotInBase, ...base];
}
return base;
}, [debouncedQuery, labels, selectedLabels]);
const selectedIds = useMemo(
() => new Set(selectedLabels.map((s) => s.labelId)),
[selectedLabels]
);
return (
<Command shouldFilter={false}>
<CommandInput
placeholder={t("labelSearch")}
value={labelSearchQuery}
onValueChange={setlabelsSearchQuery}
/>
<CommandList>
<CommandEmpty className="px-3 break-all max-w-full wrap-anywhere text-wrap">
{labelSearchQuery.trim().length > 0 ? (
<>
{t("createNewLabel", {
label: labelSearchQuery.trim()
})}
</>
) : (
t("labelsNotFound")
)}
</CommandEmpty>
<CommandGroup>
{labelsShown.map((label) => (
<CommandItem
key={label.labelId}
value={`${label.labelId}`}
onSelect={() => {
if (selectedIds.has(label.labelId)) {
onSelectionChange(
selectedLabels.filter(
(l) => l.labelId !== label.labelId
)
);
} else {
onSelectionChange([
...selectedLabels,
label
]);
}
}}
>
<Checkbox
className="pointer-events-none shrink-0"
checked={selectedIds.has(label.labelId)}
onCheckedChange={() => {}}
aria-hidden
tabIndex={-1}
/>
<div className="min-w-0 flex-1 flex items-center gap-2">
<span
className="inline-block p-1 rounded-full bg-(--label-color)"
style={{
// @ts-expect-error CSS variable
"--label-color": label.color
}}
/>
<span className="min-w-0 flex-1 truncate">
{label.name}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
);
}

View File

@@ -33,6 +33,7 @@ import { remote } from "./api";
import { durationToMs } from "./durationToMs";
import { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
import { StatusHistoryResponse } from "@server/lib/statusHistory";
import type { ListOrgLabelsResponse } from "@server/routers/labels/types";
export type ProductUpdate = {
link: string | null;
@@ -208,6 +209,33 @@ export const orgQueries = {
}
}),
labels: ({
orgId,
query,
perPage = 10_000
}: {
orgId: string;
query?: string;
perPage?: number;
}) =>
queryOptions({
queryKey: ["ORG", orgId, "LABELS", { query, perPage }] as const,
queryFn: async ({ signal, meta }) => {
const sp = new URLSearchParams({
pageSize: perPage.toString()
});
if (query?.trim()) {
sp.set("query", query);
}
const res = await meta!.api.get<
AxiosResponse<ListOrgLabelsResponse>
>(`/org/${orgId}/labels?${sp.toString()}`, { signal });
return res.data.data.labels;
}
}),
domains: ({ orgId }: { orgId: string }) =>
queryOptions({
queryKey: ["ORG", orgId, "DOMAINS"] as const,