Pipeline clone copies reworks + editor gets a Duplicate button

duplicatePipelineTemplate was written before the PipelineStageRework
table existed, so cloning a pipeline silently lost every declared
pass/fail pushback rule. Board-drag rework on the clone would then
reject every backward move because no rework edges existed. Now
copies reworkFrom edges alongside dependsOn, reusing the old→new
stage-id map so remapping is free.

Also added a "Duplicate" button to the pipeline editor header
(next to "Set Default") so admins editing a pipeline can fork and
edit the copy without going back to the /settings/pipelines list.
On success we push into the new clone — the admin's next action
is almost always "tweak the copy", no need to make them navigate.

Existing "Duplicate" button on the list page (per-card hover)
stays — this just adds a second entry point.
This commit is contained in:
DJP 2026-04-21 20:15:52 -04:00
parent d5d8c7560a
commit 28b30c60b4
2 changed files with 96 additions and 4 deletions

View file

@ -2,7 +2,7 @@
import { use, useState } from "react";
import { useRouter } from "next/navigation";
import { ArrowLeft, GitBranch, Plus, Star, Workflow, RotateCcw } from "lucide-react";
import { ArrowLeft, Copy, GitBranch, Plus, Star, Workflow, RotateCcw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
@ -25,6 +25,7 @@ import {
useRemoveDependency,
useAddRework,
useRemoveRework,
useDuplicatePipeline,
usePipelineValidation,
} from "@/hooks/use-pipelines";
import { usePipelineBuilderStore } from "@/stores/pipeline-builder-store";
@ -51,10 +52,13 @@ export default function PipelineEditorPage({ params }: PageProps) {
const removeDependency = useRemoveDependency(pipelineId);
const addRework = useAddRework(pipelineId);
const removeRework = useRemoveRework(pipelineId);
const duplicatePipeline = useDuplicatePipeline();
const { selectedStageId, selectStage } = usePipelineBuilderStore();
const [showAddStage, setShowAddStage] = useState(false);
const [newStageName, setNewStageName] = useState("");
const [showDuplicate, setShowDuplicate] = useState(false);
const [dupName, setDupName] = useState("");
// Which edge type dragging between stage handles will create.
// Both edge types are ALWAYS rendered on the canvas so the full
// workflow is visible; this just scopes the drag target.
@ -154,6 +158,24 @@ export default function PipelineEditorPage({ params }: PageProps) {
}
};
const handleDuplicate = async () => {
if (!dupName.trim()) return;
try {
const result = (await duplicatePipeline.mutateAsync({
pipelineId,
name: dupName.trim(),
})) as any;
setShowDuplicate(false);
setDupName("");
toast.success("Pipeline duplicated");
// Jump straight into the new clone so editing flows naturally
// from "clone this and tweak" without a trip back to the list.
if (result?.id) router.push(`/settings/pipelines/${result.id}`);
} catch (e: any) {
toast.error(e.message || "Failed to duplicate");
}
};
if (isLoading) {
return (
<div className="space-y-4">
@ -199,6 +221,18 @@ export default function PipelineEditorPage({ params }: PageProps) {
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => {
setDupName(`${pl.name} (copy)`);
setShowDuplicate(true);
}}
>
<Copy className="mr-1 h-3 w-3" />
Duplicate
</Button>
<Button
variant="outline"
size="sm"
@ -328,6 +362,42 @@ export default function PipelineEditorPage({ params }: PageProps) {
</DialogFooter>
</DialogContent>
</Dialog>
{/* Duplicate pipeline dialog. On success we route into the new
clone so the admin's next action ("tweak the copy") stays on
the same screen. */}
<Dialog open={showDuplicate} onOpenChange={setShowDuplicate}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Duplicate Pipeline</DialogTitle>
</DialogHeader>
<Input
placeholder="New pipeline name"
value={dupName}
onChange={(e) => setDupName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleDuplicate()}
className="h-8 text-sm"
autoFocus
/>
<p className="text-[12px] text-[var(--muted-foreground)]">
Copies every stage, forward dependency, and rework path.
The new pipeline starts NOT default flip it on in the
duplicated editor when you're ready.
</p>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={() => setShowDuplicate(false)}>
Cancel
</Button>
<Button
size="sm"
onClick={handleDuplicate}
disabled={!dupName.trim() || duplicatePipeline.isPending}
>
{duplicatePipeline.isPending ? "Duplicating…" : "Duplicate"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -90,7 +90,13 @@ export async function duplicatePipelineTemplate(
where: { id, organizationId: orgId },
include: {
stages: {
include: { dependsOn: true },
include: {
dependsOn: true,
// reworkFrom = edges where this stage is the source (i.e. you
// can push BACK from here to some earlier stage). We copy
// once per source and let the reverse side fall out naturally.
reworkFrom: true,
},
orderBy: { order: "asc" },
},
},
@ -107,7 +113,8 @@ export async function duplicatePipelineTemplate(
},
});
// Copy stages
// Copy stages first; keep the old → new id map so we can remap
// both dependency and rework edges below without re-querying.
const oldToNewStageId = new Map<string, string>();
for (const stage of source.stages) {
const newStage = await tx.pipelineStageDefinition.create({
@ -127,7 +134,7 @@ export async function duplicatePipelineTemplate(
oldToNewStageId.set(stage.id, newStage.id);
}
// Copy dependencies
// Copy forward dependencies (dependsOn graph)
for (const stage of source.stages) {
for (const dep of stage.dependsOn) {
const newStageId = oldToNewStageId.get(dep.stageId);
@ -140,6 +147,21 @@ export async function duplicatePipelineTemplate(
}
}
// Copy rework (backward / pass-fail) paths. Without this a cloned
// pipeline lost all its declared pushback rules, which meant
// board-drag rework would silently reject on the clone.
for (const stage of source.stages) {
for (const rw of stage.reworkFrom) {
const newFromId = oldToNewStageId.get(rw.fromStageId);
const newToId = oldToNewStageId.get(rw.toStageId);
if (newFromId && newToId) {
await tx.pipelineStageRework.create({
data: { fromStageId: newFromId, toStageId: newToId },
});
}
}
}
return tx.pipelineTemplate.findUnique({
where: { id: newPipeline.id },
include: { stages: { orderBy: { order: "asc" } } },