@carlonicora/nextjs-jsonapi 5.2.0 → 5.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{BlockNoteEditor-EGY4ZAAM.js → BlockNoteEditor-UJ6363LC.js} +19 -19
- package/dist/{BlockNoteEditor-EGY4ZAAM.js.map → BlockNoteEditor-UJ6363LC.js.map} +1 -1
- package/dist/{BlockNoteEditor-2QJVK2HH.mjs → BlockNoteEditor-WTSQANKI.mjs} +4 -4
- package/dist/{BlockNoteViewer-I2MMFH3S.js → BlockNoteViewer-EL4JFB6P.js} +3 -3
- package/dist/{BlockNoteViewer-I2MMFH3S.js.map → BlockNoteViewer-EL4JFB6P.js.map} +1 -1
- package/dist/{BlockNoteViewer-6BQXJ4TB.mjs → BlockNoteViewer-JVKWJZ6J.mjs} +2 -2
- package/dist/billing/index.js +441 -441
- package/dist/billing/index.mjs +3 -3
- package/dist/{chunk-3VEQ5JZJ.js → chunk-3BAM23VA.js} +847 -847
- package/dist/chunk-3BAM23VA.js.map +1 -0
- package/dist/{chunk-JEZQTZQ4.mjs → chunk-6WU74VFU.mjs} +2 -2
- package/dist/{chunk-S5MQDWWC.js → chunk-CRI4T47Y.js} +736 -176
- package/dist/chunk-CRI4T47Y.js.map +1 -0
- package/dist/{chunk-75MLT74B.js → chunk-EO75CV76.js} +8 -8
- package/dist/{chunk-75MLT74B.js.map → chunk-EO75CV76.js.map} +1 -1
- package/dist/{chunk-NPKFMRH5.mjs → chunk-FNTQQFLH.mjs} +2 -2
- package/dist/{chunk-HNOWSFM5.mjs → chunk-JQWI62NV.mjs} +563 -3
- package/dist/chunk-JQWI62NV.mjs.map +1 -0
- package/dist/{chunk-2Z3ZCYXX.mjs → chunk-MIZTWBPX.mjs} +5 -5
- package/dist/chunk-MIZTWBPX.mjs.map +1 -0
- package/dist/{chunk-YMUPTXOT.mjs → chunk-QKZUE5OF.mjs} +2 -2
- package/dist/{chunk-EXICQC4A.js → chunk-QQQE3N2X.js} +3 -3
- package/dist/{chunk-EXICQC4A.js.map → chunk-QQQE3N2X.js.map} +1 -1
- package/dist/{chunk-THKYH65N.js → chunk-XGTQWBDJ.js} +4 -4
- package/dist/{chunk-THKYH65N.js.map → chunk-XGTQWBDJ.js.map} +1 -1
- package/dist/client/index.d.mts +6 -39
- package/dist/client/index.d.ts +6 -39
- package/dist/client/index.js +4 -4
- package/dist/client/index.mjs +3 -3
- package/dist/components/index.d.mts +2 -2
- package/dist/components/index.d.ts +2 -2
- package/dist/components/index.js +4 -4
- package/dist/components/index.mjs +3 -3
- package/dist/contexts/index.js +4 -4
- package/dist/contexts/index.mjs +3 -3
- package/dist/core/index.d.mts +187 -1
- package/dist/core/index.d.ts +187 -1
- package/dist/core/index.js +30 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +29 -1
- package/dist/email-automation-enrollment.fields-BZvF14qx.d.mts +82 -0
- package/dist/email-automation-enrollment.fields-Cp3VMzFq.d.ts +82 -0
- package/dist/email-automations/index.css +30 -0
- package/dist/email-automations/index.css.map +1 -0
- package/dist/email-automations/index.d.mts +303 -0
- package/dist/email-automations/index.d.ts +303 -0
- package/dist/email-automations/index.js +1705 -0
- package/dist/email-automations/index.js.map +1 -0
- package/dist/email-automations/index.mjs +1705 -0
- package/dist/email-automations/index.mjs.map +1 -0
- package/dist/features/analytics/index.js +122 -122
- package/dist/features/analytics/index.mjs +4 -4
- package/dist/features/analytics/server.js +4 -4
- package/dist/features/analytics/server.mjs +2 -2
- package/dist/features/help/index.js +37 -37
- package/dist/features/help/index.mjs +3 -3
- package/dist/features/tokenusage/index.js +125 -125
- package/dist/features/tokenusage/index.mjs +4 -4
- package/dist/index.d.mts +2 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +31 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +30 -2
- package/dist/server/index.js +3 -3
- package/dist/server/index.mjs +2 -2
- package/dist/types-70acd-8C.d.mts +39 -0
- package/dist/types-l8SFleXi.d.ts +39 -0
- package/messages/en.json +117 -0
- package/messages/it.json +117 -0
- package/package.json +11 -1
- package/src/components/forms/FormFieldWrapper.tsx +1 -1
- package/src/components/forms/FormTextarea.tsx +2 -1
- package/src/core/index.ts +8 -0
- package/src/core/registry/ModuleRegistry.ts +5 -0
- package/src/email-automations/index.ts +5 -0
- package/src/features/email-automation/EmailAutomationEnrollmentModule.ts +11 -0
- package/src/features/email-automation/EmailAutomationModule.ts +30 -0
- package/src/features/email-automation/EmailAutomationStepModule.ts +11 -0
- package/src/features/email-automation/EmailAutomationTriggerModule.ts +11 -0
- package/src/features/email-automation/components/__tests__/EmailAutomationEditor.spec.tsx +104 -0
- package/src/features/email-automation/components/canvas/EmailAutomationActiveSwitch.tsx +49 -0
- package/src/features/email-automation/components/canvas/EmailAutomationCanvas.tsx +474 -0
- package/src/features/email-automation/components/canvas/EmailAutomationCanvasInspector.tsx +89 -0
- package/src/features/email-automation/components/canvas/EmailAutomationCanvasTab.tsx +32 -0
- package/src/features/email-automation/components/canvas/__tests__/buildCanvasGraph.spec.ts +67 -0
- package/src/features/email-automation/components/canvas/buildCanvasGraph.ts +141 -0
- package/src/features/email-automation/components/canvas/edges/DelayEdge.tsx +68 -0
- package/src/features/email-automation/components/canvas/nodes/EmailStepNode.tsx +43 -0
- package/src/features/email-automation/components/canvas/nodes/TriggerNode.tsx +23 -0
- package/src/features/email-automation/components/canvas/shared/CanvasToolbar.tsx +53 -0
- package/src/features/email-automation/components/canvas/shared/EmptyCanvas.tsx +11 -0
- package/src/features/email-automation/components/canvas/shared/__tests__/autoLayout.spec.ts +42 -0
- package/src/features/email-automation/components/canvas/shared/__tests__/longPress.spec.ts +51 -0
- package/src/features/email-automation/components/canvas/shared/autoLayout.ts +55 -0
- package/src/features/email-automation/components/canvas/shared/index.ts +4 -0
- package/src/features/email-automation/components/canvas/shared/longPress.ts +57 -0
- package/src/features/email-automation/components/containers/EmailAutomationContainer.tsx +80 -0
- package/src/features/email-automation/components/containers/EmailAutomationsListContainer.tsx +27 -0
- package/src/features/email-automation/components/forms/EmailAutomationEditor.tsx +153 -0
- package/src/features/email-automation/components/forms/EmailStepEditor.tsx +151 -0
- package/src/features/email-automation/components/index.ts +15 -0
- package/src/features/email-automation/components/lists/EmailAutomationRecipientsList.tsx +90 -0
- package/src/features/email-automation/components/lists/EmailAutomationsList.tsx +76 -0
- package/src/features/email-automation/components/unsubscribe/EmailUnsubscribe.tsx +99 -0
- package/src/features/email-automation/components/unsubscribe/__tests__/EmailUnsubscribe.spec.tsx +55 -0
- package/src/features/email-automation/contexts/EmailAutomationContext.tsx +175 -0
- package/src/features/email-automation/data/EmailAutomation.ts +87 -0
- package/src/features/email-automation/data/EmailAutomationEnrollment.ts +105 -0
- package/src/features/email-automation/data/EmailAutomationEnrollmentInterface.ts +18 -0
- package/src/features/email-automation/data/EmailAutomationEnrollmentService.ts +31 -0
- package/src/features/email-automation/data/EmailAutomationInterface.ts +22 -0
- package/src/features/email-automation/data/EmailAutomationService.ts +106 -0
- package/src/features/email-automation/data/EmailAutomationStep.ts +104 -0
- package/src/features/email-automation/data/EmailAutomationStepInterface.ts +23 -0
- package/src/features/email-automation/data/EmailAutomationStepService.ts +80 -0
- package/src/features/email-automation/data/EmailAutomationTrigger.ts +28 -0
- package/src/features/email-automation/data/EmailAutomationTriggerInterface.ts +6 -0
- package/src/features/email-automation/data/__tests__/email-automation-models.spec.ts +103 -0
- package/src/features/email-automation/data/email-automation-enrollment.fields.ts +8 -0
- package/src/features/email-automation/data/email-automation.fields.ts +9 -0
- package/src/features/email-automation/data/index.ts +13 -0
- package/src/features/email-automation/hooks/index.ts +2 -0
- package/src/features/email-automation/hooks/useEmailAutomationEnrollmentTableStructure.tsx +126 -0
- package/src/features/email-automation/hooks/useEmailAutomationTableStructure.tsx +119 -0
- package/src/features/email-automation/index.ts +5 -0
- package/dist/chunk-2Z3ZCYXX.mjs.map +0 -1
- package/dist/chunk-3VEQ5JZJ.js.map +0 -1
- package/dist/chunk-HNOWSFM5.mjs.map +0 -1
- package/dist/chunk-S5MQDWWC.js.map +0 -1
- /package/dist/{BlockNoteEditor-2QJVK2HH.mjs.map → BlockNoteEditor-WTSQANKI.mjs.map} +0 -0
- /package/dist/{BlockNoteViewer-6BQXJ4TB.mjs.map → BlockNoteViewer-JVKWJZ6J.mjs.map} +0 -0
- /package/dist/{chunk-JEZQTZQ4.mjs.map → chunk-6WU74VFU.mjs.map} +0 -0
- /package/dist/{chunk-NPKFMRH5.mjs.map → chunk-FNTQQFLH.mjs.map} +0 -0
- /package/dist/{chunk-YMUPTXOT.mjs.map → chunk-QKZUE5OF.mjs.map} +0 -0
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Background,
|
|
5
|
+
Connection,
|
|
6
|
+
Edge,
|
|
7
|
+
EdgeChange,
|
|
8
|
+
FinalConnectionState,
|
|
9
|
+
Node,
|
|
10
|
+
NodeChange,
|
|
11
|
+
OnConnectStart,
|
|
12
|
+
OnNodeDrag,
|
|
13
|
+
ReactFlow,
|
|
14
|
+
applyEdgeChanges,
|
|
15
|
+
applyNodeChanges,
|
|
16
|
+
useReactFlow,
|
|
17
|
+
} from "@xyflow/react";
|
|
18
|
+
import "@xyflow/react/dist/style.css";
|
|
19
|
+
import { PlusIcon, Trash2Icon } from "lucide-react";
|
|
20
|
+
import { useTranslations } from "next-intl";
|
|
21
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
22
|
+
import { v4 as uuidv4 } from "uuid";
|
|
23
|
+
import { errorToast } from "../../../../components/errors";
|
|
24
|
+
import { ConfirmDialog, ContextMenu, ContextMenuContent, ContextMenuItem } from "../../../../shadcnui";
|
|
25
|
+
import { useIsMobile } from "../../../../utils/use-mobile";
|
|
26
|
+
import { showError } from "../../../../utils/toast";
|
|
27
|
+
import { useEmailAutomation } from "../../contexts/EmailAutomationContext";
|
|
28
|
+
import { EmailAutomationEditor } from "../forms/EmailAutomationEditor";
|
|
29
|
+
import {
|
|
30
|
+
STEP_NODE_HEIGHT,
|
|
31
|
+
STEP_NODE_PREFIX,
|
|
32
|
+
STEP_NODE_WIDTH,
|
|
33
|
+
STEP_SOURCE_HANDLE_PREFIX,
|
|
34
|
+
STEP_TARGET_HANDLE_PREFIX,
|
|
35
|
+
TRIGGER_NODE_ID,
|
|
36
|
+
TRIGGER_SOURCE_HANDLE,
|
|
37
|
+
buildCanvasGraph,
|
|
38
|
+
} from "./buildCanvasGraph";
|
|
39
|
+
import { DelayEdge } from "./edges/DelayEdge";
|
|
40
|
+
import { EmailAutomationCanvasInspector, useUserClosedGuard } from "./EmailAutomationCanvasInspector";
|
|
41
|
+
import { EmailStepNode } from "./nodes/EmailStepNode";
|
|
42
|
+
import { TriggerNode } from "./nodes/TriggerNode";
|
|
43
|
+
import { CanvasToolbar, EmptyCanvas, createLongPressTracker } from "./shared";
|
|
44
|
+
|
|
45
|
+
const DEFAULT_DELAY_HOURS = 24;
|
|
46
|
+
|
|
47
|
+
type PendingPaneMenu = {
|
|
48
|
+
flowX: number;
|
|
49
|
+
flowY: number;
|
|
50
|
+
screenX: number;
|
|
51
|
+
screenY: number;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
type PendingNodeMenu = {
|
|
55
|
+
stepId: string;
|
|
56
|
+
screenX: number;
|
|
57
|
+
screenY: number;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const pointAnchor = (screenX: number, screenY: number) => ({
|
|
61
|
+
getBoundingClientRect: () =>
|
|
62
|
+
({
|
|
63
|
+
x: screenX,
|
|
64
|
+
y: screenY,
|
|
65
|
+
top: screenY,
|
|
66
|
+
left: screenX,
|
|
67
|
+
right: screenX,
|
|
68
|
+
bottom: screenY,
|
|
69
|
+
width: 0,
|
|
70
|
+
height: 0,
|
|
71
|
+
toJSON: () => ({}),
|
|
72
|
+
}) as DOMRect,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const stepIdOf = (nodeId: string | null | undefined): string | null =>
|
|
76
|
+
nodeId?.startsWith(STEP_NODE_PREFIX) ? nodeId.slice(STEP_NODE_PREFIX.length) : null;
|
|
77
|
+
|
|
78
|
+
export function EmailAutomationCanvas() {
|
|
79
|
+
const { automation, steps, triggers, select, reload, createStep, deleteStep, setNext, updatePosition, tidy } =
|
|
80
|
+
useEmailAutomation();
|
|
81
|
+
const t = useTranslations();
|
|
82
|
+
// Phone-only: a looser zoom-out floor so "Fit to view" can show the whole
|
|
83
|
+
// graph. Desktop keeps React Flow's defaults untouched.
|
|
84
|
+
const isMobile = useIsMobile();
|
|
85
|
+
|
|
86
|
+
const [nodes, setNodes] = useState<Node[]>([]);
|
|
87
|
+
const [edges, setEdges] = useState<Edge[]>([]);
|
|
88
|
+
const { screenToFlowPosition } = useReactFlow();
|
|
89
|
+
const connectingFromRef = useRef<{ nodeId: string | null; handleId: string | null } | null>(null);
|
|
90
|
+
const [paneMenu, setPaneMenu] = useState<PendingPaneMenu | null>(null);
|
|
91
|
+
const [nodeMenu, setNodeMenu] = useState<PendingNodeMenu | null>(null);
|
|
92
|
+
const [pendingDeleteStepId, setPendingDeleteStepId] = useState<string | null>(null);
|
|
93
|
+
const [automationEditorOpen, setAutomationEditorOpen] = useState<boolean>(false);
|
|
94
|
+
|
|
95
|
+
const triggerLabel = useMemo(
|
|
96
|
+
() =>
|
|
97
|
+
triggers.find((trigger) => trigger.key === automation?.trigger)?.label ?? t("emailAutomations.canvas.noTrigger"),
|
|
98
|
+
[triggers, automation?.trigger, t],
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const { laidNodes, laidEdges } = useMemo(() => {
|
|
102
|
+
const graph = buildCanvasGraph(steps, triggerLabel);
|
|
103
|
+
return { laidNodes: graph.nodes, laidEdges: graph.edges };
|
|
104
|
+
}, [steps, triggerLabel]);
|
|
105
|
+
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
setNodes(laidNodes);
|
|
108
|
+
setEdges(laidEdges);
|
|
109
|
+
}, [laidNodes, laidEdges]);
|
|
110
|
+
|
|
111
|
+
const onNodesChange = useCallback((changes: NodeChange[]) => setNodes((nds) => applyNodeChanges(changes, nds)), []);
|
|
112
|
+
const onEdgesChange = useCallback((changes: EdgeChange[]) => setEdges((eds) => applyEdgeChanges(changes, eds)), []);
|
|
113
|
+
|
|
114
|
+
// The trigger has no stored position: only steps are persisted.
|
|
115
|
+
const onNodeDragStop: OnNodeDrag = useCallback(
|
|
116
|
+
(_e, node) => {
|
|
117
|
+
const stepId = stepIdOf(node.id);
|
|
118
|
+
if (!stepId) return;
|
|
119
|
+
void updatePosition(stepId, node.position.x, node.position.y);
|
|
120
|
+
},
|
|
121
|
+
[updatePosition],
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const onConnectStart: OnConnectStart = useCallback((_e, params) => {
|
|
125
|
+
connectingFromRef.current = { nodeId: params.nodeId ?? null, handleId: params.handleId ?? null };
|
|
126
|
+
}, []);
|
|
127
|
+
|
|
128
|
+
/** A rejected link (self-link, loop, step already linked) reloads the true chain. */
|
|
129
|
+
const linkSteps = useCallback(
|
|
130
|
+
async (stepId: string, nextStepId: string) => {
|
|
131
|
+
try {
|
|
132
|
+
await setNext(stepId, nextStepId);
|
|
133
|
+
} catch (error) {
|
|
134
|
+
errorToast({ title: t("emailAutomations.canvas.chainError"), error });
|
|
135
|
+
await reload();
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
[setNext, reload, t],
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
// onConnect fires only when React Flow finds a valid target handle.
|
|
142
|
+
const onConnect = useCallback(
|
|
143
|
+
async (conn: Connection) => {
|
|
144
|
+
const from = connectingFromRef.current;
|
|
145
|
+
connectingFromRef.current = null;
|
|
146
|
+
if (!from || !from.handleId) return;
|
|
147
|
+
if (!conn.targetHandle?.startsWith(STEP_TARGET_HANDLE_PREFIX)) return;
|
|
148
|
+
const targetStepId = conn.targetHandle.slice(STEP_TARGET_HANDLE_PREFIX.length);
|
|
149
|
+
|
|
150
|
+
// The trigger→head link is not stored: the API derives the head as the
|
|
151
|
+
// oldest step with no incoming NEXT. A drag from the trigger onto a step
|
|
152
|
+
// therefore cannot be saved, so it is refused with the chain error and
|
|
153
|
+
// nothing changes.
|
|
154
|
+
if (from.handleId === TRIGGER_SOURCE_HANDLE) {
|
|
155
|
+
showError(t("emailAutomations.canvas.chainError"));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!from.handleId.startsWith(STEP_SOURCE_HANDLE_PREFIX)) return;
|
|
160
|
+
const sourceStepId = from.handleId.slice(STEP_SOURCE_HANDLE_PREFIX.length);
|
|
161
|
+
await linkSteps(sourceStepId, targetStepId);
|
|
162
|
+
},
|
|
163
|
+
[linkSteps, t],
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
/** Creates a step centred on a flow point and returns its id. */
|
|
167
|
+
const createStepAt = useCallback(
|
|
168
|
+
async (flowX: number, flowY: number): Promise<string | null> => {
|
|
169
|
+
if (!automation) return null;
|
|
170
|
+
const id = uuidv4();
|
|
171
|
+
await createStep({
|
|
172
|
+
id,
|
|
173
|
+
automationId: automation.id,
|
|
174
|
+
subject: t("emailAutomations.step.title"),
|
|
175
|
+
body: "",
|
|
176
|
+
delayHours: DEFAULT_DELAY_HOURS,
|
|
177
|
+
positionX: flowX - STEP_NODE_WIDTH / 2,
|
|
178
|
+
positionY: flowY - STEP_NODE_HEIGHT / 2,
|
|
179
|
+
});
|
|
180
|
+
return id;
|
|
181
|
+
},
|
|
182
|
+
[automation, createStep, t],
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
// onConnectEnd fires for EVERY drag end. If onConnect already cleared the
|
|
186
|
+
// ref, it handled the case. Otherwise a drop on empty canvas creates a new
|
|
187
|
+
// step there: from a step it is linked as that step's next; from the
|
|
188
|
+
// trigger it is simply the first step when none existed, otherwise an
|
|
189
|
+
// orphan (the trigger→head link is derived, never stored).
|
|
190
|
+
const onConnectEnd = useCallback(
|
|
191
|
+
async (event: MouseEvent | TouchEvent, connectionState: FinalConnectionState) => {
|
|
192
|
+
const from = connectingFromRef.current;
|
|
193
|
+
if (!from || !from.handleId || !automation) return;
|
|
194
|
+
connectingFromRef.current = null;
|
|
195
|
+
// Dropped on a node but not on its handle: not an empty-canvas drop.
|
|
196
|
+
if (connectionState.toNode) return;
|
|
197
|
+
|
|
198
|
+
const clientX =
|
|
199
|
+
"clientX" in event ? (event as MouseEvent).clientX : ((event as TouchEvent).changedTouches[0]?.clientX ?? 0);
|
|
200
|
+
const clientY =
|
|
201
|
+
"clientY" in event ? (event as MouseEvent).clientY : ((event as TouchEvent).changedTouches[0]?.clientY ?? 0);
|
|
202
|
+
const flowPos = screenToFlowPosition({ x: clientX, y: clientY });
|
|
203
|
+
|
|
204
|
+
const sourceStepId = from.handleId.startsWith(STEP_SOURCE_HANDLE_PREFIX)
|
|
205
|
+
? from.handleId.slice(STEP_SOURCE_HANDLE_PREFIX.length)
|
|
206
|
+
: null;
|
|
207
|
+
if (!sourceStepId && from.handleId !== TRIGGER_SOURCE_HANDLE) return;
|
|
208
|
+
|
|
209
|
+
const newStepId = await createStepAt(flowPos.x, flowPos.y);
|
|
210
|
+
if (!newStepId) return;
|
|
211
|
+
if (sourceStepId) await linkSteps(sourceStepId, newStepId);
|
|
212
|
+
select(newStepId);
|
|
213
|
+
},
|
|
214
|
+
[automation, screenToFlowPosition, createStepAt, linkSteps, select],
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
// --- Pane context menu ---
|
|
218
|
+
|
|
219
|
+
const handlePaneContextMenu = useCallback(
|
|
220
|
+
(event: React.MouseEvent | MouseEvent) => {
|
|
221
|
+
if (!automation) return;
|
|
222
|
+
event.preventDefault();
|
|
223
|
+
const clientX = (event as MouseEvent).clientX;
|
|
224
|
+
const clientY = (event as MouseEvent).clientY;
|
|
225
|
+
const flowPos = screenToFlowPosition({ x: clientX, y: clientY });
|
|
226
|
+
setPaneMenu({
|
|
227
|
+
flowX: flowPos.x,
|
|
228
|
+
flowY: flowPos.y,
|
|
229
|
+
screenX: clientX,
|
|
230
|
+
screenY: clientY,
|
|
231
|
+
});
|
|
232
|
+
},
|
|
233
|
+
[automation, screenToFlowPosition],
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
const handleCreateStepFromPane = useCallback(async () => {
|
|
237
|
+
const menu = paneMenu;
|
|
238
|
+
if (!menu) return;
|
|
239
|
+
setPaneMenu(null);
|
|
240
|
+
const newStepId = await createStepAt(menu.flowX, menu.flowY);
|
|
241
|
+
if (newStepId) select(newStepId);
|
|
242
|
+
}, [paneMenu, createStepAt, select]);
|
|
243
|
+
|
|
244
|
+
// --- Node context menu ---
|
|
245
|
+
//
|
|
246
|
+
// Rendered through the same portal-based ContextMenu + fixed-anchor
|
|
247
|
+
// technique as the pane menu: Base UI menus do NOT work when mounted inside
|
|
248
|
+
// a React Flow node, so the menu lives outside the canvas and is anchored
|
|
249
|
+
// to the click coordinates. The trigger node has no menu.
|
|
250
|
+
const openNodeMenu = useCallback((nodeId: string | undefined, screenX: number, screenY: number) => {
|
|
251
|
+
const stepId = stepIdOf(nodeId);
|
|
252
|
+
if (!stepId) return;
|
|
253
|
+
setNodeMenu({ stepId, screenX, screenY });
|
|
254
|
+
}, []);
|
|
255
|
+
|
|
256
|
+
const handleNodeContextMenu = useCallback(
|
|
257
|
+
(event: React.MouseEvent, node: Node) => {
|
|
258
|
+
event.preventDefault();
|
|
259
|
+
openNodeMenu(node.id, event.clientX, event.clientY);
|
|
260
|
+
},
|
|
261
|
+
[openNodeMenu],
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
// --- Touch: long-press a node opens the same node menu ---
|
|
265
|
+
//
|
|
266
|
+
// Touch screens have no right-click. A ~500ms finger hold on a step node
|
|
267
|
+
// opens the menu at the finger; moving the finger (pan / node drag) cancels
|
|
268
|
+
// it, and the click produced by lifting the finger is swallowed so the hold
|
|
269
|
+
// does not also open the step editor.
|
|
270
|
+
const openNodeMenuRef = useRef(openNodeMenu);
|
|
271
|
+
openNodeMenuRef.current = openNodeMenu;
|
|
272
|
+
const longPress = useMemo(
|
|
273
|
+
() =>
|
|
274
|
+
createLongPressTracker({
|
|
275
|
+
onLongPress: ({ target, clientX, clientY }) => {
|
|
276
|
+
openNodeMenuRef.current(target, clientX, clientY);
|
|
277
|
+
},
|
|
278
|
+
}),
|
|
279
|
+
[],
|
|
280
|
+
);
|
|
281
|
+
useEffect(() => () => longPress.cancel(), [longPress]);
|
|
282
|
+
|
|
283
|
+
const handleTouchPointerDown = useCallback(
|
|
284
|
+
(event: React.PointerEvent) => {
|
|
285
|
+
if (event.pointerType !== "touch") return;
|
|
286
|
+
const el = event.target as Element | null;
|
|
287
|
+
// A press on a connection handle starts a link drag, never a menu.
|
|
288
|
+
if (!el || el.closest(".react-flow__handle")) return;
|
|
289
|
+
const nodeId = el.closest(".react-flow__node")?.getAttribute("data-id");
|
|
290
|
+
if (!stepIdOf(nodeId)) return;
|
|
291
|
+
longPress.down(nodeId!, event.clientX, event.clientY);
|
|
292
|
+
},
|
|
293
|
+
[longPress],
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
const handleNodeClick = useCallback(
|
|
297
|
+
(_event: React.MouseEvent, node: Node) => {
|
|
298
|
+
if (node.id === TRIGGER_NODE_ID) {
|
|
299
|
+
setAutomationEditorOpen(true);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const stepId = stepIdOf(node.id);
|
|
303
|
+
if (stepId) select(stepId);
|
|
304
|
+
},
|
|
305
|
+
[select],
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
const handleDeleteStep = useCallback(async () => {
|
|
309
|
+
const stepId = pendingDeleteStepId;
|
|
310
|
+
if (!stepId) return;
|
|
311
|
+
try {
|
|
312
|
+
await deleteStep(stepId);
|
|
313
|
+
} catch (error) {
|
|
314
|
+
errorToast({ title: t("emailAutomations.canvas.deleteEmail"), error });
|
|
315
|
+
throw error; // keeps the dialog open — ConfirmDialog swallows rejections
|
|
316
|
+
}
|
|
317
|
+
}, [pendingDeleteStepId, deleteStep, t]);
|
|
318
|
+
|
|
319
|
+
const nodeTypes = useMemo(() => ({ triggerNode: TriggerNode, emailStepNode: EmailStepNode }), []);
|
|
320
|
+
const edgeTypes = useMemo(() => ({ delayEdge: DelayEdge }), []);
|
|
321
|
+
|
|
322
|
+
return (
|
|
323
|
+
<>
|
|
324
|
+
<ReactFlow
|
|
325
|
+
nodes={nodes}
|
|
326
|
+
edges={edges}
|
|
327
|
+
onNodesChange={onNodesChange}
|
|
328
|
+
onEdgesChange={onEdgesChange}
|
|
329
|
+
onNodeDragStop={onNodeDragStop}
|
|
330
|
+
onConnectStart={onConnectStart}
|
|
331
|
+
onConnect={onConnect}
|
|
332
|
+
onConnectEnd={onConnectEnd}
|
|
333
|
+
onPaneContextMenu={handlePaneContextMenu}
|
|
334
|
+
onNodeContextMenu={handleNodeContextMenu}
|
|
335
|
+
onNodeClick={handleNodeClick}
|
|
336
|
+
nodeTypes={nodeTypes}
|
|
337
|
+
edgeTypes={edgeTypes}
|
|
338
|
+
// Built-in React Flow click handler — independent of EdgeLabelRenderer
|
|
339
|
+
// pointer-events. The label button calls `select` too; either path
|
|
340
|
+
// produces the same selection state.
|
|
341
|
+
onEdgeClick={(_event, edge) => {
|
|
342
|
+
const targetStepId = (edge.data as { targetStepId?: string } | undefined)?.targetStepId;
|
|
343
|
+
if (targetStepId) select(targetStepId);
|
|
344
|
+
}}
|
|
345
|
+
fitView
|
|
346
|
+
// Never zoom past 100% on fit: with no emails the trigger is the only
|
|
347
|
+
// node, and the default ceiling (2x) blows it up over the empty state.
|
|
348
|
+
fitViewOptions={{ maxZoom: 1 }}
|
|
349
|
+
// React Flow's default floor is 0.5, which on a phone stops "Fit to
|
|
350
|
+
// view" before the graph fits. undefined keeps the desktop default.
|
|
351
|
+
minZoom={isMobile ? 0.15 : undefined}
|
|
352
|
+
onPointerDownCapture={handleTouchPointerDown}
|
|
353
|
+
onPointerMoveCapture={(event) => {
|
|
354
|
+
if (event.pointerType === "touch") longPress.move(event.clientX, event.clientY);
|
|
355
|
+
}}
|
|
356
|
+
onPointerUpCapture={() => longPress.up()}
|
|
357
|
+
onPointerCancelCapture={() => longPress.cancel()}
|
|
358
|
+
onClickCapture={(event) => {
|
|
359
|
+
if (!longPress.consumeClick()) return;
|
|
360
|
+
event.preventDefault();
|
|
361
|
+
event.stopPropagation();
|
|
362
|
+
}}
|
|
363
|
+
proOptions={{ hideAttribution: true }}
|
|
364
|
+
>
|
|
365
|
+
<Background gap={16} size={1} />
|
|
366
|
+
</ReactFlow>
|
|
367
|
+
{steps.length === 0 && (
|
|
368
|
+
// The canvas stays mounted under the empty state: the first email is
|
|
369
|
+
// added by right-clicking it or dragging from the trigger.
|
|
370
|
+
// Lower half only, so the text sits below the centred trigger node.
|
|
371
|
+
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-1/2">
|
|
372
|
+
<EmptyCanvas>
|
|
373
|
+
<div className="flex flex-col gap-1">
|
|
374
|
+
<span className="font-medium">{t("emailAutomations.canvas.emptyTitle")}</span>
|
|
375
|
+
<span className="text-xs/relaxed">{t("emailAutomations.canvas.emptyBody")}</span>
|
|
376
|
+
</div>
|
|
377
|
+
</EmptyCanvas>
|
|
378
|
+
</div>
|
|
379
|
+
)}
|
|
380
|
+
<CanvasToolbar onTidy={tidy} />
|
|
381
|
+
<EmailAutomationCanvasInspector />
|
|
382
|
+
{automationEditorOpen && automation && (
|
|
383
|
+
<TriggerAutomationEditor
|
|
384
|
+
automation={automation}
|
|
385
|
+
onSaved={reload}
|
|
386
|
+
onClose={() => setAutomationEditorOpen(false)}
|
|
387
|
+
/>
|
|
388
|
+
)}
|
|
389
|
+
<ConfirmDialog
|
|
390
|
+
open={pendingDeleteStepId !== null}
|
|
391
|
+
onOpenChange={(open) => {
|
|
392
|
+
if (!open) setPendingDeleteStepId(null);
|
|
393
|
+
}}
|
|
394
|
+
title={t("emailAutomations.canvas.deleteEmail")}
|
|
395
|
+
description={t("emailAutomations.canvas.deleteEmailConfirm")}
|
|
396
|
+
confirmLabel={t("emailAutomations.canvas.deleteEmail")}
|
|
397
|
+
cancelLabel={t("ui.buttons.cancel")}
|
|
398
|
+
destructive
|
|
399
|
+
onConfirm={handleDeleteStep}
|
|
400
|
+
/>
|
|
401
|
+
{paneMenu && (
|
|
402
|
+
<ContextMenu
|
|
403
|
+
open={true}
|
|
404
|
+
onOpenChange={(open) => {
|
|
405
|
+
if (!open) setPaneMenu(null);
|
|
406
|
+
}}
|
|
407
|
+
>
|
|
408
|
+
<ContextMenuContent
|
|
409
|
+
anchor={pointAnchor(paneMenu.screenX, paneMenu.screenY)}
|
|
410
|
+
side="bottom"
|
|
411
|
+
align="start"
|
|
412
|
+
sideOffset={4}
|
|
413
|
+
>
|
|
414
|
+
<ContextMenuItem onClick={handleCreateStepFromPane}>
|
|
415
|
+
<PlusIcon />
|
|
416
|
+
{t("emailAutomations.canvas.addEmail")}
|
|
417
|
+
</ContextMenuItem>
|
|
418
|
+
</ContextMenuContent>
|
|
419
|
+
</ContextMenu>
|
|
420
|
+
)}
|
|
421
|
+
{nodeMenu && (
|
|
422
|
+
<ContextMenu
|
|
423
|
+
open={true}
|
|
424
|
+
onOpenChange={(open) => {
|
|
425
|
+
if (!open) setNodeMenu(null);
|
|
426
|
+
}}
|
|
427
|
+
>
|
|
428
|
+
<ContextMenuContent
|
|
429
|
+
anchor={pointAnchor(nodeMenu.screenX, nodeMenu.screenY)}
|
|
430
|
+
side="bottom"
|
|
431
|
+
align="start"
|
|
432
|
+
sideOffset={4}
|
|
433
|
+
>
|
|
434
|
+
<ContextMenuItem
|
|
435
|
+
onClick={() => {
|
|
436
|
+
const menu = nodeMenu;
|
|
437
|
+
setNodeMenu(null);
|
|
438
|
+
if (menu) setPendingDeleteStepId(menu.stepId);
|
|
439
|
+
}}
|
|
440
|
+
>
|
|
441
|
+
<Trash2Icon />
|
|
442
|
+
{t("emailAutomations.canvas.deleteEmail")}
|
|
443
|
+
</ContextMenuItem>
|
|
444
|
+
</ContextMenuContent>
|
|
445
|
+
</ContextMenu>
|
|
446
|
+
)}
|
|
447
|
+
</>
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* The trigger node's click opens the automation's own EditorSheet, controlled
|
|
453
|
+
* the same way the inspector controls the step editor.
|
|
454
|
+
*/
|
|
455
|
+
function TriggerAutomationEditor(props: {
|
|
456
|
+
automation: NonNullable<ReturnType<typeof useEmailAutomation>["automation"]>;
|
|
457
|
+
onSaved: () => Promise<void> | void;
|
|
458
|
+
onClose: () => void;
|
|
459
|
+
}) {
|
|
460
|
+
const onDialogOpenChange = useUserClosedGuard(props.onClose);
|
|
461
|
+
|
|
462
|
+
return (
|
|
463
|
+
<EmailAutomationEditor
|
|
464
|
+
automation={props.automation}
|
|
465
|
+
forceShow
|
|
466
|
+
dialogOpen
|
|
467
|
+
onDialogOpenChange={onDialogOpenChange}
|
|
468
|
+
onSuccess={async () => {
|
|
469
|
+
await props.onSaved();
|
|
470
|
+
props.onClose();
|
|
471
|
+
}}
|
|
472
|
+
/>
|
|
473
|
+
);
|
|
474
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useMemo, useRef } from "react";
|
|
4
|
+
import { useEmailAutomation } from "../../contexts/EmailAutomationContext";
|
|
5
|
+
import { EmailStepEditor } from "../forms/EmailStepEditor";
|
|
6
|
+
import { findHeadStepId } from "./buildCanvasGraph";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Mounts EmailStepEditor (the EditorSheet the step uses everywhere) on top of
|
|
10
|
+
* the canvas, driven by `selectedStepId` from EmailAutomationContext. Copy of
|
|
11
|
+
* the app's ScenarioCanvasInspector with one branch.
|
|
12
|
+
*
|
|
13
|
+
* - We control open state via `forceShow + dialogOpen + onDialogOpenChange`.
|
|
14
|
+
* - On `onSuccess` we reload the canvas.
|
|
15
|
+
* - The step comes from the context's `steps` (already loaded for the
|
|
16
|
+
* canvas), so there is no per-step fetch.
|
|
17
|
+
*/
|
|
18
|
+
export function EmailAutomationCanvasInspector() {
|
|
19
|
+
const { automation, steps, selectedStepId, select, reload } = useEmailAutomation();
|
|
20
|
+
|
|
21
|
+
const close = useCallback(() => select(null), [select]);
|
|
22
|
+
const onSaved = useCallback(async () => {
|
|
23
|
+
await reload();
|
|
24
|
+
}, [reload]);
|
|
25
|
+
|
|
26
|
+
const headStepId = useMemo(() => findHeadStepId(steps), [steps]);
|
|
27
|
+
const step = useMemo(() => steps.find((s) => s.id === selectedStepId) ?? null, [steps, selectedStepId]);
|
|
28
|
+
|
|
29
|
+
if (!automation || !selectedStepId || !step) return null;
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<StepEditor
|
|
33
|
+
key={step.id}
|
|
34
|
+
step={step}
|
|
35
|
+
automationId={automation.id}
|
|
36
|
+
isHead={step.id === headStepId}
|
|
37
|
+
onSaved={onSaved}
|
|
38
|
+
onClose={close}
|
|
39
|
+
/>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Build an `onDialogOpenChange` callback that ignores the editor's
|
|
45
|
+
* initial `open=false` notification. `useEditorDialog` (the hook
|
|
46
|
+
* inside every EditorSheet) initialises `open` to `false`, then in
|
|
47
|
+
* an effect sets it to `dialogOpen` (true). The "open=false" effect
|
|
48
|
+
* fires synchronously on mount and would otherwise wipe our canvas
|
|
49
|
+
* selection before the editor ever paints.
|
|
50
|
+
*/
|
|
51
|
+
export function useUserClosedGuard(onClose: () => void) {
|
|
52
|
+
const wasOpenRef = useRef(false);
|
|
53
|
+
return useCallback(
|
|
54
|
+
(open: boolean) => {
|
|
55
|
+
if (open) {
|
|
56
|
+
wasOpenRef.current = true;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
// open === false; only treat as "user closed" once we've seen open=true.
|
|
60
|
+
if (wasOpenRef.current) onClose();
|
|
61
|
+
},
|
|
62
|
+
[onClose],
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function StepEditor(props: {
|
|
67
|
+
step: ReturnType<typeof useEmailAutomation>["steps"][number];
|
|
68
|
+
automationId: string;
|
|
69
|
+
isHead: boolean;
|
|
70
|
+
onSaved: () => Promise<void>;
|
|
71
|
+
onClose: () => void;
|
|
72
|
+
}) {
|
|
73
|
+
const onDialogOpenChange = useUserClosedGuard(props.onClose);
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<EmailStepEditor
|
|
77
|
+
step={props.step}
|
|
78
|
+
automationId={props.automationId}
|
|
79
|
+
isHead={props.isHead}
|
|
80
|
+
forceShow
|
|
81
|
+
dialogOpen
|
|
82
|
+
onDialogOpenChange={onDialogOpenChange}
|
|
83
|
+
onSuccess={async () => {
|
|
84
|
+
await props.onSaved();
|
|
85
|
+
props.onClose();
|
|
86
|
+
}}
|
|
87
|
+
/>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ReactFlowProvider } from "@xyflow/react";
|
|
4
|
+
import { useEffect } from "react";
|
|
5
|
+
import { useSidebar } from "../../../../shadcnui";
|
|
6
|
+
import { EmailAutomationCanvas } from "./EmailAutomationCanvas";
|
|
7
|
+
|
|
8
|
+
export function EmailAutomationCanvasTab() {
|
|
9
|
+
const { open, setOpen } = useSidebar();
|
|
10
|
+
|
|
11
|
+
// Performance mode: collapse the app sidebar on mount; restore on unmount.
|
|
12
|
+
// Mirrors the app's ScenarioCanvasPage shell.
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
const prev = open;
|
|
15
|
+
if (prev) setOpen(false);
|
|
16
|
+
return () => {
|
|
17
|
+
if (prev) setOpen(true);
|
|
18
|
+
};
|
|
19
|
+
}, []);
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<main className="flex h-full w-full overflow-hidden">
|
|
23
|
+
{/* The canvas column keeps `relative` so CanvasToolbar's absolute
|
|
24
|
+
positioning anchors to the canvas, not to the whole row. */}
|
|
25
|
+
<div className="relative h-full min-w-0 flex-1">
|
|
26
|
+
<ReactFlowProvider>
|
|
27
|
+
<EmailAutomationCanvas />
|
|
28
|
+
</ReactFlowProvider>
|
|
29
|
+
</div>
|
|
30
|
+
</main>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { buildCanvasGraph, CanvasStep } from "../buildCanvasGraph";
|
|
3
|
+
|
|
4
|
+
const step = (id: string, overrides: Partial<CanvasStep> = {}): CanvasStep => ({
|
|
5
|
+
id,
|
|
6
|
+
subject: `Subject ${id}`,
|
|
7
|
+
delayHours: 24,
|
|
8
|
+
sentCount: 0,
|
|
9
|
+
...overrides,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe("buildCanvasGraph", () => {
|
|
13
|
+
const steps: CanvasStep[] = [
|
|
14
|
+
step("a", { next: { id: "b" }, delayHours: 0 }),
|
|
15
|
+
step("b", { next: { id: "c" }, delayHours: 24 }),
|
|
16
|
+
step("c", { delayHours: 48 }),
|
|
17
|
+
step("d", { delayHours: 12 }),
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
it("builds the trigger node plus one node per step", () => {
|
|
21
|
+
const { nodes } = buildCanvasGraph(steps, "Waitlist confirmed");
|
|
22
|
+
expect(nodes.map((n) => n.id)).toEqual(["trigger", "step:a", "step:b", "step:c", "step:d"]);
|
|
23
|
+
expect(nodes[0].type).toBe("triggerNode");
|
|
24
|
+
expect((nodes[0].data as { data: { label: string } }).data.label).toBe("Waitlist confirmed");
|
|
25
|
+
expect(nodes.slice(1).every((n) => n.type === "emailStepNode")).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("draws trigger→head and one delay edge per NEXT link", () => {
|
|
29
|
+
const { edges } = buildCanvasGraph(steps, "Waitlist confirmed");
|
|
30
|
+
expect(edges.map((e) => [e.source, e.target])).toEqual([
|
|
31
|
+
["trigger", "step:a"],
|
|
32
|
+
["step:a", "step:b"],
|
|
33
|
+
["step:b", "step:c"],
|
|
34
|
+
]);
|
|
35
|
+
expect(edges.every((e) => e.type === "delayEdge")).toBe(true);
|
|
36
|
+
expect(edges.map((e) => (e.data as { targetStepId: string }).targetStepId)).toEqual(["a", "b", "c"]);
|
|
37
|
+
expect(edges.map((e) => e.label)).toEqual(["0h", "24h", "48h"]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("marks steps outside the chain as orphans", () => {
|
|
41
|
+
const { nodes, orphanIds } = buildCanvasGraph(steps, "Waitlist confirmed");
|
|
42
|
+
expect(orphanIds).toEqual(["d"]);
|
|
43
|
+
const orphanFlag = (id: string) =>
|
|
44
|
+
(nodes.find((n) => n.id === `step:${id}`)?.data as { data: { orphan: boolean } }).data.orphan;
|
|
45
|
+
expect(orphanFlag("d")).toBe(true);
|
|
46
|
+
expect(orphanFlag("a")).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("keeps stored positions and lays out the rest with dagre", () => {
|
|
50
|
+
const positioned = [step("a", { next: { id: "b" }, positionX: 500, positionY: 300 }), step("b")];
|
|
51
|
+
const { nodes } = buildCanvasGraph(positioned, "Waitlist confirmed");
|
|
52
|
+
const a = nodes.find((n) => n.id === "step:a")!;
|
|
53
|
+
const b = nodes.find((n) => n.id === "step:b")!;
|
|
54
|
+
expect(a.position).toEqual({ x: 500, y: 300 });
|
|
55
|
+
expect((a.data as { data: { manuallyPositioned: boolean } }).data.manuallyPositioned).toBe(true);
|
|
56
|
+
expect((b.data as { data: { manuallyPositioned: boolean } }).data.manuallyPositioned).toBe(false);
|
|
57
|
+
expect(typeof b.position.x).toBe("number");
|
|
58
|
+
expect(typeof b.position.y).toBe("number");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("returns the trigger node alone and no edges when there are no steps", () => {
|
|
62
|
+
const { nodes, edges, orphanIds } = buildCanvasGraph([], "No trigger");
|
|
63
|
+
expect(nodes.map((n) => n.id)).toEqual(["trigger"]);
|
|
64
|
+
expect(edges).toEqual([]);
|
|
65
|
+
expect(orphanIds).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
});
|