mirror of
https://github.com/fosrl/pangolin.git
synced 2026-06-27 09:39:01 +00:00
♻️ Label table cell optimization
This commit is contained in:
@@ -21,29 +21,32 @@ const MAX_VISIBLE_BEFORE_OVERFLOW = MAX_VISIBLE_LABELS - 1;
|
|||||||
|
|
||||||
type TableLabelsCellProps = {
|
type TableLabelsCellProps = {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
localLabels: SelectedLabel[];
|
selectedLabels: SelectedLabel[];
|
||||||
toggleLabel: (label: SelectedLabel, action: "attach" | "detach") => void;
|
onToggleLabel: (label: SelectedLabel, action: "attach" | "detach") => void;
|
||||||
|
onClosePopover: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TableLabelsCell({
|
export function LabelsTableCell({
|
||||||
orgId,
|
orgId,
|
||||||
localLabels,
|
selectedLabels,
|
||||||
toggleLabel
|
onToggleLabel,
|
||||||
|
onClosePopover
|
||||||
}: TableLabelsCellProps) {
|
}: TableLabelsCellProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||||
|
|
||||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const frozenAnchorRef = useRef<Measurable>({
|
const frozenAnchorRef = useRef<Measurable>({
|
||||||
getBoundingClientRect: () => new DOMRect()
|
getBoundingClientRect: () => new DOMRect()
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasOverflow = localLabels.length > MAX_VISIBLE_LABELS;
|
const hasOverflow = selectedLabels.length > MAX_VISIBLE_LABELS;
|
||||||
const visibleLabels = localLabels.slice(
|
const visibleLabels = selectedLabels.slice(
|
||||||
0,
|
0,
|
||||||
hasOverflow ? MAX_VISIBLE_BEFORE_OVERFLOW : MAX_VISIBLE_LABELS
|
hasOverflow ? MAX_VISIBLE_BEFORE_OVERFLOW : MAX_VISIBLE_LABELS
|
||||||
);
|
);
|
||||||
const overflowLabels = hasOverflow
|
const overflowLabels = hasOverflow
|
||||||
? localLabels.slice(MAX_VISIBLE_BEFORE_OVERFLOW)
|
? selectedLabels.slice(MAX_VISIBLE_BEFORE_OVERFLOW)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
function handleOpenChange(open: boolean) {
|
function handleOpenChange(open: boolean) {
|
||||||
@@ -54,10 +57,14 @@ export function TableLabelsCell({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
setIsPopoverOpen(open);
|
setIsPopoverOpen(open);
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
onClosePopover();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid w-full min-w-0 grid-cols-[auto_minmax(0,1fr)] items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Popover open={isPopoverOpen} onOpenChange={handleOpenChange}>
|
<Popover open={isPopoverOpen} onOpenChange={handleOpenChange}>
|
||||||
<PopoverAnchor virtualRef={frozenAnchorRef} />
|
<PopoverAnchor virtualRef={frozenAnchorRef} />
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
@@ -80,9 +87,8 @@ export function TableLabelsCell({
|
|||||||
>
|
>
|
||||||
<LabelsSelector
|
<LabelsSelector
|
||||||
orgId={orgId}
|
orgId={orgId}
|
||||||
selectedLabels={localLabels}
|
selectedLabels={selectedLabels}
|
||||||
toggleLabel={toggleLabel}
|
toggleLabel={onToggleLabel}
|
||||||
onClose={() => handleOpenChange(false)}
|
|
||||||
/>
|
/>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
@@ -34,7 +34,7 @@ import { useDebouncedCallback } from "use-debounce";
|
|||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { ColumnFilterButton } from "./ColumnFilterButton";
|
import { ColumnFilterButton } from "./ColumnFilterButton";
|
||||||
import { type SelectedLabel } from "./labels-selector";
|
import { type SelectedLabel } from "./labels-selector";
|
||||||
import { TableLabelsCell } from "./TableLabelsCell";
|
import { LabelsTableCell } from "./LabelsTableCell";
|
||||||
import { Badge } from "./ui/badge";
|
import { Badge } from "./ui/badge";
|
||||||
import { ControlledDataTable } from "./ui/controlled-data-table";
|
import { ControlledDataTable } from "./ui/controlled-data-table";
|
||||||
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
|
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
|
||||||
@@ -651,7 +651,7 @@ function MachineClientLabelCell({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableLabelsCell
|
<LabelsTableCell
|
||||||
orgId={orgId}
|
orgId={orgId}
|
||||||
localLabels={localLabels}
|
localLabels={localLabels}
|
||||||
toggleLabel={toggleClientLabel}
|
toggleLabel={toggleClientLabel}
|
||||||
|
|||||||
@@ -61,9 +61,10 @@ import { build } from "@server/build";
|
|||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import { type SelectedLabel } from "./labels-selector";
|
import { type SelectedLabel } from "./labels-selector";
|
||||||
import { TableLabelsCell } from "./TableLabelsCell";
|
import { LabelsTableCell } from "./LabelsTableCell";
|
||||||
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
|
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
|
||||||
import { useLocalLabels } from "@app/hooks/useLocalLabels";
|
import { useLocalLabels } from "@app/hooks/useLocalLabels";
|
||||||
|
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
|
||||||
|
|
||||||
export type InternalResourceSiteRow = ResourceSiteRow;
|
export type InternalResourceSiteRow = ResourceSiteRow;
|
||||||
|
|
||||||
@@ -705,54 +706,19 @@ function ClientResourceLabelCell({
|
|||||||
resource,
|
resource,
|
||||||
orgId
|
orgId
|
||||||
}: ClientResourceLabelCellProps) {
|
}: ClientResourceLabelCellProps) {
|
||||||
const t = useTranslations();
|
const { localLabels, refresh, toggleLabel } = useOptimisticLabels({
|
||||||
const api = createApiClient(useEnvContext());
|
serverLabels: resource.labels,
|
||||||
const [localLabels, setLocalLabels] = useLocalLabels(
|
orgId,
|
||||||
resource.labels,
|
entityId: resource.id,
|
||||||
resource.id
|
entityIdField: "siteResourceId"
|
||||||
);
|
});
|
||||||
|
|
||||||
function toggleResourceLabel(
|
|
||||||
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`,
|
|
||||||
{ 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"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableLabelsCell
|
<LabelsTableCell
|
||||||
orgId={orgId}
|
orgId={orgId}
|
||||||
localLabels={localLabels}
|
onClosePopover={() => startTransition(refresh)}
|
||||||
toggleLabel={toggleResourceLabel}
|
onToggleLabel={toggleLabel}
|
||||||
|
selectedLabels={localLabels}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,9 @@ import UptimeMiniBar from "./UptimeMiniBar";
|
|||||||
import { type SelectedLabel } from "./labels-selector";
|
import { type SelectedLabel } from "./labels-selector";
|
||||||
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
|
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
|
||||||
import { useLocalLabels } from "@app/hooks/useLocalLabels";
|
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 = {
|
export type TargetHealth = {
|
||||||
targetId: number;
|
targetId: number;
|
||||||
@@ -772,57 +774,19 @@ type ResourceLabelCellProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function ResourceLabelCell({ resource, orgId }: ResourceLabelCellProps) {
|
function ResourceLabelCell({ resource, orgId }: ResourceLabelCellProps) {
|
||||||
const t = useTranslations();
|
const { localLabels, refresh, toggleLabel } = useOptimisticLabels({
|
||||||
|
serverLabels: resource.labels,
|
||||||
const api = createApiClient(useEnvContext());
|
orgId,
|
||||||
|
entityId: resource.id,
|
||||||
const [localLabels, setLocalLabels] = useLocalLabels(
|
entityIdField: "resourceId"
|
||||||
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"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableLabelsCell
|
<LabelsTableCell
|
||||||
orgId={orgId}
|
orgId={orgId}
|
||||||
localLabels={localLabels}
|
selectedLabels={localLabels}
|
||||||
toggleLabel={toggleSiteLabel}
|
onToggleLabel={toggleLabel}
|
||||||
|
onClosePopover={() => startTransition(refresh)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,13 +41,7 @@ import {
|
|||||||
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 {
|
import { startTransition, useMemo, useState, useTransition } from "react";
|
||||||
startTransition,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
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";
|
||||||
@@ -56,13 +50,11 @@ import {
|
|||||||
type ExtendedColumnDef
|
type ExtendedColumnDef
|
||||||
} from "./ui/controlled-data-table";
|
} from "./ui/controlled-data-table";
|
||||||
|
|
||||||
|
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
|
||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
import { cn } from "@app/lib/cn";
|
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import { type SelectedLabel } from "./labels-selector";
|
|
||||||
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
|
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
|
||||||
import { useLocalLabels } from "@app/hooks/useLocalLabels";
|
import { LabelsTableCell } from "./LabelsTableCell";
|
||||||
import { TableLabelsCell } from "./TableLabelsCell";
|
|
||||||
|
|
||||||
export type SiteRow = {
|
export type SiteRow = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -686,54 +678,19 @@ type SiteLabelCellProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function SiteLabelCell({ site, orgId }: SiteLabelCellProps) {
|
function SiteLabelCell({ site, orgId }: SiteLabelCellProps) {
|
||||||
const t = useTranslations();
|
const { localLabels, refresh, toggleLabel } = useOptimisticLabels({
|
||||||
|
serverLabels: site.labels,
|
||||||
const api = createApiClient(useEnvContext());
|
orgId,
|
||||||
|
entityId: site.id,
|
||||||
const [localLabels, setLocalLabels] = useLocalLabels(site.labels, site.id);
|
entityIdField: "siteId"
|
||||||
|
});
|
||||||
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"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableLabelsCell
|
<LabelsTableCell
|
||||||
orgId={orgId}
|
orgId={orgId}
|
||||||
localLabels={localLabels}
|
selectedLabels={localLabels}
|
||||||
toggleLabel={toggleSiteLabel}
|
onToggleLabel={toggleLabel}
|
||||||
|
onClosePopover={() => startTransition(refresh)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ export type LabelsSelectorProps = {
|
|||||||
orgId: string;
|
orgId: string;
|
||||||
selectedLabels: SelectedLabel[];
|
selectedLabels: SelectedLabel[];
|
||||||
toggleLabel: (newlabel: SelectedLabel, action: "detach" | "attach") => void;
|
toggleLabel: (newlabel: SelectedLabel, action: "detach" | "attach") => void;
|
||||||
onClose?: () => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LABEL_COLORS = {
|
export const LABEL_COLORS = {
|
||||||
@@ -52,8 +51,7 @@ export const LABEL_COLORS = {
|
|||||||
export function LabelsSelector({
|
export function LabelsSelector({
|
||||||
orgId,
|
orgId,
|
||||||
selectedLabels,
|
selectedLabels,
|
||||||
toggleLabel,
|
toggleLabel
|
||||||
onClose
|
|
||||||
}: LabelsSelectorProps) {
|
}: LabelsSelectorProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const [labelSearchQuery, setlabelsSearchQuery] = useState("");
|
const [labelSearchQuery, setlabelsSearchQuery] = useState("");
|
||||||
@@ -202,7 +200,6 @@ export function LabelsSelector({
|
|||||||
? "detach"
|
? "detach"
|
||||||
: "attach"
|
: "attach"
|
||||||
);
|
);
|
||||||
onClose?.();
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useSearchParams, usePathname, useRouter } from "next/navigation";
|
import { useSearchParams, usePathname, useRouter } from "next/navigation";
|
||||||
import { useTransition } from "react";
|
import { useCallback, useMemo, useTransition } from "react";
|
||||||
|
|
||||||
export function useNavigationContext() {
|
export function useNavigationContext() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -7,29 +7,38 @@ export function useNavigationContext() {
|
|||||||
const path = usePathname();
|
const path = usePathname();
|
||||||
const [isNavigating, startTransition] = useTransition();
|
const [isNavigating, startTransition] = useTransition();
|
||||||
|
|
||||||
function navigate({
|
const navigate = useCallback(
|
||||||
searchParams: params,
|
function ({
|
||||||
pathname = path,
|
searchParams: params,
|
||||||
replace = false
|
pathname = path,
|
||||||
}: {
|
replace = false
|
||||||
pathname?: string;
|
}: {
|
||||||
searchParams?: URLSearchParams;
|
pathname?: string;
|
||||||
replace?: boolean;
|
searchParams?: URLSearchParams;
|
||||||
}) {
|
replace?: boolean;
|
||||||
startTransition(() => {
|
}) {
|
||||||
const fullPath = pathname + (params ? `?${params.toString()}` : "");
|
startTransition(() => {
|
||||||
|
const fullPath =
|
||||||
|
pathname + (params ? `?${params.toString()}` : "");
|
||||||
|
|
||||||
if (replace) {
|
if (replace) {
|
||||||
router.replace(fullPath);
|
router.replace(fullPath);
|
||||||
} else {
|
} else {
|
||||||
router.push(fullPath);
|
router.push(fullPath);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
const writableSearchParams = useMemo(
|
||||||
|
() => new URLSearchParams(searchParams),
|
||||||
|
[searchParams]
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pathname: path,
|
pathname: path,
|
||||||
searchParams: new URLSearchParams(searchParams), // we want the search params to be writeable
|
searchParams: writableSearchParams,
|
||||||
navigate,
|
navigate,
|
||||||
isNavigating
|
isNavigating
|
||||||
};
|
};
|
||||||
|
|||||||
103
src/hooks/useOptimisticLabels.ts
Normal file
103
src/hooks/useOptimisticLabels.ts
Normal file
@@ -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<LabelToggleAction[]>(
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user