Add labels column to private resources table

This commit is contained in:
Fred KISSIE
2026-05-12 20:25:32 +02:00
parent 9378103ddd
commit 12e777b32e
2 changed files with 425 additions and 269 deletions

View File

@@ -127,7 +127,8 @@ export default async function ClientResourcesPage(
authDaemonPort: siteResource.authDaemonPort ?? null, authDaemonPort: siteResource.authDaemonPort ?? null,
subdomain: siteResource.subdomain ?? null, subdomain: siteResource.subdomain ?? null,
domainId: siteResource.domainId ?? null, domainId: siteResource.domainId ?? null,
fullDomain: siteResource.fullDomain ?? null fullDomain: siteResource.fullDomain ?? null,
labels: siteResource.labels ?? []
}; };
} }
); );

View File

@@ -2,7 +2,6 @@
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import CopyToClipboard from "@app/components/CopyToClipboard"; import CopyToClipboard from "@app/components/CopyToClipboard";
import { DataTable } from "@app/components/ui/data-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { ExtendedColumnDef } from "@app/components/ui/data-table";
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";
@@ -30,13 +29,21 @@ import {
ChevronDown, ChevronDown,
ChevronsUpDownIcon, ChevronsUpDownIcon,
Funnel, Funnel,
MoreHorizontal MoreHorizontal,
PlusIcon
} 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 { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Selectedsite, SitesSelector } from "@app/components/site-selector"; import { Selectedsite, SitesSelector } from "@app/components/site-selector";
import { useEffect, useMemo, useState, useTransition } from "react"; import {
startTransition,
useEffect,
useMemo,
useOptimistic,
useState,
useTransition
} from "react";
import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog"; import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog";
import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog"; import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog";
import type { PaginationState } from "@tanstack/react-table"; import type { PaginationState } from "@tanstack/react-table";
@@ -53,6 +60,10 @@ import {
} from "@app/components/ResourceSitesStatusCell"; } from "@app/components/ResourceSitesStatusCell";
import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator"; import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator";
import { build } from "@server/build"; import { build } from "@server/build";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { LabelBadge } from "./label-badge";
import { LabelsSelector, type SelectedLabel } from "./labels-selector";
export type InternalResourceSiteRow = ResourceSiteRow; export type InternalResourceSiteRow = ResourceSiteRow;
@@ -84,6 +95,11 @@ export type InternalResourceRow = {
subdomain?: string | null; subdomain?: string | null;
domainId?: string | null; domainId?: string | null;
fullDomain?: string | null; fullDomain?: string | null;
labels?: Array<{
labelId: number;
name: string;
color: string;
}>;
}; };
function formatDestinationDisplay(row: InternalResourceRow): string { function formatDestinationDisplay(row: InternalResourceRow): string {
@@ -141,7 +157,10 @@ export default function ClientResourcesTable({
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [siteFilterOpen, setSiteFilterOpen] = useState(false); const [siteFilterOpen, setSiteFilterOpen] = useState(false);
const [isRefreshing, startTransition] = useTransition(); const [isRefreshing, startRefreshTransition] = useTransition();
const { isPaidUser } = usePaidStatus();
const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels);
useEffect(() => { useEffect(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
@@ -167,7 +186,7 @@ export default function ClientResourcesTable({
}, [initialFilterSite, siteIdQ, siteIdNum, t]); }, [initialFilterSite, siteIdQ, siteIdNum, t]);
const refreshData = () => { const refreshData = () => {
startTransition(() => { startRefreshTransition(() => {
try { try {
router.refresh(); router.refresh();
} catch (error) { } catch (error) {
@@ -254,7 +273,10 @@ export default function ClientResourcesTable({
); );
} }
const internalColumns: ExtendedColumnDef<InternalResourceRow>[] = [ const internalColumns = useMemo<
ExtendedColumnDef<InternalResourceRow>[]
>(() => {
const cols: ExtendedColumnDef<InternalResourceRow>[] = [
{ {
accessorKey: "name", accessorKey: "name",
enableHiding: false, enableHiding: false,
@@ -290,7 +312,9 @@ export default function ClientResourcesTable({
<Button <Button
variant="ghost" variant="ghost"
onClick={() => onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc") column.toggleSorting(
column.getIsSorted() === "asc"
)
} }
> >
{t("identifier")} {t("identifier")}
@@ -304,10 +328,14 @@ export default function ClientResourcesTable({
}, },
{ {
id: "sites", id: "sites",
accessorFn: (row) => row.sites.map((s) => s.siteName).join(", "), accessorFn: (row) =>
row.sites.map((s) => s.siteName).join(", "),
friendlyName: t("sites"), friendlyName: t("sites"),
header: () => ( header: () => (
<Popover open={siteFilterOpen} onOpenChange={setSiteFilterOpen}> <Popover
open={siteFilterOpen}
onOpenChange={setSiteFilterOpen}
>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
type="button" type="button"
@@ -385,7 +413,9 @@ export default function ClientResourcesTable({
} }
]} ]}
selectedValue={searchParams.get("mode") ?? undefined} selectedValue={searchParams.get("mode") ?? undefined}
onValueChange={(value) => handleFilterChange("mode", value)} onValueChange={(value) =>
handleFilterChange("mode", value)
}
searchPlaceholder={t("searchPlaceholder")} searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")} emptyMessage={t("emptySearchOptions")}
label={t("editInternalResourceDialogMode")} label={t("editInternalResourceDialogMode")}
@@ -410,7 +440,9 @@ export default function ClientResourcesTable({
accessorKey: "destination", accessorKey: "destination",
friendlyName: t("resourcesTableDestination"), friendlyName: t("resourcesTableDestination"),
header: () => ( header: () => (
<span className="p-3">{t("resourcesTableDestination")}</span> <span className="p-3">
{t("resourcesTableDestination")}
</span>
), ),
cell: ({ row }) => { cell: ({ row }) => {
const resourceRow = row.original; const resourceRow = row.original;
@@ -508,7 +540,10 @@ export default function ClientResourcesTable({
<div className="flex items-center gap-2 justify-end"> <div className="flex items-center gap-2 justify-end">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0"> <Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only"> <span className="sr-only">
{t("openMenu")} {t("openMenu")}
</span> </span>
@@ -545,6 +580,27 @@ export default function ClientResourcesTable({
} }
]; ];
if (isLabelFeatureEnabled) {
cols.splice(cols.length - 1, 0, {
id: "labels",
accessorKey: "labels",
header: () => (
<span className="p-3 text-end w-full inline-block">
{t("labels")}
</span>
),
cell: ({ row }: { row: { original: InternalResourceRow } }) => (
<ClientResourceLabelCell
resource={row.original}
orgId={orgId}
/>
)
});
}
return cols;
}, [isLabelFeatureEnabled, orgId, t, searchParams]);
function handleFilterChange( function handleFilterChange(
column: string, column: string,
value: string | undefined | null value: string | undefined | null
@@ -638,7 +694,8 @@ export default function ClientResourcesTable({
enableColumnVisibility enableColumnVisibility
columnVisibility={{ columnVisibility={{
niceId: false, niceId: false,
aliasAddress: false aliasAddress: false,
labels: false
}} }}
stickyLeftColumn="name" stickyLeftColumn="name"
stickyRightColumn="actions" stickyRightColumn="actions"
@@ -674,3 +731,101 @@ export default function ClientResourcesTable({
</> </>
); );
} }
type ClientResourceLabelCellProps = {
resource: InternalResourceRow;
orgId: string;
};
function ClientResourceLabelCell({
resource,
orgId
}: ClientResourceLabelCellProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const router = useRouter();
const labels = resource.labels ?? [];
const [optimisticLabels, setOptimisticLabels] = useOptimistic(labels);
function toggleResourceLabel(
label: SelectedLabel,
action: "attach" | "detach"
) {
startTransition(async () => {
try {
if (action === "attach") {
setOptimisticLabels([...optimisticLabels, label]);
await api.put(
`/org/${orgId}/label/${label.labelId}/attach`,
{ siteResourceId: resource.id }
);
} else {
setOptimisticLabels(
optimisticLabels.filter(
(lb) => lb.labelId !== label.labelId
)
);
await api.put(
`/org/${orgId}/label/${label.labelId}/detach`,
{ siteResourceId: resource.id }
);
}
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e, t("errorOccurred")),
variant: "destructive"
});
} finally {
router.refresh();
}
});
}
return (
<div className="inline-flex flex-wrap items-center justify-end w-full gap-1">
{optimisticLabels.slice(0, 3).map((label) => (
<LabelBadge
key={label.labelId}
onClick={() => setIsPopoverOpen(true)}
{...label}
/>
))}
{optimisticLabels.length > 3 && (
<Button
variant="outline"
className={cn(
"inline-flex gap-1 items-center",
"rounded-full text-sm cursor-pointer",
"px-1.5 py-0 h-auto"
)}
onClick={() => setIsPopoverOpen(true)}
>
+{optimisticLabels.length - 3}
</Button>
)}
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<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={optimisticLabels}
toggleLabel={toggleResourceLabel}
/>
</PopoverContent>
</Popover>
</div>
);
}