Merge branch 'dev' of github.com:fosrl/pangolin into dev

This commit is contained in:
Owen
2026-06-03 13:50:54 -07:00
16 changed files with 248 additions and 273 deletions

View File

@@ -1392,6 +1392,16 @@ export type ResourceHeaderAuthExtendedCompatibility = InferSelectModel<
export type ResourceOtp = InferSelectModel<typeof resourceOtp>; export type ResourceOtp = InferSelectModel<typeof resourceOtp>;
export type ResourceAccessToken = InferSelectModel<typeof resourceAccessToken>; export type ResourceAccessToken = InferSelectModel<typeof resourceAccessToken>;
export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>; export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>;
export type ResourcePolicyPincode = InferSelectModel<
typeof resourcePolicyPincode
>;
export type ResourcePolicyPassword = InferSelectModel<
typeof resourcePolicyPassword
>;
export type ResourcePolicyHeaderAuth = InferSelectModel<
typeof resourcePolicyHeaderAuth
>;
export type VersionMigration = InferSelectModel<typeof versionMigrations>; export type VersionMigration = InferSelectModel<typeof versionMigrations>;
export type ResourceRule = InferSelectModel<typeof resourceRules>; export type ResourceRule = InferSelectModel<typeof resourceRules>;
export type Domain = InferSelectModel<typeof domains>; export type Domain = InferSelectModel<typeof domains>;

View File

@@ -47,6 +47,7 @@ import { signPublicKey, getOrgCAKeys } from "@server/lib/sshCA";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { sendToClient } from "#private/routers/ws"; import { sendToClient } from "#private/routers/ws";
import { ActionsEnum } from "@server/auth/actions"; import { ActionsEnum } from "@server/auth/actions";
import type { SignSshKeyResponse } from "@server/routers/ssh/types";
const paramsSchema = z.strictObject({ const paramsSchema = z.strictObject({
orgId: z.string().nonempty() orgId: z.string().nonempty()
@@ -72,23 +73,6 @@ const bodySchema = z
} }
); );
export type SignSshKeyResponse = {
certificate?: string;
messageIds: number[];
messageId?: number;
sshUsername: string;
sshHost: string;
resourceId: number;
siteIds: number[];
siteId: number;
keyId?: string;
validPrincipals?: string[];
validAfter?: string;
validBefore?: string;
expiresIn?: number;
authDaemonMode: "site" | "remote" | "native" | null;
};
export async function signSshKey( export async function signSshKey(
req: Request, req: Request,
res: Response, res: Response,

View File

@@ -0,0 +1,16 @@
export type SignSshKeyResponse = {
certificate?: string;
messageIds: number[];
messageId?: number;
sshUsername: string;
sshHost: string;
resourceId: number;
siteIds: number[];
siteId: number;
keyId?: string;
validPrincipals?: string[];
validAfter?: string;
validBefore?: string;
expiresIn?: number;
authDaemonMode: "site" | "remote" | "native" | null;
};

View File

@@ -6,7 +6,6 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import type { SignSshKeyResponse } from "@server/private/routers/ssh";
import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget"; import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget";
import { import {
Card, Card,
@@ -18,6 +17,7 @@ import {
import Link from "next/link"; import Link from "next/link";
import { ExternalLink } from "lucide-react"; import { ExternalLink } from "lucide-react";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import type { SignSshKeyResponse } from "@server/routers/ssh/types";
type AuthTab = "password" | "privateKey"; type AuthTab = "password" | "privateKey";

View File

@@ -3,9 +3,9 @@ import { priv } from "@app/lib/api";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget"; import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget";
import SshClient from "./SshClient"; import SshClient from "./SshClient";
import { SignSshKeyResponse } from "@server/private/routers/ssh";
import crypto from "crypto"; import crypto from "crypto";
import AuthFooter from "@app/components/AuthFooter"; import AuthFooter from "@app/components/AuthFooter";
import type { SignSshKeyResponse } from "@server/routers/ssh/types";
const pollInitialDelayMs = 250; const pollInitialDelayMs = 250;
const pollStartIntervalMs = 250; const pollStartIntervalMs = 250;

View File

@@ -20,6 +20,7 @@ import { CheckIcon, Funnel } from "lucide-react";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover"; import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
import { Badge } from "./ui/badge"; import { Badge } from "./ui/badge";
import { Checkbox } from "./ui/checkbox";
type FilterOption = { type FilterOption = {
value: string; value: string;
@@ -130,13 +131,11 @@ export function ColumnMultiFilterButton({
toggle(option.value); toggle(option.value);
}} }}
> >
<CheckIcon <Checkbox
className={cn( className="pointer-events-none shrink-0"
"mr-2 h-4 w-4", checked={selectedSet.has(option.value)}
selectedSet.has(option.value) aria-hidden
? "opacity-100" tabIndex={-1}
: "opacity-0"
)}
/> />
{option.label} {option.label}
</CommandItem> </CommandItem>

View File

@@ -25,6 +25,7 @@ import { useDebounce } from "use-debounce";
import { LabelBadge } from "./label-badge"; import { LabelBadge } from "./label-badge";
import { LabelOverflowBadge } from "./label-overflow-badge"; import { LabelOverflowBadge } from "./label-overflow-badge";
import { LABEL_COLORS } from "./labels-selector"; import { LABEL_COLORS } from "./labels-selector";
import { Checkbox } from "./ui/checkbox";
function areSelectionsEqual(a: string[], b: string[]) { function areSelectionsEqual(a: string[], b: string[]) {
if (a.length !== b.length) { if (a.length !== b.length) {
@@ -179,13 +180,11 @@ export function LabelColumnFilterButton({
}} }}
className="flex items-center gap-2" className="flex items-center gap-2"
> >
<CheckIcon <Checkbox
className={cn( className="pointer-events-none shrink-0"
"mr-2 h-4 w-4", checked={draftSet.has(label.name)}
draftSet.has(label.name) aria-hidden
? "opacity-100" tabIndex={-1}
: "opacity-0"
)}
/> />
<div <div
className="size-2 rounded-full bg-(--color) flex-none" className="size-2 rounded-full bg-(--color) flex-none"

View File

@@ -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>

View File

@@ -34,11 +34,12 @@ 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";
import { useLocalLabels } from "@app/hooks/useLocalLabels"; import { useLocalLabels } from "@app/hooks/useLocalLabels";
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
export type ClientRow = { export type ClientRow = {
id: number; id: number;
@@ -607,54 +608,19 @@ function MachineClientLabelCell({
client, client,
orgId orgId
}: MachineClientLabelCellProps) { }: MachineClientLabelCellProps) {
const t = useTranslations(); const { localLabels, refresh, toggleLabel } = useOptimisticLabels({
const api = createApiClient(useEnvContext()); serverLabels: client.labels,
const [localLabels, setLocalLabels] = useLocalLabels( orgId,
client.labels, entityId: client.id,
client.id entityIdField: "clientId"
); });
function toggleClientLabel(
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`,
{ clientId: client.id }
);
} else {
setLocalLabels(
previousLabels.filter(
(lb) => lb.labelId !== label.labelId
)
);
await api.put(
`/org/${orgId}/label/${label.labelId}/detach`,
{ clientId: client.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={toggleClientLabel} onToggleLabel={toggleLabel}
onClosePopover={() => startTransition(refresh)}
/> />
); );
} }

View File

@@ -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}
/> />
); );
} }

View File

@@ -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)}
/> />
); );
} }

View File

@@ -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)}
/> />
); );
} }

View File

@@ -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

View File

@@ -10,6 +10,7 @@ import {
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import { CheckIcon } from "lucide-react"; import { CheckIcon } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Checkbox } from "../ui/checkbox";
export type TagValue = { text: string; id: string; isAdmin?: boolean }; export type TagValue = { text: string; id: string; isAdmin?: boolean };
@@ -70,13 +71,11 @@ export function MultiSelectContent<T extends TagValue>({
onChange(newValues); onChange(newValues);
}} }}
> >
<CheckIcon <Checkbox
className={cn( className="pointer-events-none shrink-0"
"mr-2 h-4 w-4", checked={selectedValues.has(option.id)}
selectedValues.has(option.id) aria-hidden
? "opacity-100" tabIndex={-1}
: "opacity-0"
)}
/> />
{`${option.text}`} {`${option.text}`}
</CommandItem> </CommandItem>

View File

@@ -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
}; };

View 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 };
}