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:
parent
d5d8c7560a
commit
28b30c60b4
2 changed files with 96 additions and 4 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" } } },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue