mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-20 15:55:31 +00:00
✨ add and toggle site labels
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user