Pipeline editor: rework paths (pass/fail loops) now configurable in the browser

Admins can draw backward transitions on the same ReactFlow canvas
that already handles forward dependencies. With this, the pipeline
editor is a full workflow builder — new pipelines for new clients
can be assembled in the browser without code changes.

How it works:
- "Workflow Graph" panel now has a mode toggle: Dependencies /
  Rework paths. Both edge types always render — the toggle just
  scopes what dragging between stages creates.
- Forward (solid) and rework (dashed red) edges coexist on the
  canvas; arrows point source→target in both cases.
- Drag validation: rework drops where target.order >= source.order
  are rejected client-side (silent — a round-trip error would just
  confirm the same thing).
- Click the × on any edge to delete it.

Plumbing:
- pipeline-template-service: addReworkPath / removeReworkPath with
  same-pipeline + not-self + strictly-backward checks.
  getPipelineTemplate includes reworkFrom + reworkTo so the canvas
  renders reworks alongside dependencies without a second fetch.
- POST/DELETE /api/pipelines/:id/reworks
- useAddRework / useRemoveRework hooks
- New ReworkEdge component — dashed, red, curvier arc to keep
  pass/fail visually distinct from the sequential forward flow
This commit is contained in:
DJP 2026-04-21 13:19:43 -04:00
parent 9ff0f03a4d
commit f89fb73aff
6 changed files with 392 additions and 22 deletions

View file

@ -2,7 +2,7 @@
import { use, useState } from "react";
import { useRouter } from "next/navigation";
import { ArrowLeft, GitBranch, Plus, Star } from "lucide-react";
import { ArrowLeft, 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";
@ -23,11 +23,13 @@ import {
useReorderStages,
useAddDependency,
useRemoveDependency,
useAddRework,
useRemoveRework,
usePipelineValidation,
} from "@/hooks/use-pipelines";
import { usePipelineBuilderStore } from "@/stores/pipeline-builder-store";
import { PipelineStageList } from "@/components/pipeline-builder/pipeline-stage-list";
import { PipelineGraph } from "@/components/pipeline-builder/pipeline-graph";
import { PipelineGraph, type PipelineGraphMode } from "@/components/pipeline-builder/pipeline-graph";
import { StageEditSheet } from "@/components/pipeline-builder/stage-edit-sheet";
import { PipelineValidationBanner } from "@/components/pipeline-builder/pipeline-validation-banner";
import { toast } from "sonner";
@ -47,10 +49,16 @@ export default function PipelineEditorPage({ params }: PageProps) {
const reorderStages = useReorderStages(pipelineId);
const addDependency = useAddDependency(pipelineId);
const removeDependency = useRemoveDependency(pipelineId);
const addRework = useAddRework(pipelineId);
const removeRework = useRemoveRework(pipelineId);
const { selectedStageId, selectStage } = usePipelineBuilderStore();
const [showAddStage, setShowAddStage] = useState(false);
const [newStageName, setNewStageName] = 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.
const [graphMode, setGraphMode] = useState<PipelineGraphMode>("dependency");
const pl = pipeline as any;
const stages = (pl?.stages ?? []) as any[];
@ -119,6 +127,24 @@ export default function PipelineEditorPage({ params }: PageProps) {
}
};
const handleConnectRework = async (fromStageId: string, toStageId: string) => {
try {
await addRework.mutateAsync({ fromStageId, toStageId });
toast.success("Rework path added");
} catch (e: any) {
toast.error(e.message || "Failed to add rework path");
}
};
const handleDeleteRework = async (fromStageId: string, toStageId: string) => {
try {
await removeRework.mutateAsync({ fromStageId, toStageId });
toast.success("Rework path removed");
} catch (e: any) {
toast.error(e.message || "Failed to remove rework path");
}
};
const handleToggleDefault = async () => {
try {
await updatePipeline.mutateAsync({ isDefault: !pl?.isDefault });
@ -217,18 +243,52 @@ export default function PipelineEditorPage({ params }: PageProps) {
/>
</div>
{/* Right: Dependency graph */}
{/* Right: Dependency + rework graph */}
<div className="rounded-lg border bg-[var(--card)] overflow-hidden" style={{ minHeight: 500 }}>
<div className="border-b px-3 py-2">
<div className="flex items-center justify-between border-b px-3 py-2">
<span className="label-upper text-[10px] font-semibold tracking-wider text-[var(--muted-foreground)]">
Dependency Graph
Workflow Graph
</span>
<div className="flex items-center gap-0.5 rounded-md border bg-[var(--muted)]/40 p-0.5">
<Button
type="button"
size="sm"
variant={graphMode === "dependency" ? "default" : "ghost"}
className="h-6 gap-1 px-2 text-[10px]"
onClick={() => setGraphMode("dependency")}
title="Drag between stages to create forward dependencies"
>
<Workflow className="h-3 w-3" />
Dependencies
</Button>
<Button
type="button"
size="sm"
variant={graphMode === "rework" ? "default" : "ghost"}
className="h-6 gap-1 px-2 text-[10px]"
onClick={() => setGraphMode("rework")}
title="Drag backward (higher → lower order) to allow pushing work back on failure"
>
<RotateCcw className="h-3 w-3" />
Rework paths
</Button>
</div>
</div>
<div style={{ height: 460 }}>
<div className="border-b bg-[var(--muted)]/20 px-3 py-1.5 text-[10px] text-[var(--muted-foreground)]">
{graphMode === "dependency" ? (
<>Drag from one stage to another to add a <strong>forward prerequisite</strong>. Click the × on a solid line to remove.</>
) : (
<>Drag from a later stage to an earlier one to add a <strong>rework (pushback) path</strong> e.g., Final Approval In Progress. Drawn as dashed red. Click × to remove.</>
)}
</div>
<div style={{ height: 440 }}>
<PipelineGraph
stages={stages}
mode={graphMode}
onConnect={handleConnect}
onDeleteEdge={handleDeleteEdge}
onConnectRework={handleConnectRework}
onDeleteRework={handleDeleteRework}
onSelectStage={selectStage}
/>
</div>

View file

@ -0,0 +1,67 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { badRequest, serverError } from "@/lib/api-utils";
import { requireAuth } from "@/lib/rbac/require-auth";
import {
addReworkPath,
removeReworkPath,
} from "@/lib/services/pipeline-template-service";
// Body for POST — both stage ids must belong to this pipeline.
// Direction is validated in the service (toStage.order < fromStage.order).
const bodySchema = z.object({
fromStageId: z.string().min(1),
toStageId: z.string().min(1),
});
// POST — add a rework (pushback) path between stages
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ pipelineId: string }> }
) {
const { session, error } = await requireAuth("PIPELINE_MANAGE");
if (error) return error;
try {
const { pipelineId } = await params;
const body = await req.json();
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return badRequest(parsed.error.issues.map((i) => i.message).join(", "));
}
const rework = await addReworkPath(
pipelineId,
parsed.data.fromStageId,
parsed.data.toStageId,
session.user.organizationId
);
return NextResponse.json(rework, { status: 201 });
} catch (e) {
return serverError(e);
}
}
// DELETE — remove a rework path
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ pipelineId: string }> }
) {
const { session, error } = await requireAuth("PIPELINE_MANAGE");
if (error) return error;
try {
const { pipelineId } = await params;
const body = await req.json();
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return badRequest(parsed.error.issues.map((i) => i.message).join(", "));
}
await removeReworkPath(
pipelineId,
parsed.data.fromStageId,
parsed.data.toStageId,
session.user.organizationId
);
return NextResponse.json({ success: true });
} catch (e) {
return serverError(e);
}
}

View file

@ -17,6 +17,9 @@ import "@xyflow/react/dist/style.css";
import { StageNode } from "./stage-node";
import { DependencyEdge } from "./dependency-edge";
import { ReworkEdge } from "./rework-edge";
export type PipelineGraphMode = "dependency" | "rework";
interface PipelineGraphProps {
stages: Array<{
@ -28,14 +31,23 @@ interface PipelineGraphProps {
isOptional: boolean;
color: string | null;
dependsOn: Array<{ prerequisiteId: string }>;
/** Rework edges originating at this stage `toStageId` is the
* pushback target (must have a lower `order`). Included in the
* getPipelineTemplate response. */
reworkFrom?: Array<{ toStageId: string }>;
}>;
/** Which edge type the user is currently drawing. Both types are
* always rendered this just scopes drag behaviour. */
mode: PipelineGraphMode;
onConnect: (stageId: string, prerequisiteId: string) => void;
onDeleteEdge: (stageId: string, prerequisiteId: string) => void;
onConnectRework: (fromStageId: string, toStageId: string) => void;
onDeleteRework: (fromStageId: string, toStageId: string) => void;
onSelectStage: (id: string) => void;
}
const nodeTypes = { stage: StageNode };
const edgeTypes = { dependency: DependencyEdge };
const edgeTypes = { dependency: DependencyEdge, rework: ReworkEdge };
function buildLayout(
stages: PipelineGraphProps["stages"]
@ -111,7 +123,7 @@ function buildLayout(
for (const stage of sorted) {
for (const dep of stage.dependsOn) {
edges.push({
id: `${dep.prerequisiteId}->${stage.id}`,
id: `dep:${dep.prerequisiteId}->${stage.id}`,
source: dep.prerequisiteId,
target: stage.id,
type: "dependency",
@ -121,6 +133,20 @@ function buildLayout(
},
});
}
// Rework edges — source is the "from" stage (where work currently
// sits), target is the stage we're pushing back to.
for (const rw of stage.reworkFrom ?? []) {
edges.push({
id: `rew:${stage.id}->${rw.toStageId}`,
source: stage.id,
target: rw.toStageId,
type: "rework",
data: {
fromStageId: stage.id,
toStageId: rw.toStageId,
},
});
}
}
return { nodes, edges };
@ -128,8 +154,11 @@ function buildLayout(
function PipelineGraphInner({
stages,
mode,
onConnect,
onDeleteEdge,
onConnectRework,
onDeleteRework,
onSelectStage,
}: PipelineGraphProps) {
const { nodes: layoutNodes, edges: layoutEdges } = useMemo(
@ -137,20 +166,38 @@ function PipelineGraphInner({
[stages]
);
// Delete handlers vary by edge type — bind each edge's onDelete to
// the right callback so the UI doesn't need to know which type it's
// looking at.
const edgesWithHandlers = useMemo(
() =>
layoutEdges.map((edge) => ({
...edge,
data: {
...edge.data,
onDelete: () =>
onDeleteEdge(
edge.data?.stageId as string,
edge.data?.prerequisiteId as string
),
},
})),
[layoutEdges, onDeleteEdge]
layoutEdges.map((edge) => {
if (edge.type === "rework") {
return {
...edge,
data: {
...edge.data,
onDelete: () =>
onDeleteRework(
edge.data?.fromStageId as string,
edge.data?.toStageId as string
),
},
};
}
return {
...edge,
data: {
...edge.data,
onDelete: () =>
onDeleteEdge(
edge.data?.stageId as string,
edge.data?.prerequisiteId as string
),
},
};
}),
[layoutEdges, onDeleteEdge, onDeleteRework]
);
const [nodes, setNodes, onNodesChange] = useNodesState(layoutNodes);
@ -166,13 +213,33 @@ function PipelineGraphInner({
requestAnimationFrame(() => fitView({ padding: 0.3 }));
}, [layoutNodes, edgesWithHandlers, setNodes, setEdges, fitView]);
// Stage order lookup for client-side rework direction check — we
// still validate on the server, but rejecting a bad drag locally
// gives instant feedback rather than a round-trip error toast.
const orderById = useMemo(
() => new Map(stages.map((s) => [s.id, s.order])),
[stages]
);
const handleConnect = useCallback(
(connection: Connection) => {
if (connection.source && connection.target) {
if (!connection.source || !connection.target) return;
if (mode === "rework") {
const srcOrder = orderById.get(connection.source) ?? -1;
const tgtOrder = orderById.get(connection.target) ?? -1;
if (tgtOrder >= srcOrder) {
// Silent reject — rework must go backward. The server would
// return the same error; this keeps the UI snappy.
return;
}
onConnectRework(connection.source, connection.target);
} else {
// Dependency: source is the prerequisite, target is the stage
// that depends on it (preserves the original signature).
onConnect(connection.target, connection.source);
}
},
[onConnect]
[mode, onConnect, onConnectRework, orderById]
);
const handleNodeClick = useCallback(

View file

@ -0,0 +1,87 @@
"use client";
/**
* Rework edge (backward/rejection path) for the pipeline canvas.
*
* Visually distinct from dependency edges dashed, primary-accent
* colored, curved wider so it reads as a loop rather than a sequence.
* Arrow still points toward the target (the stage we push back to),
* which matches ReactFlow's sourcetarget direction convention.
*/
import {
BaseEdge,
EdgeLabelRenderer,
getBezierPath,
type EdgeProps,
} from "@xyflow/react";
import { X } from "lucide-react";
interface ReworkEdgeData {
onDelete?: () => void;
[key: string]: unknown;
}
export function ReworkEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
data,
markerEnd,
}: EdgeProps) {
// Give rework edges a steeper curve so they arc over the top/bottom
// of the canvas rather than overlapping the forward edges — visual
// separation makes pass vs fail paths readable at a glance.
const [edgePath, labelX, labelY] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
curvature: 0.6,
});
const edgeData = data as ReworkEdgeData | undefined;
// Red-ish hue signals "this is a rejection / backward loop" without
// clashing with the theme's brand (Dow navy) or error (destructive).
const stroke = "#DC2626";
return (
<>
<path d={edgePath} fill="none" stroke="transparent" strokeWidth={20} />
<BaseEdge
id={id}
path={edgePath}
markerEnd={markerEnd}
style={{
stroke,
strokeWidth: 2,
strokeDasharray: "6 4",
}}
/>
{edgeData?.onDelete && (
<EdgeLabelRenderer>
<button
type="button"
className="nodrag nopan absolute flex items-center justify-center size-5 rounded-full bg-[var(--muted)] border shadow-sm transition-colors hover:bg-destructive hover:text-white hover:border-destructive focus-visible:outline-none focus-visible:ring-2"
style={{
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
pointerEvents: "all",
borderColor: stroke,
color: stroke,
}}
onClick={edgeData.onDelete}
aria-label="Delete rework path"
>
<X className="size-3" />
</button>
</EdgeLabelRenderer>
)}
</>
);
}

View file

@ -160,6 +160,26 @@ export function useRemoveDependency(pipelineId: string) {
});
}
// ─── Rework (backward) paths ────────────────────────────
export function useAddRework(pipelineId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { fromStageId: string; toStageId: string }) =>
jsonPost(`/api/pipelines/${pipelineId}/reworks`, data),
onSuccess: () => qc.invalidateQueries({ queryKey: ["pipelines", pipelineId] }),
});
}
export function useRemoveRework(pipelineId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { fromStageId: string; toStageId: string }) =>
jsonDelete(`/api/pipelines/${pipelineId}/reworks`, data),
onSuccess: () => qc.invalidateQueries({ queryKey: ["pipelines", pipelineId] }),
});
}
// ─── Validation ─────────────────────────────────────────
export function usePipelineValidation(pipelineId: string) {

View file

@ -29,6 +29,10 @@ export async function getPipelineTemplate(id: string, orgId: string) {
include: {
dependsOn: { include: { prerequisite: true } },
dependedBy: { include: { stage: true } },
// Rework edges are read alongside forward deps so the canvas
// can render both without a second round-trip.
reworkFrom: { include: { toStage: true } },
reworkTo: { include: { fromStage: true } },
},
},
_count: { select: { projects: true } },
@ -327,6 +331,71 @@ export async function removeDependency(
});
}
// ─── Rework (backward) paths ────────────────────────────
//
// A rework edge says "from stage X you may push a deliverable back to
// stage Y". Unlike dependsOn (which is about forward prerequisites),
// reworks are strictly backward and opt-in — no rework edge = no
// backward drag allowed on the board.
export async function addReworkPath(
pipelineId: string,
fromStageId: string,
toStageId: string,
orgId: string
) {
const pipeline = await prisma.pipelineTemplate.findFirst({
where: { id: pipelineId, organizationId: orgId },
select: { id: true },
});
if (!pipeline) throw new Error("Pipeline template not found");
if (fromStageId === toStageId) {
throw new Error("A stage can't rework back to itself");
}
// Both stages must live on this pipeline, and target must come
// earlier (lower order). Rework is explicitly backward; forward is
// what dependsOn is for.
const stages = await prisma.pipelineStageDefinition.findMany({
where: { pipelineId, id: { in: [fromStageId, toStageId] } },
select: { id: true, order: true, name: true },
});
if (stages.length !== 2) {
throw new Error("Both stages must belong to this pipeline");
}
const fromStage = stages.find((s) => s.id === fromStageId)!;
const toStage = stages.find((s) => s.id === toStageId)!;
if (toStage.order >= fromStage.order) {
throw new Error(
`Rework target must come before the source (${toStage.name} is not before ${fromStage.name})`
);
}
return prisma.pipelineStageRework.create({
data: { fromStageId, toStageId },
});
}
export async function removeReworkPath(
pipelineId: string,
fromStageId: string,
toStageId: string,
orgId: string
) {
const pipeline = await prisma.pipelineTemplate.findFirst({
where: { id: pipelineId, organizationId: orgId },
select: { id: true },
});
if (!pipeline) throw new Error("Pipeline template not found");
return prisma.pipelineStageRework.delete({
where: {
fromStageId_toStageId: { fromStageId, toStageId },
},
});
}
// ─── Validation ─────────────────────────────────────────
export interface PipelineValidationResult {