Files
kandev/apps/web/components/integrations/integration-copy-config-menu.tsx
T

206 lines
6.3 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { IconCopy } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@kandev/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useToast } from "@/components/toast-provider";
import {
copyIntegrationConfig,
integrationLabel,
type IntegrationSlug,
} from "./integration-copy-config";
type Workspace = { id: string; name: string };
type IntegrationCopyConfigMenuProps = {
slug: IntegrationSlug;
sourceWorkspaceId: string;
workspaces: Workspace[];
};
function workspaceName(workspaces: Workspace[], id: string | null): string {
return workspaces.find((w) => w.id === id)?.name ?? "";
}
function CopyConfigDialogBody({
label,
slug,
sourceName,
targets,
targetId,
setTargetId,
copying,
onCopy,
onCancel,
}: {
label: string;
slug: IntegrationSlug;
sourceName: string;
targets: Workspace[];
targetId: string | null;
setTargetId: (id: string) => void;
copying: boolean;
onCopy: () => void;
onCancel: () => void;
}) {
return (
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Copy {label} configuration</DialogTitle>
<DialogDescription>
Copy the {label} settings
{slug === "github" ? "" : " and credentials"} from{" "}
<span className="font-medium text-foreground">{sourceName}</span> into another workspace.
This overwrites the target workspace&apos;s current {label} configuration. Watchers and
automations are not copied.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<label htmlFor="copy-config-target" className="text-xs font-medium text-muted-foreground">
Target workspace
</label>
<Select value={targetId ?? undefined} onValueChange={setTargetId}>
<SelectTrigger
id="copy-config-target"
className="w-full"
data-testid="integration-copy-config-target"
>
<SelectValue placeholder="Select a workspace…" />
</SelectTrigger>
<SelectContent>
{targets.map((workspace) => (
<SelectItem key={workspace.id} value={workspace.id}>
{workspace.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button type="button" variant="outline" className="cursor-pointer" onClick={onCancel}>
Cancel
</Button>
<Button
type="button"
className="cursor-pointer"
disabled={!targetId || copying}
onClick={onCopy}
data-dialog-default-action
data-testid="integration-copy-config-confirm"
>
<IconCopy className="h-4 w-4" />
Copy config
</Button>
</DialogFooter>
</DialogContent>
);
}
// IntegrationCopyConfigMenu renders an icon-only button next to the workspace
// switcher. Clicking it opens a dialog that explains what copying does and lets
// the user pick a target workspace to copy the current integration's config
// (and credentials, except GitHub) into.
export function IntegrationCopyConfigMenu({
slug,
sourceWorkspaceId,
workspaces,
}: IntegrationCopyConfigMenuProps) {
const { toast, updateToast } = useToast();
const [open, setOpen] = useState(false);
const [copying, setCopying] = useState(false);
const [targetId, setTargetId] = useState<string | null>(null);
const label = integrationLabel(slug);
const targets = useMemo(
() => workspaces.filter((w) => w.id !== sourceWorkspaceId),
[workspaces, sourceWorkspaceId],
);
const sourceName = workspaceName(workspaces, sourceWorkspaceId);
// Clear a selected target that is no longer valid — e.g. the user switched the
// editing workspace (making the old source a valid target, or the old target
// the new source) while a target was already picked.
useEffect(() => {
if (targetId && !targets.some((w) => w.id === targetId)) {
setTargetId(null);
}
}, [targetId, targets]);
const onOpenChange = useCallback((next: boolean) => {
setOpen(next);
// Reset the selection when the dialog closes (ESC, backdrop, or Cancel) so
// reopening doesn't pre-select a previously chosen target.
if (!next) setTargetId(null);
}, []);
const onCopy = useCallback(async () => {
if (!targetId) return;
const targetName = workspaceName(workspaces, targetId);
setCopying(true);
const pendingId = toast({
variant: "loading",
description: `Copying ${label} config to ${targetName}…`,
});
try {
await copyIntegrationConfig(slug, sourceWorkspaceId, targetId);
updateToast(pendingId, {
variant: "success",
description: `Copied ${label} config to ${targetName}.`,
});
setOpen(false);
setTargetId(null);
} catch (err) {
updateToast(pendingId, {
variant: "error",
description: `Failed to copy config: ${String(err)}`,
});
} finally {
setCopying(false);
}
}, [slug, sourceWorkspaceId, targetId, workspaces, label, toast, updateToast]);
if (targets.length === 0) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="outline"
size="icon-lg"
className="cursor-pointer"
aria-label="Copy config to another workspace"
data-testid="integration-copy-config-trigger"
onClick={() => setOpen(true)}
>
<IconCopy className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Copy config to another workspace</TooltipContent>
</Tooltip>
<CopyConfigDialogBody
label={label}
slug={slug}
sourceName={sourceName}
targets={targets}
targetId={targetId}
setTargetId={setTargetId}
copying={copying}
onCopy={() => void onCopy()}
onCancel={() => onOpenChange(false)}
/>
</Dialog>
);
}