add and toggle site labels

This commit is contained in:
Fred KISSIE
2026-05-08 22:31:36 +02:00
parent a63c1ec364
commit 2fd519e102
5 changed files with 156 additions and 104 deletions

View File

@@ -765,7 +765,7 @@ authenticated.put(
labels.attachLabelToItem labels.attachLabelToItem
); );
authenticated.delete( authenticated.put(
"/org/:orgId/label/:labelId/detach", "/org/:orgId/label/:labelId/detach",
verifyValidLicense, verifyValidLicense,
verifyOrgAccess, verifyOrgAccess,

View File

@@ -392,7 +392,7 @@ export async function listSites(
.select({ .select({
labelId: labels.labelId, labelId: labels.labelId,
name: labels.name, name: labels.name,
color: labels.name, color: labels.color,
siteId: siteLabels.siteId siteId: siteLabels.siteId
}) })
.from(labels) .from(labels)

View File

@@ -60,6 +60,7 @@ export default async function SitesPage(props: SitesPageProps) {
return { return {
name: site.name, name: site.name,
id: site.siteId, id: site.siteId,
labels: site.labels,
nice: site.niceId.toString(), nice: site.niceId.toString(),
address: site.address?.split("/")[0], address: site.address?.split("/")[0],
mbIn: formatSize(site.megabytesIn || 0, site.type), mbIn: formatSize(site.megabytesIn || 0, site.type),

View File

@@ -3,6 +3,16 @@
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import UptimeMiniBar from "@app/components/UptimeMiniBar"; import UptimeMiniBar from "@app/components/UptimeMiniBar";
import {
Credenza,
CredenzaBody,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import SiteResourcesOverview from "@app/components/SiteResourcesOverview";
import { Badge } from "@app/components/ui/badge"; import { Badge } from "@app/components/ui/badge";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { import {
@@ -14,9 +24,9 @@ import {
import { InfoPopup } from "@app/components/ui/info-popup"; import { InfoPopup } from "@app/components/ui/info-popup";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
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 { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { build } from "@server/build"; import { build } from "@server/build";
import { type PaginationState } from "@tanstack/react-table"; import { type PaginationState } from "@tanstack/react-table";
import { import {
@@ -27,32 +37,30 @@ import {
ChevronDown, ChevronDown,
ChevronsUpDownIcon, ChevronsUpDownIcon,
MoreHorizontal, MoreHorizontal,
PlusIcon PlusIcon,
XIcon
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Link from "next/link"; import Link from "next/link";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useState, useTransition, useEffect } from "react"; import {
startTransition,
useEffect,
useOptimistic,
useState,
useTransition
} from "react";
import { useDebouncedCallback } from "use-debounce"; import { useDebouncedCallback } from "use-debounce";
import z from "zod"; import z from "zod";
import { ColumnFilterButton } from "./ColumnFilterButton"; import { ColumnFilterButton } from "./ColumnFilterButton";
import SiteResourcesOverview from "@app/components/SiteResourcesOverview";
import {
Credenza,
CredenzaBody,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { import {
ControlledDataTable, ControlledDataTable,
type ExtendedColumnDef type ExtendedColumnDef
} from "./ui/controlled-data-table"; } from "./ui/controlled-data-table";
import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip";
import { LabelsSelector, type SelectedLabel } from "./labels-selector";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { LabelsSelector } from "./labels-selector"; import { cn } from "@app/lib/cn";
export type SiteRow = { export type SiteRow = {
id: number; id: number;
@@ -463,36 +471,7 @@ export default function SitesTable({
accessorKey: "labels", accessorKey: "labels",
header: () => <span className="p-3">{t("labels")}</span>, header: () => <span className="p-3">{t("labels")}</span>,
cell: ({ row }) => { cell: ({ row }) => {
const labels = row.original.labels ?? []; return <SiteLabelCell site={row.original} orgId={orgId} />;
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>
);
} }
}, },
{ {
@@ -653,12 +632,6 @@ export default function SitesTable({
string={selectedSite.name} string={selectedSite.name}
title={t("siteDelete")} title={t("siteDelete")}
/> />
{/* <SiteLabelsDialog
isOpen={isLabelsDialogOpen}
setIsOpen={setIsLabelsDialogOpen}
site={selectedSite}
/> */}
</> </>
)} )}
@@ -696,36 +669,104 @@ export default function SitesTable({
); );
} }
type SiteLabelsDialogProps = { type SiteLabelCellProps = {
site: SiteRow; site: SiteRow;
isOpen: boolean; orgId: string;
setIsOpen: (open: boolean) => void;
}; };
function SiteLabelsDialog({ site, isOpen, setIsOpen }: SiteLabelsDialogProps) { function SiteLabelCell({ site, orgId }: SiteLabelCellProps) {
const t = useTranslations(); const t = useTranslations();
const api = createApiClient(useEnvContext());
const router = useRouter();
const labels = site.labels ?? [];
const [optimisticLabels, setOptimisticLabels] = useOptimistic(labels);
function toggleSiteLabel(
label: SelectedLabel,
action: "attach" | "detach"
) {
startTransition(async () => {
try {
if (action === "attach") {
setOptimisticLabels([...optimisticLabels, label]);
await api.put(
`/org/${orgId}/label/${label.labelId}/attach`,
{ siteId: site.id }
);
} else {
setOptimisticLabels(
optimisticLabels.filter(
(lb) => lb.labelId !== label.labelId
)
);
await api.put(
`/org/${orgId}/label/${label.labelId}/detach`,
{ siteId: site.id }
);
}
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e, t("errorOccurred")),
variant: "destructive"
});
} finally {
router.refresh();
}
});
}
return ( return (
<Credenza open={isOpen} onOpenChange={setIsOpen}> <div className="inline-flex flex-wrap items-center justify-end w-full gap-1">
<CredenzaContent className=""> {optimisticLabels.map((label) => (
<CredenzaHeader> <Button
<CredenzaTitle>{t("siteLabelsTab")}</CredenzaTitle> key={label.labelId}
<CredenzaDescription> variant="outline"
{t("siteLabelsDescription")} onClick={() => toggleSiteLabel(label, "detach")}
</CredenzaDescription> className={cn(
</CredenzaHeader> "inline-flex gap-1 items-center",
<CredenzaBody> "rounded-full text-sm cursor-pointer",
<></> "px-1.5 py-0 h-auto"
</CredenzaBody> )}
<CredenzaFooter> >
<div
className="size-3 rounded-full bg-(--color) flex-none"
style={{
// @ts-expect-error css color
"--color": label.color
}}
/>
<span className="whitespace-nowrap text-ellipsis max-w-20 overflow-hidden relative bottom-0.5">
{label.name}
</span>
<XIcon className="size-3 flex-none" />
</Button>
))}
<Popover>
<PopoverTrigger asChild>
<Button <Button
type="button" size="icon"
variant="outline" variant="outline"
onClick={() => setIsOpen(false)} className="p-1 size-auto rounded-full"
title={t("addLabels")}
> >
{t("close")} <span className="sr-only">{t("addLabels")}</span>
<PlusIcon className="size-3" />
</Button> </Button>
</CredenzaFooter> </PopoverTrigger>
</CredenzaContent> <PopoverContent align="center" className="p-0 w-full">
</Credenza> <LabelsSelector
orgId={orgId}
selectedLabels={optimisticLabels}
toggleLabel={toggleSiteLabel}
/>
</PopoverContent>
</Popover>
</div>
); );
} }

View File

@@ -21,12 +21,13 @@ import {
SelectTrigger, SelectTrigger,
SelectValue SelectValue
} from "./ui/select"; } from "./ui/select";
import { createApiClient } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types"; import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
import type { AxiosResponse } from "axios"; import type { AxiosResponse } from "axios";
import { toast } from "@app/hooks/useToast";
type SelectedLabel = { export type SelectedLabel = {
name: string; name: string;
color: string; color: string;
labelId: number; labelId: number;
@@ -35,8 +36,7 @@ type SelectedLabel = {
export type LabelsSelectorProps = { export type LabelsSelectorProps = {
orgId: string; orgId: string;
selectedLabels: SelectedLabel[]; selectedLabels: SelectedLabel[];
onSelectionChange: (sites: SelectedLabel[]) => void; toggleLabel: (newlabel: SelectedLabel, action: "detach" | "attach") => void;
onCreateLabel: (newlabel: SelectedLabel) => Promise<void>;
}; };
const LABEL_COLORS = { const LABEL_COLORS = {
@@ -52,8 +52,7 @@ const LABEL_COLORS = {
export function LabelsSelector({ export function LabelsSelector({
orgId, orgId,
selectedLabels, selectedLabels,
onSelectionChange, toggleLabel
onCreateLabel
}: LabelsSelectorProps) { }: LabelsSelectorProps) {
const t = useTranslations(); const t = useTranslations();
const [labelSearchQuery, setlabelsSearchQuery] = useState(""); const [labelSearchQuery, setlabelsSearchQuery] = useState("");
@@ -94,17 +93,28 @@ export function LabelsSelector({
async function createLabel(_: any, formData: FormData) { async function createLabel(_: any, formData: FormData) {
const name = formData.get("name")?.toString(); const name = formData.get("name")?.toString();
const color = formData.get("color")?.toString(); const color = formData.get("color")?.toString();
const res = await api.post<AxiosResponse<CreateOrEditLabelResponse>>( try {
`/org/${orgId}/labels`, const res = await api.post<
{ name, color } AxiosResponse<CreateOrEditLabelResponse>
); >(`/org/${orgId}/labels`, { name, color });
const { label } = res.data.data; const { label } = res.data.data;
await onCreateLabel({
labelId: label.labelId, toggleLabel(
name: label.name, {
color: label.color labelId: label.labelId,
}); name: label.name,
color: label.color
},
"attach"
);
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e, t("errorOccurred")),
variant: "destructive"
});
}
setlabelsSearchQuery(""); setlabelsSearchQuery("");
} }
@@ -185,18 +195,18 @@ export function LabelsSelector({
key={label.labelId} key={label.labelId}
value={`${label.labelId}`} value={`${label.labelId}`}
onSelect={() => { onSelect={() => {
if (selectedIds.has(label.labelId)) { toggleLabel(
onSelectionChange( label,
selectedLabels.filter( selectedIds.has(label.labelId)
(l) => l.labelId !== label.labelId ? "detach"
) : "attach"
); );
} else { // } else {
onSelectionChange([ // onSelectionChange([
...selectedLabels, // ...selectedLabels,
label // label
]); // ]);
} // }
}} }}
> >
<Checkbox <Checkbox
@@ -208,7 +218,7 @@ export function LabelsSelector({
/> />
<div className="min-w-0 flex-1 flex items-center gap-2"> <div className="min-w-0 flex-1 flex items-center gap-2">
<span <span
className="inline-block p-1 rounded-full bg-(--label-color)" className="inline-block size-3 flex-none rounded-full bg-(--label-color)"
style={{ style={{
// @ts-expect-error CSS variable // @ts-expect-error CSS variable
"--label-color": label.color "--label-color": label.color