mirror of
https://github.com/fosrl/pangolin.git
synced 2026-06-05 15:26:35 +00:00
Merge pull request #3201 from Fredkiss3/refactor/standardize-dropdowns
refactor: standardize dropdowns accross tables
This commit is contained in:
@@ -1392,6 +1392,16 @@ export type ResourceHeaderAuthExtendedCompatibility = InferSelectModel<
|
||||
export type ResourceOtp = InferSelectModel<typeof resourceOtp>;
|
||||
export type ResourceAccessToken = InferSelectModel<typeof resourceAccessToken>;
|
||||
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 ResourceRule = InferSelectModel<typeof resourceRules>;
|
||||
export type Domain = InferSelectModel<typeof domains>;
|
||||
|
||||
@@ -40,6 +40,7 @@ import { signPublicKey, getOrgCAKeys } from "@server/lib/sshCA";
|
||||
import config from "@server/lib/config";
|
||||
import { sendToClient } from "#private/routers/ws";
|
||||
import { ActionsEnum } from "@server/auth/actions";
|
||||
import type { SignSshKeyResponse } from "@server/routers/ssh/types";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty()
|
||||
@@ -64,23 +65,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(
|
||||
req: Request,
|
||||
res: Response,
|
||||
|
||||
16
server/routers/ssh/types.ts
Normal file
16
server/routers/ssh/types.ts
Normal 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;
|
||||
};
|
||||
@@ -6,7 +6,6 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import type { SignSshKeyResponse } from "@server/private/routers/ssh";
|
||||
import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget";
|
||||
import {
|
||||
Card,
|
||||
@@ -18,6 +17,7 @@ import {
|
||||
import Link from "next/link";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import type { SignSshKeyResponse } from "@server/routers/ssh/types";
|
||||
|
||||
type AuthTab = "password" | "privateKey";
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ import { priv } from "@app/lib/api";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget";
|
||||
import SshClient from "./SshClient";
|
||||
import { SignSshKeyResponse } from "@server/private/routers/ssh";
|
||||
import crypto from "crypto";
|
||||
import AuthFooter from "@app/components/AuthFooter";
|
||||
import type { SignSshKeyResponse } from "@server/routers/ssh/types";
|
||||
|
||||
function generateEphemeralKeyPair(): {
|
||||
privateKeyPem: string;
|
||||
|
||||
@@ -20,6 +20,7 @@ import { CheckIcon, Funnel } from "lucide-react";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Checkbox } from "./ui/checkbox";
|
||||
|
||||
type FilterOption = {
|
||||
value: string;
|
||||
@@ -130,13 +131,11 @@ export function ColumnMultiFilterButton({
|
||||
toggle(option.value);
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedSet.has(option.value)
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
<Checkbox
|
||||
className="pointer-events-none shrink-0"
|
||||
checked={selectedSet.has(option.value)}
|
||||
aria-hidden
|
||||
tabIndex={-1}
|
||||
/>
|
||||
{option.label}
|
||||
</CommandItem>
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useDebounce } from "use-debounce";
|
||||
import { LabelBadge } from "./label-badge";
|
||||
import { LabelOverflowBadge } from "./label-overflow-badge";
|
||||
import { LABEL_COLORS } from "./labels-selector";
|
||||
import { Checkbox } from "./ui/checkbox";
|
||||
|
||||
function areSelectionsEqual(a: string[], b: string[]) {
|
||||
if (a.length !== b.length) {
|
||||
@@ -179,13 +180,11 @@ export function LabelColumnFilterButton({
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
draftSet.has(label.name)
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
<Checkbox
|
||||
className="pointer-events-none shrink-0"
|
||||
checked={draftSet.has(label.name)}
|
||||
aria-hidden
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<div
|
||||
className="size-2 rounded-full bg-(--color) flex-none"
|
||||
|
||||
@@ -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<HTMLButtonElement>(null);
|
||||
const frozenAnchorRef = useRef<Measurable>({
|
||||
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 (
|
||||
<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}>
|
||||
<PopoverAnchor virtualRef={frozenAnchorRef} />
|
||||
<PopoverTrigger asChild>
|
||||
@@ -80,9 +87,8 @@ export function TableLabelsCell({
|
||||
>
|
||||
<LabelsSelector
|
||||
orgId={orgId}
|
||||
selectedLabels={localLabels}
|
||||
toggleLabel={toggleLabel}
|
||||
onClose={() => handleOpenChange(false)}
|
||||
selectedLabels={selectedLabels}
|
||||
toggleLabel={onToggleLabel}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
@@ -34,11 +34,12 @@ 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";
|
||||
import { useLocalLabels } from "@app/hooks/useLocalLabels";
|
||||
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
|
||||
|
||||
export type ClientRow = {
|
||||
id: number;
|
||||
@@ -607,54 +608,19 @@ function MachineClientLabelCell({
|
||||
client,
|
||||
orgId
|
||||
}: MachineClientLabelCellProps) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const [localLabels, setLocalLabels] = useLocalLabels(
|
||||
client.labels,
|
||||
client.id
|
||||
);
|
||||
|
||||
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"
|
||||
});
|
||||
}
|
||||
})();
|
||||
}
|
||||
const { localLabels, refresh, toggleLabel } = useOptimisticLabels({
|
||||
serverLabels: client.labels,
|
||||
orgId,
|
||||
entityId: client.id,
|
||||
entityIdField: "clientId"
|
||||
});
|
||||
|
||||
return (
|
||||
<TableLabelsCell
|
||||
<LabelsTableCell
|
||||
orgId={orgId}
|
||||
localLabels={localLabels}
|
||||
toggleLabel={toggleClientLabel}
|
||||
selectedLabels={localLabels}
|
||||
onToggleLabel={toggleLabel}
|
||||
onClosePopover={() => startTransition(refresh)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,9 +61,10 @@ import { build } from "@server/build";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { type SelectedLabel } from "./labels-selector";
|
||||
import { TableLabelsCell } from "./TableLabelsCell";
|
||||
import { LabelsTableCell } from "./LabelsTableCell";
|
||||
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
|
||||
import { useLocalLabels } from "@app/hooks/useLocalLabels";
|
||||
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
|
||||
|
||||
export type InternalResourceSiteRow = ResourceSiteRow;
|
||||
|
||||
@@ -705,54 +706,19 @@ function ClientResourceLabelCell({
|
||||
resource,
|
||||
orgId
|
||||
}: ClientResourceLabelCellProps) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const [localLabels, setLocalLabels] = useLocalLabels(
|
||||
resource.labels,
|
||||
resource.id
|
||||
);
|
||||
|
||||
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"
|
||||
});
|
||||
}
|
||||
})();
|
||||
}
|
||||
const { localLabels, refresh, toggleLabel } = useOptimisticLabels({
|
||||
serverLabels: resource.labels,
|
||||
orgId,
|
||||
entityId: resource.id,
|
||||
entityIdField: "siteResourceId"
|
||||
});
|
||||
|
||||
return (
|
||||
<TableLabelsCell
|
||||
<LabelsTableCell
|
||||
orgId={orgId}
|
||||
localLabels={localLabels}
|
||||
toggleLabel={toggleResourceLabel}
|
||||
onClosePopover={() => startTransition(refresh)}
|
||||
onToggleLabel={toggleLabel}
|
||||
selectedLabels={localLabels}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<TableLabelsCell
|
||||
<LabelsTableCell
|
||||
orgId={orgId}
|
||||
localLabels={localLabels}
|
||||
toggleLabel={toggleSiteLabel}
|
||||
selectedLabels={localLabels}
|
||||
onToggleLabel={toggleLabel}
|
||||
onClosePopover={() => startTransition(refresh)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<TableLabelsCell
|
||||
<LabelsTableCell
|
||||
orgId={orgId}
|
||||
localLabels={localLabels}
|
||||
toggleLabel={toggleSiteLabel}
|
||||
selectedLabels={localLabels}
|
||||
onToggleLabel={toggleLabel}
|
||||
onClosePopover={() => startTransition(refresh)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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?.();
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Checkbox } from "../ui/checkbox";
|
||||
|
||||
export type TagValue = { text: string; id: string; isAdmin?: boolean };
|
||||
|
||||
@@ -70,13 +71,11 @@ export function MultiSelectContent<T extends TagValue>({
|
||||
onChange(newValues);
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedValues.has(option.id)
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
<Checkbox
|
||||
className="pointer-events-none shrink-0"
|
||||
checked={selectedValues.has(option.id)}
|
||||
aria-hidden
|
||||
tabIndex={-1}
|
||||
/>
|
||||
{`${option.text}`}
|
||||
</CommandItem>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useSearchParams, usePathname, useRouter } from "next/navigation";
|
||||
import { useTransition } from "react";
|
||||
import { useCallback, useMemo, useTransition } from "react";
|
||||
|
||||
export function useNavigationContext() {
|
||||
const router = useRouter();
|
||||
@@ -7,29 +7,38 @@ export function useNavigationContext() {
|
||||
const path = usePathname();
|
||||
const [isNavigating, startTransition] = useTransition();
|
||||
|
||||
function navigate({
|
||||
searchParams: params,
|
||||
pathname = path,
|
||||
replace = false
|
||||
}: {
|
||||
pathname?: string;
|
||||
searchParams?: URLSearchParams;
|
||||
replace?: boolean;
|
||||
}) {
|
||||
startTransition(() => {
|
||||
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
|
||||
};
|
||||
|
||||
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