From 97aeee541a1bd008bbf7bef0f471b731c1b57769 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 3 Jun 2026 19:15:44 +0200 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Label=20table=20cell=20opt?= =?UTF-8?q?imization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ableLabelsCell.tsx => LabelsTableCell.tsx} | 30 +++-- src/components/MachineClientsTable.tsx | 4 +- src/components/PrivateResourcesTable.tsx | 58 ++-------- src/components/ProxyResourcesTable.tsx | 62 +++-------- src/components/SitesTable.tsx | 69 +++--------- src/components/labels-selector.tsx | 5 +- src/hooks/useNavigationContext.ts | 49 +++++---- src/hooks/useOptimisticLabels.ts | 103 ++++++++++++++++++ 8 files changed, 191 insertions(+), 189 deletions(-) rename src/components/{TableLabelsCell.tsx => LabelsTableCell.tsx} (83%) create mode 100644 src/hooks/useOptimisticLabels.ts diff --git a/src/components/TableLabelsCell.tsx b/src/components/LabelsTableCell.tsx similarity index 83% rename from src/components/TableLabelsCell.tsx rename to src/components/LabelsTableCell.tsx index 77a9538ba..0b6893181 100644 --- a/src/components/TableLabelsCell.tsx +++ b/src/components/LabelsTableCell.tsx @@ -21,29 +21,32 @@ const MAX_VISIBLE_BEFORE_OVERFLOW = MAX_VISIBLE_LABELS - 1; type TableLabelsCellProps = { orgId: string; - localLabels: SelectedLabel[]; - toggleLabel: (label: SelectedLabel, action: "attach" | "detach") => void; + selectedLabels: SelectedLabel[]; + onToggleLabel: (label: SelectedLabel, action: "attach" | "detach") => void; + onClosePopover: () => void; }; -export function TableLabelsCell({ +export function LabelsTableCell({ orgId, - localLabels, - toggleLabel + selectedLabels, + onToggleLabel, + onClosePopover }: TableLabelsCellProps) { const t = useTranslations(); const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const triggerRef = useRef(null); const frozenAnchorRef = useRef({ getBoundingClientRect: () => new DOMRect() }); - const hasOverflow = localLabels.length > MAX_VISIBLE_LABELS; - const visibleLabels = localLabels.slice( + const hasOverflow = selectedLabels.length > MAX_VISIBLE_LABELS; + const visibleLabels = selectedLabels.slice( 0, hasOverflow ? MAX_VISIBLE_BEFORE_OVERFLOW : MAX_VISIBLE_LABELS ); const overflowLabels = hasOverflow - ? localLabels.slice(MAX_VISIBLE_BEFORE_OVERFLOW) + ? selectedLabels.slice(MAX_VISIBLE_BEFORE_OVERFLOW) : []; function handleOpenChange(open: boolean) { @@ -54,10 +57,14 @@ export function TableLabelsCell({ }; } setIsPopoverOpen(open); + + if (!open) { + onClosePopover(); + } } return ( -
+
@@ -80,9 +87,8 @@ export function TableLabelsCell({ > handleOpenChange(false)} + selectedLabels={selectedLabels} + toggleLabel={onToggleLabel} /> diff --git a/src/components/MachineClientsTable.tsx b/src/components/MachineClientsTable.tsx index d7bdba029..613c8ecba 100644 --- a/src/components/MachineClientsTable.tsx +++ b/src/components/MachineClientsTable.tsx @@ -34,7 +34,7 @@ import { useDebouncedCallback } from "use-debounce"; import z from "zod"; import { ColumnFilterButton } from "./ColumnFilterButton"; import { type SelectedLabel } from "./labels-selector"; -import { TableLabelsCell } from "./TableLabelsCell"; +import { LabelsTableCell } from "./LabelsTableCell"; import { Badge } from "./ui/badge"; import { ControlledDataTable } from "./ui/controlled-data-table"; import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; @@ -651,7 +651,7 @@ function MachineClientLabelCell({ } return ( - { - try { - if (action === "attach") { - setLocalLabels([...previousLabels, label]); - await api.put( - `/org/${orgId}/label/${label.labelId}/attach`, - { siteResourceId: resource.id } - ); - } else { - setLocalLabels( - previousLabels.filter( - (lb) => lb.labelId !== label.labelId - ) - ); - await api.put( - `/org/${orgId}/label/${label.labelId}/detach`, - { siteResourceId: resource.id } - ); - } - } catch (e) { - setLocalLabels(previousLabels); - toast({ - title: t("error"), - description: formatAxiosError(e, t("errorOccurred")), - variant: "destructive" - }); - } - })(); - } + const { localLabels, refresh, toggleLabel } = useOptimisticLabels({ + serverLabels: resource.labels, + orgId, + entityId: resource.id, + entityIdField: "siteResourceId" + }); return ( - startTransition(refresh)} + onToggleLabel={toggleLabel} + selectedLabels={localLabels} /> ); } diff --git a/src/components/ProxyResourcesTable.tsx b/src/components/ProxyResourcesTable.tsx index 80d2e9d25..0b761a540 100644 --- a/src/components/ProxyResourcesTable.tsx +++ b/src/components/ProxyResourcesTable.tsx @@ -73,7 +73,9 @@ import UptimeMiniBar from "./UptimeMiniBar"; import { type SelectedLabel } from "./labels-selector"; import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; import { useLocalLabels } from "@app/hooks/useLocalLabels"; -import { TableLabelsCell } from "./TableLabelsCell"; +import { LabelsTableCell } from "./LabelsTableCell"; +import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels"; +import { refresh } from "next/cache"; export type TargetHealth = { targetId: number; @@ -772,57 +774,19 @@ type ResourceLabelCellProps = { }; function ResourceLabelCell({ resource, orgId }: ResourceLabelCellProps) { - const t = useTranslations(); - - const api = createApiClient(useEnvContext()); - - const [localLabels, setLocalLabels] = useLocalLabels( - resource.labels, - resource.id - ); - - function toggleSiteLabel( - label: SelectedLabel, - action: "attach" | "detach" - ) { - const previousLabels = localLabels; - - void (async () => { - try { - if (action === "attach") { - setLocalLabels([...previousLabels, label]); - - await api.put( - `/org/${orgId}/label/${label.labelId}/attach`, - { resourceId: resource.id } - ); - } else { - setLocalLabels( - previousLabels.filter( - (lb) => lb.labelId !== label.labelId - ) - ); - await api.put( - `/org/${orgId}/label/${label.labelId}/detach`, - { resourceId: resource.id } - ); - } - } catch (e) { - setLocalLabels(previousLabels); - toast({ - title: t("error"), - description: formatAxiosError(e, t("errorOccurred")), - variant: "destructive" - }); - } - })(); - } + const { localLabels, refresh, toggleLabel } = useOptimisticLabels({ + serverLabels: resource.labels, + orgId, + entityId: resource.id, + entityIdField: "resourceId" + }); return ( - startTransition(refresh)} /> ); } diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index aac422de9..3e234bf79 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -41,13 +41,7 @@ import { import { useTranslations } from "next-intl"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; -import { - startTransition, - useEffect, - useMemo, - useState, - useTransition -} from "react"; +import { startTransition, useMemo, useState, useTransition } from "react"; import { useDebouncedCallback } from "use-debounce"; import z from "zod"; import { ColumnFilterButton } from "./ColumnFilterButton"; @@ -56,13 +50,11 @@ import { type ExtendedColumnDef } from "./ui/controlled-data-table"; +import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; -import { cn } from "@app/lib/cn"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; -import { type SelectedLabel } from "./labels-selector"; import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; -import { useLocalLabels } from "@app/hooks/useLocalLabels"; -import { TableLabelsCell } from "./TableLabelsCell"; +import { LabelsTableCell } from "./LabelsTableCell"; export type SiteRow = { id: number; @@ -686,54 +678,19 @@ type SiteLabelCellProps = { }; function SiteLabelCell({ site, orgId }: SiteLabelCellProps) { - const t = useTranslations(); - - const api = createApiClient(useEnvContext()); - - const [localLabels, setLocalLabels] = useLocalLabels(site.labels, site.id); - - function toggleSiteLabel( - label: SelectedLabel, - action: "attach" | "detach" - ) { - const previousLabels = localLabels; - - void (async () => { - try { - if (action === "attach") { - setLocalLabels([...previousLabels, label]); - - await api.put( - `/org/${orgId}/label/${label.labelId}/attach`, - { siteId: site.id } - ); - } else { - setLocalLabels( - previousLabels.filter( - (lb) => lb.labelId !== label.labelId - ) - ); - await api.put( - `/org/${orgId}/label/${label.labelId}/detach`, - { siteId: site.id } - ); - } - } catch (e) { - setLocalLabels(previousLabels); - toast({ - title: t("error"), - description: formatAxiosError(e, t("errorOccurred")), - variant: "destructive" - }); - } - })(); - } + const { localLabels, refresh, toggleLabel } = useOptimisticLabels({ + serverLabels: site.labels, + orgId, + entityId: site.id, + entityIdField: "siteId" + }); return ( - startTransition(refresh)} /> ); } diff --git a/src/components/labels-selector.tsx b/src/components/labels-selector.tsx index eefa87d8a..877fbc1c6 100644 --- a/src/components/labels-selector.tsx +++ b/src/components/labels-selector.tsx @@ -36,7 +36,6 @@ export type LabelsSelectorProps = { orgId: string; selectedLabels: SelectedLabel[]; toggleLabel: (newlabel: SelectedLabel, action: "detach" | "attach") => void; - onClose?: () => void; }; export const LABEL_COLORS = { @@ -52,8 +51,7 @@ export const LABEL_COLORS = { export function LabelsSelector({ orgId, selectedLabels, - toggleLabel, - onClose + toggleLabel }: LabelsSelectorProps) { const t = useTranslations(); const [labelSearchQuery, setlabelsSearchQuery] = useState(""); @@ -202,7 +200,6 @@ export function LabelsSelector({ ? "detach" : "attach" ); - onClose?.(); }} > { - const fullPath = pathname + (params ? `?${params.toString()}` : ""); + const navigate = useCallback( + function ({ + searchParams: params, + pathname = path, + replace = false + }: { + pathname?: string; + searchParams?: URLSearchParams; + replace?: boolean; + }) { + startTransition(() => { + const fullPath = + pathname + (params ? `?${params.toString()}` : ""); - if (replace) { - router.replace(fullPath); - } else { - router.push(fullPath); - } - }); - } + if (replace) { + router.replace(fullPath); + } else { + router.push(fullPath); + } + }); + }, + [router] + ); + + const writableSearchParams = useMemo( + () => new URLSearchParams(searchParams), + [searchParams] + ); return { pathname: path, - searchParams: new URLSearchParams(searchParams), // we want the search params to be writeable + searchParams: writableSearchParams, navigate, isNavigating }; diff --git a/src/hooks/useOptimisticLabels.ts b/src/hooks/useOptimisticLabels.ts new file mode 100644 index 000000000..f5c787474 --- /dev/null +++ b/src/hooks/useOptimisticLabels.ts @@ -0,0 +1,103 @@ +import type { SelectedLabel } from "@app/components/labels-selector"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useState, useMemo } from "react"; +import { toast } from "./useToast"; +import { useEnvContext } from "./useEnvContext"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; + +export type LabelToggleAction = { + label: SelectedLabel; + action: "attach" | "detach"; +}; + +function computeLabelToggleActions( + values: SelectedLabel[], + actions: LabelToggleAction[] +) { + let newValues = [...values]; + for (const { action, label } of actions) { + if (action === "attach") { + newValues = [...newValues, label]; + } else { + newValues = newValues.filter((lb) => lb.labelId !== label.labelId); + } + } + + return newValues; +} + +type UseOptimisticLabelsArgs = { + serverLabels: SelectedLabel[] | undefined; + orgId: string; + entityId: number; + entityIdField: string; +}; + +export function useOptimisticLabels({ + serverLabels, + orgId, + entityId, + entityIdField +}: UseOptimisticLabelsArgs) { + const router = useRouter(); + const labels = serverLabels ?? []; + const api = createApiClient(useEnvContext()); + const t = useTranslations(); + + const [pendingActions, setPendingActions] = useState( + [] + ); + + const localLabels = useMemo( + () => computeLabelToggleActions(labels ?? [], pendingActions), + [labels, pendingActions] + ); + + async function toggleLabel( + label: SelectedLabel, + action: "attach" | "detach" + ) { + const oppositeAction = action === "attach" ? "detach" : "attach"; + const existingActionIndex = pendingActions.findIndex( + (pending) => + pending.action === oppositeAction && + pending.label.labelId === label.labelId + ); + + // if there are two actions that cancel each-other + // they should just be removed + if (existingActionIndex !== -1) { + setPendingActions((prevActions) => + prevActions.toSpliced(existingActionIndex, 1) + ); + } else { + setPendingActions((actions) => [...actions, { label, action }]); + } + + try { + if (action === "attach") { + await api.put(`/org/${orgId}/label/${label.labelId}/attach`, { + [entityIdField]: entityId + }); + } else { + await api.put(`/org/${orgId}/label/${label.labelId}/detach`, { + [entityIdField]: entityId + }); + } + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e, t("errorOccurred")), + variant: "destructive" + }); + } + } + + async function refresh() { + router.refresh(); + setPendingActions([]); + } + + return { localLabels, toggleLabel, refresh }; +}