diff --git a/src/app/(app)/settings/pipelines/[pipelineId]/page.tsx b/src/app/(app)/settings/pipelines/[pipelineId]/page.tsx index ac8c00b..57a4989 100644 --- a/src/app/(app)/settings/pipelines/[pipelineId]/page.tsx +++ b/src/app/(app)/settings/pipelines/[pipelineId]/page.tsx @@ -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 (
@@ -199,6 +221,18 @@ export default function PipelineEditorPage({ params }: PageProps) { )}
+ + + + +
); } diff --git a/src/lib/services/pipeline-template-service.ts b/src/lib/services/pipeline-template-service.ts index 93fe616..ccc8c11 100644 --- a/src/lib/services/pipeline-template-service.ts +++ b/src/lib/services/pipeline-template-service.ts @@ -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(); 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" } } },