From aba0878833ba3d9fa93d9cda827624dcf696ca90 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Fri, 12 Jun 2026 09:51:11 +0200 Subject: [PATCH] feat: replace the per-node amber if-dot with a single output dot that opens a drag-drop create menu (email, action, or condition) on release, and add a Condition router node (kind wait) you can chain into nested decision trees --- .../app/campaigns/sequences/CampaignFlow.tsx | 205 ++++++++++++++++-- .../app/campaigns/sequences/Sequence.ts | 9 +- 2 files changed, 189 insertions(+), 25 deletions(-) diff --git a/web/src/components/app/campaigns/sequences/CampaignFlow.tsx b/web/src/components/app/campaigns/sequences/CampaignFlow.tsx index 2e23bdcd..481279a0 100644 --- a/web/src/components/app/campaigns/sequences/CampaignFlow.tsx +++ b/web/src/components/app/campaigns/sequences/CampaignFlow.tsx @@ -14,6 +14,7 @@ // - Nothing connects automatically; a step with no outgoing path ends in STOP. import React from "react"; +import { createPortal } from "react-dom"; import { ArrowRightLeftIcon, BellOffIcon, @@ -317,8 +318,7 @@ function StepNode({ data, selected }: NodeProps) { On reply - {/* Right dot = start an "if" branch; bottom dot = plain "go there". */} - + {/* One output dot: drag it out to add the next step, action, or condition. */} ); @@ -472,14 +472,55 @@ function ActionNode({ data, selected }: NodeProps) { Ends here ) : null} - {/* Same handles as a step: right = "if" branch, bottom = "go there". */} - + {/* One output dot: drag it out to add the next step, action, or condition. */} ); } -const nodeTypes = { step: StepNode, ifcond: IfNode, stop: StopNode, action: ActionNode }; +type ConditionNodeData = { label: string; endsHere: boolean; orphan: boolean; onDelete: () => void }; + +// A Condition node is a pure router (no email, no action): it just branches. +// Drag from it to add conditional paths, and chain Condition nodes to build +// nested decision trees. Persisted as a no-op step (kind "wait"). +function ConditionNode({ data, selected }: NodeProps) { + const d = data as ConditionNodeData; + return ( +
+ +
+ + + + + {d.label || "Condition"} + + +
+
+ {d.endsHere ? "Drag out to add the branches" : "Routes by your conditions"} +
+ {/* One output dot: drag out to add each conditional path. */} + +
+ ); +} + +const nodeTypes = { step: StepNode, ifcond: IfNode, stop: StopNode, action: ActionNode, condition: ConditionNode }; // Convergent edge. // Several branches can route to the same next step (many in -> one node). They @@ -561,6 +602,9 @@ export default function CampaignFlow({ campaignId }: { campaignId: string }) { const [selectedEdge, setSelectedEdge] = React.useState<{ sourceId: string; branchId: string } | null>(null); const [editStepId, setEditStepId] = React.useState(null); const [adding, setAdding] = React.useState(false); + // When you drag a node's dot out to empty canvas, we open a create menu at + // the drop point instead of immediately making an email step. + const [dragCreate, setDragCreate] = React.useState<{ x: number; y: number; sourceId: string } | null>(null); // While dragging a node, kill the position transition so the drag is 1:1. const [dragging, setDragging] = React.useState(false); // Editing the sequence flow (add a step, drag a node, draw a branch) needs @@ -796,28 +840,48 @@ export default function CampaignFlow({ campaignId }: { campaignId: string }) { }, [adding, sequences.length, seqById, createSequence, saveBranches], ); - // Drag from an IF block's side to empty -> new step + new if-branch. - const addIfToNew = React.useCallback( + // Drag out -> new action step of the chosen type, connected unconditionally. + const dragOutAction = React.useCallback( + async (sourceId: string, type: SequenceActionType) => { + if (adding || sequences.length >= MAX_STEPS) return; + const src = seqById.get(sourceId); + setAdding(true); + try { + const created = (await createSequence.mutateAsync()) as Sequence; + await updateSequence(campaignId, created.id, { kind: "action", action: defaultActionFor(type) }); + await saveBranches(sourceId, [ + ...(src?.conditions?.branches ?? []), + { branch_id: newBranchId(), target_step_id: created.id, conditions: [] }, + ]); + } catch { + toast.error("Couldn't add the action"); + } finally { + setAdding(false); + } + }, + [adding, sequences.length, seqById, createSequence, campaignId, saveBranches], + ); + // Drag out -> a Condition router node (no-op step) you branch from. Chain + // these to build nested decision trees. + const dragOutCondition = React.useCallback( async (sourceId: string) => { if (adding || sequences.length >= MAX_STEPS) return; const src = seqById.get(sourceId); setAdding(true); try { const created = (await createSequence.mutateAsync()) as Sequence; - const branch: SequenceBranch = { - branch_id: newBranchId(), - target_step_id: created.id, - conditions: [{ field: "opened", operator: "within_days", value: 3 }], - }; - await saveBranches(sourceId, [...(src?.conditions?.branches ?? []), branch]); - openCondition(sourceId, branch.branch_id); + await updateSequence(campaignId, created.id, { kind: "wait", name: "Condition" }); + await saveBranches(sourceId, [ + ...(src?.conditions?.branches ?? []), + { branch_id: newBranchId(), target_step_id: created.id, conditions: [] }, + ]); } catch { - toast.error("Couldn't add the step"); + toast.error("Couldn't add the condition"); } finally { setAdding(false); } }, - [adding, sequences.length, seqById, createSequence, saveBranches, openCondition], + [adding, sequences.length, seqById, createSequence, campaignId, saveBranches], ); // "On reply" on a step: create a new action step + a reply branch that fires // it the moment the contact replies, then open the branch so you can pick the @@ -943,6 +1007,20 @@ export default function CampaignFlow({ campaignId }: { campaignId: string }) { const allNodes: Node[] = sequences.map((s, i) => { const branches = s.conditions?.branches ?? []; const isAction = s.kind !== "email"; + if (s.kind === "wait") { + // Pure router: a Condition node that only branches. + return { + id: s.id, + type: "condition", + position: { x: 0, y: 0 }, + data: { + label: s.name?.trim() || "Condition", + endsHere: branches.length === 0, + orphan: !reachable.has(s.id), + onDelete: () => deleteStepRef.current(s.id), + } satisfies ConditionNodeData, + }; + } if (isAction) { const at = s.action?.type ?? "add_tag"; const fallback = ACTION_META[at]?.label ?? "Action"; @@ -1270,9 +1348,13 @@ export default function CampaignFlow({ campaignId }: { campaignId: string }) { preventScrolling={false} minZoom={0.2} maxZoom={1.75} - onConnectEnd={(_, state) => { + onConnectEnd={(event, state) => { const from = state.fromNode; if (!from || state.toNode) return; + if (!canEditFlow) { + showPermissionDenied("MANAGE_SEQUENCES"); + return; + } const handle = state.fromHandle?.id; if (isIfId(from.id)) { const m = ifMetaRef.current[from.id]; @@ -1281,10 +1363,13 @@ export default function CampaignFlow({ campaignId }: { campaignId: string }) { // step reached unconditionally ("always / just go there"). if (handle === "out") retargetToNew(m.sourceId, m.branchId); else dragOutStep(m.sourceId); - } else if (handle === "if") { - addIfToNew(from.id); } else { - dragOutStep(from.id); + // Open the create menu at the drop point: email / action / condition. + const pt = + "changedTouches" in event && event.changedTouches.length + ? event.changedTouches[0] + : (event as MouseEvent); + setDragCreate({ x: pt.clientX, y: pt.clientY, sourceId: from.id }); } }} onReconnect={(oldEdge, conn) => { @@ -1360,12 +1445,27 @@ export default function CampaignFlow({ campaignId }: { campaignId: string }) { canvas (touch users remove edges via the editor's Disconnect). */}
- step: bottom dot = go there, right (amber) dot = add an “if” · IF block: right dot = then, bottom (gray) dot = else / just go there · click a line then press Delete to remove it · no match = stop + drag a node’s bottom dot onto another node to connect, or onto empty space to pick what to add (email, action, or a condition) · click a line to set its condition · add Condition nodes to branch, and chain them for nested trees · click a line then press Delete to remove it · no match = stop
+ {dragCreate && ( + setDragCreate(null)} + onPick={(choice) => { + const sid = dragCreate.sourceId; + setDragCreate(null); + if (choice === "email") dragOutStep(sid); + else if (choice === "condition") dragOutCondition(sid); + else dragOutAction(sid, choice); + }} + /> + )} + {selected && ( void; + onClose: () => void; +}) { + const vw = typeof window !== "undefined" ? window.innerWidth : x + 240; + const vh = typeof window !== "undefined" ? window.innerHeight : y + 360; + const left = Math.max(8, Math.min(x, vw - 232)); + const top = Math.max(8, Math.min(y, vh - 360)); + return createPortal( + <> +
+
+
Add
+ } label="Email step" onClick={() => onPick("email")} /> + } label="Condition (branch)" onClick={() => onPick("condition")} /> +
+
Actions
+ {ADD_ACTION_OPTIONS.map((o) => { + const meta = ACTION_META[o.type]; + const Icon = meta?.Icon ?? ZapIcon; + return ( + } + label={o.label} + onClick={() => onPick(o.type)} + /> + ); + })} +
+ , + document.body, + ); +} + +function CreateRow({ icon, label, onClick }: { icon: React.ReactNode; label: string; onClick: () => void }) { + return ( + + ); +} + function AddNodeMenu({ onAddEmail, onAddAction, diff --git a/web/src/lib/api/models/app/campaigns/sequences/Sequence.ts b/web/src/lib/api/models/app/campaigns/sequences/Sequence.ts index ab4c631b..70474181 100644 --- a/web/src/lib/api/models/app/campaigns/sequences/Sequence.ts +++ b/web/src/lib/api/models/app/campaigns/sequences/Sequence.ts @@ -18,10 +18,11 @@ export default interface Sequence { // with this field replaces the step's branch set wholesale. conditions?: SequenceConditions | null; - // "email" (default — subject/body are sent) or "action" (a side effect named - // by action.type — no email is sent). Step spacing is the per-step wait_after, - // not a node kind. - kind: "email" | "action"; + // "email" (default — subject/body are sent), "action" (a side effect named + // by action.type — no email is sent), or "wait" (a no-op control node used + // as a Condition / pure router in the flow editor). Step spacing is the + // per-step wait_after, not a node kind. + kind: "email" | "action" | "wait"; // Typed config for action nodes; empty/absent for email nodes. action?: SequenceAction | null;