diff --git a/styles.css b/styles.css index 2f48efb..ebdab05 100644 --- a/styles.css +++ b/styles.css @@ -116,17 +116,24 @@ button, select, input { /* Mobile: sidebar becomes bottom sheet */ @media (max-width: 768px) { .jf-layout { - flex-direction: column; + min-height: 0; } .jf-layout__sidebar { - width: 100%; - max-height: 40vh; - border-left: none; + position: absolute; + left: 0.75rem; + right: 0.75rem; + bottom: 0.75rem; + width: auto; + max-height: min(68vh, 560px); + max-height: min(68dvh, 560px); + border: 1px solid var(--border); border-top: 1px solid var(--border); - flex-direction: row; - flex-wrap: wrap; + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + flex-direction: column; overflow-y: auto; + z-index: 20; } } @@ -229,6 +236,15 @@ button, select, input { } @media (max-width: 480px) { + .jf-toolbar { + align-items: flex-start; + } + .jf-toolbar__actions { + width: 100%; + overflow-x: auto; + padding-bottom: 0.15rem; + flex-wrap: nowrap; + } .jf-toolbar__btn-label { display: none; } @@ -302,6 +318,12 @@ button, select, input { overflow: hidden; } +@media (max-width: 768px) { + .jf-node { + width: min(180px, calc(100vw - 2rem)); + } +} + .jf-node:active { cursor: grabbing; } @@ -413,12 +435,11 @@ button, select, input { @media (max-width: 768px) { .jf-palette { - border-bottom: none; - border-right: 1px solid var(--border); - min-width: 50%; + border-right: none; + min-width: 100%; } .jf-palette__items { - grid-template-columns: 1fr 1fr; + grid-template-columns: repeat(2, minmax(0, 1fr)); } } @@ -470,7 +491,7 @@ button, select, input { @media (max-width: 768px) { .jf-panel { - min-width: 50%; + min-width: 100%; } } @@ -513,6 +534,14 @@ button, select, input { padding: 0.75rem 1rem; } +.jf-panel__hint { + padding: 0.75rem; + border: 1px dashed var(--border); + border-radius: var(--radius-sm); + color: var(--text-secondary); + font-size: 0.8rem; +} + .jf-panel__state { display: inline-flex; padding: 0.1rem 0.45rem; @@ -538,6 +567,10 @@ button, select, input { margin-top: 0.3rem; } +.jf-field--fill { + flex: 1; +} + .jf-field__label { font-size: 0.75rem; font-weight: 500; @@ -556,6 +589,17 @@ button, select, input { width: 100%; } +.jf-field__input--compact { + min-width: 0; + max-width: 160px; +} + +.jf-field__textarea { + min-height: 86px; + resize: vertical; + white-space: pre-wrap; +} + .jf-field__input:focus { outline: none; border-color: var(--accent); @@ -597,6 +641,74 @@ button, select, input { background: rgba(2, 6, 20, 0.4); } +.jf-fork, +.jf-fork-branch { + padding: 0.75rem; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: rgba(2, 6, 20, 0.35); +} + +.jf-fork-branch { + margin-top: 0.5rem; +} + +.jf-stack { + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +.jf-card__header, +.jf-section__header, +.jf-inline-controls { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.jf-card__header, +.jf-section__header { + justify-content: space-between; + margin-bottom: 0.5rem; +} + +.jf-inline-controls { + flex-wrap: wrap; + justify-content: flex-end; +} + +.jf-reference-list { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +.jf-reference-chip { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.35rem 0.6rem; + border-radius: var(--radius-full); + border: 1px solid rgba(59, 130, 246, 0.28); + background: rgba(59, 130, 246, 0.08); + color: #bfdbfe; + cursor: pointer; + font-size: 0.72rem; + transition: background 0.2s, border-color 0.2s, transform 0.2s; +} + +.jf-reference-chip:hover { + border-color: var(--accent); + background: rgba(59, 130, 246, 0.16); + transform: translateY(-1px); +} + +.jf-reference-chip--active { + background: rgba(59, 130, 246, 0.22); + color: white; +} + .jf-expr__type { display: inline-flex; padding: 0.15rem 0.5rem; @@ -628,10 +740,20 @@ button, select, input { transition: all 0.2s; } +.jf-btn--ghost { + background: transparent; +} + .jf-btn:hover { transform: translateY(-1px); } +.jf-btn:disabled { + opacity: 0.45; + cursor: not-allowed; + transform: none; +} + .jf-btn--danger { border-color: rgba(239, 68, 68, 0.3); color: var(--danger); @@ -706,6 +828,12 @@ button, select, input { border-top: 1px solid var(--border); } +@media (max-width: 768px) { + .jf-results { + min-width: 100%; + } +} + .jf-results__count { display: inline-flex; align-items: center; diff --git a/ui/canvas.ts b/ui/canvas.ts index 82f5eae..085bf20 100644 --- a/ui/canvas.ts +++ b/ui/canvas.ts @@ -19,6 +19,7 @@ let panOffsetStart: Position = { x: 0, y: 0 }; let dragNodeId: string | null = null; let dragStart: Position = { x: 0, y: 0 }; let dragNodeStart: Position = { x: 0, y: 0 }; +let dragPointerId: number | null = null; export function initCanvas(container: HTMLDivElement) { containerEl = container; @@ -90,6 +91,10 @@ function bindPanEvents(container: HTMLDivElement) { } if (dragNodeId) { dragNodeId = null; + if (dragPointerId !== null) { + container.releasePointerCapture(dragPointerId); + } + dragPointerId = null; } }); @@ -102,12 +107,16 @@ function bindPanEvents(container: HTMLDivElement) { }, { passive: false }); } -export function startNodeDrag(nodeId: string, clientX: number, clientY: number) { +export function startNodeDrag(nodeId: string, clientX: number, clientY: number, pointerId?: number) { const node = getState().nodes.find((n) => n.id === nodeId); if (!node) return; dragNodeId = nodeId; dragStart = { x: clientX, y: clientY }; dragNodeStart = { ...node.position }; + if (containerEl && pointerId != null) { + dragPointerId = pointerId; + containerEl.setPointerCapture(pointerId); + } setState({ selectedNodeId: nodeId }); } diff --git a/ui/connections.ts b/ui/connections.ts index 4b9b5a2..829554d 100644 --- a/ui/connections.ts +++ b/ui/connections.ts @@ -2,9 +2,35 @@ import { NodeData, NodeExecutionState } from "./types"; -const NODE_WIDTH = 200; +const MOBILE_BREAKPOINT = 768; +const MOBILE_NODE_MARGIN = 32; +const MOBILE_NODE_MAX_WIDTH = 180; +const DESKTOP_NODE_WIDTH = 200; + +let cachedNodeWidth = typeof window !== "undefined" ? calculateNodeWidth(window.innerWidth) : DESKTOP_NODE_WIDTH; +let resizeFrame = 0; + +if (typeof window !== "undefined") { + window.addEventListener("resize", () => { + if (resizeFrame) { + cancelAnimationFrame(resizeFrame); + } + resizeFrame = requestAnimationFrame(() => { + cachedNodeWidth = calculateNodeWidth(window.innerWidth); + resizeFrame = 0; + }); + }); +} + +const getNodeWidth = () => cachedNodeWidth; const NODE_HEIGHT = 80; +function calculateNodeWidth(windowWidth: number): number { + return windowWidth <= MOBILE_BREAKPOINT + ? Math.min(MOBILE_NODE_MAX_WIDTH, windowWidth - MOBILE_NODE_MARGIN) + : DESKTOP_NODE_WIDTH; +} + export function renderConnections( svg: SVGSVGElement, nodes: NodeData[], @@ -14,6 +40,7 @@ export function renderConnections( svg.innerHTML = ""; const nodeMap = new Map(nodes.map((n) => [n.id, n])); + const nodeWidth = getNodeWidth(); for (const node of nodes) { // Parent → child connections @@ -21,7 +48,7 @@ export function renderConnections( if (parentId === "workflow") continue; const parent = nodeMap.get(parentId); if (!parent) continue; - drawConnection(svg, parent, node, "parent", executionStates); + drawConnection(svg, parent, node, "parent", executionStates, nodeWidth); } // Fork connections @@ -29,11 +56,11 @@ export function renderConnections( for (const branch of fork.branches) { for (const targetId of branch.resultTrueBlocks) { const target = nodeMap.get(targetId); - if (target) drawConnection(svg, node, target, "true", executionStates); + if (target) drawConnection(svg, node, target, "true", executionStates, nodeWidth); } for (const targetId of branch.resultFalseBlocks) { const target = nodeMap.get(targetId); - if (target) drawConnection(svg, node, target, "false", executionStates); + if (target) drawConnection(svg, node, target, "false", executionStates, nodeWidth); } } } @@ -45,11 +72,12 @@ function drawConnection( from: NodeData, to: NodeData, type: "parent" | "true" | "false", - executionStates: Record + executionStates: Record, + nodeWidth: number ) { - const fromX = from.position.x + NODE_WIDTH / 2; + const fromX = from.position.x + nodeWidth / 2; const fromY = from.position.y + NODE_HEIGHT; - const toX = to.position.x + NODE_WIDTH / 2; + const toX = to.position.x + nodeWidth / 2; const toY = to.position.y; // Bezier curve diff --git a/ui/expressionTemplates.ts b/ui/expressionTemplates.ts new file mode 100644 index 0000000..7d250e6 --- /dev/null +++ b/ui/expressionTemplates.ts @@ -0,0 +1,57 @@ +import { ExpressionData } from "./types"; +import { generateId } from "./ids"; +import { ensureUniqueReferenceKey, resolveExpressionReferenceKey, toReferenceKey } from "../workflow/referenceKeys"; + +export const EXPRESSION_LIBRARY: { label: string; icon: string; expressionType: string }[] = [ + { label: "Math", icon: "🧮", expressionType: "Math" }, + { label: "Console Log", icon: "📝", expressionType: "ConsoleLog" }, + { label: "HTTP Request", icon: "🌐", expressionType: "HTTPRequest" }, + { label: "Wait", icon: "⏱️", expressionType: "Wait" }, +]; + +export function createExpression(type: string, existingExpressions: ExpressionData[] = []): ExpressionData { + return { + id: generateId("expr"), + name: createExpressionName(type, existingExpressions), + type, + parameters: getDefaultParams(type), + }; +} + +export function fallbackExpressionName(type: string, index: number): string { + return `${toReferenceKey(type, "expression")}_${index + 1}`; +} + +export function getDefaultParams(type: string): { id: string; name: string; value: string }[] { + switch (type) { + case "Math": + return [{ id: "p1", name: "expression", value: "2 + 2" }]; + case "ConsoleLog": + return [{ id: "p1", name: "data", value: "Hello from JFlow" }]; + case "HTTPRequest": + return [ + { id: "p1", name: "url", value: "https://httpbin.org/get" }, + { id: "p2", name: "method", value: "GET" }, + ]; + case "Wait": + return [{ id: "p1", name: "seconds", value: "2" }]; + default: + return []; + } +} + +export function getExpressionMeta(type: string) { + return ( + EXPRESSION_LIBRARY.find((entry) => entry.expressionType === type) ?? { + label: type, + icon: "◆", + expressionType: type, + } + ); +} + +function createExpressionName(type: string, existingExpressions: ExpressionData[]): string { + const taken = new Set(existingExpressions.map((expression) => expression.name)); + const base = resolveExpressionReferenceKey({ type }, []); + return ensureUniqueReferenceKey(base, taken); +} diff --git a/ui/ids.ts b/ui/ids.ts new file mode 100644 index 0000000..bfc1698 --- /dev/null +++ b/ui/ids.ts @@ -0,0 +1,3 @@ +export function generateId(prefix: string): string { + return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; +} diff --git a/ui/node.ts b/ui/node.ts index 785de1c..75a83c0 100644 --- a/ui/node.ts +++ b/ui/node.ts @@ -21,7 +21,7 @@ export function createNodeElement(node: NodeData): HTMLElement { // Drag handler el.addEventListener("pointerdown", (e) => { e.stopPropagation(); - startNodeDrag(node.id, e.clientX, e.clientY); + startNodeDrag(node.id, e.clientX, e.clientY, e.pointerId); }); // Tap to select diff --git a/ui/palette.ts b/ui/palette.ts index 199cf8e..9749969 100644 --- a/ui/palette.ts +++ b/ui/palette.ts @@ -1,14 +1,9 @@ /** Node palette – drag new blocks onto canvas */ import { addNode, getState } from "./state"; -import { NodeData, ExpressionData } from "./types"; - -const BLOCK_TEMPLATES: { label: string; icon: string; expressionType: string }[] = [ - { label: "Math", icon: "🧮", expressionType: "Math" }, - { label: "Console Log", icon: "📝", expressionType: "ConsoleLog" }, - { label: "HTTP Request", icon: "🌐", expressionType: "HTTPRequest" }, - { label: "Wait", icon: "⏱️", expressionType: "Wait" }, -]; +import { NodeData } from "./types"; +import { EXPRESSION_LIBRARY, createExpression } from "./expressionTemplates"; +import { generateId } from "./ids"; let paletteEl: HTMLDivElement; @@ -23,7 +18,7 @@ export function initPalette(container: HTMLElement) {
- ${BLOCK_TEMPLATES.map( + ${EXPRESSION_LIBRARY.map( (t, i) => ` Node Properties - +
@@ -56,107 +58,823 @@ function renderPanel(node: NodeData | null) { Name -
- Parent Blocks -
- ${node.parentBlocks.map((p) => `${p}`).join("")} - + Parents +
+ ${parentOptions + .map( + (option) => ` + + ` + ) + .join("")}
- Expressions ${stateLabel} - ${node.expressions - .map( - (expr, i) => ` -
- ${expr.type} - ${expr.parameters - .map( - (p) => ` - - ` - ) - .join("")} +
+ Expressions ${stateLabel} +
+ +
+
+
+ ${node.expressions + .map((expression, expressionIndex) => renderExpression(node, expression, expressionIndex)) + .join("")} +
+
+
+
+ Forks + +
+
+ ${node.forks.length === 0 ? `
Add a fork to create decision branches.
` : ""} + ${node.forks.map((fork, forkIndex) => renderFork(node, fork, forkIndex)).join("")} +
+
+
+ +
+
+
+ `; +} + +function renderExpression(node: NodeData, expression: ExpressionData, expressionIndex: number): string { + const referenceOptions = getReferenceOptions(node, expressionIndex); + + return ` +
+
+ ${escapeHtml(expression.type)} + +
+ + + ${expression.parameters + .map( + (parameter) => ` + + ` + ) + .join("")} + ${ + referenceOptions.length > 0 + ? ` +
+ Reference values +
+ ${referenceOptions + .map( + (reference) => ` + + ` + ) + .join("")} +
+
+ ` + : "" + } +
+ `; +} + +function renderFork(node: NodeData, fork: ForkData, forkIndex: number): string { + return ` +
+
+ + +
+
+ ${fork.branches.map((branch, branchIndex) => renderForkBranch(node, branch, forkIndex, branchIndex)).join("")} + +
+
+ `; +} + +function renderForkBranch(node: NodeData, branch: ForkBranchData, forkIndex: number, branchIndex: number): string { + const branchTargets = getBranchTargets(node); + const branchReferences = getForkReferenceOptions(node); + + return ` +
+
+ Branch ${branchIndex + 1} + +
+ + ${ + branchReferences.length > 0 + ? ` +
+ Reference values +
+ ${branchReferences + .map( + (reference) => ` + + ` + ) + .join("")} +
+
+ ` + : "" + } +
+ If true +
+ ${branchTargets + .map((target) => + renderTargetChip(target.id, target.label, branch.resultTrueBlocks.includes(target.id), forkIndex, branchIndex, "true") ) .join("")}
-
- +
+
+ If false +
+ ${branchTargets + .map((target) => + renderTargetChip(target.id, target.label, branch.resultFalseBlocks.includes(target.id), forkIndex, branchIndex, "false") + ) + .join("")}
`; +} - // Event bindings - panelEl.querySelector(".jf-panel__close")?.addEventListener("click", () => { - setState({ selectedNodeId: null }); - }); +function renderTargetChip( + id: string, + label: string, + active: boolean, + forkIndex: number, + branchIndex: number, + branchType: "true" | "false" +): string { + return ` + + `; +} - panelEl.querySelector('[data-field="name"]')?.addEventListener("input", (e) => { - updateNode(node.id, { name: (e.target as HTMLInputElement).value }); - }); +function handleClick(event: Event) { + const target = event.target as HTMLElement; + + if (target.closest(".jf-collapsible__toggle")) { + panelEl.classList.toggle("jf-collapsible--collapsed"); + const btn = panelEl.querySelector(".jf-collapsible__toggle"); + if (btn) btn.textContent = panelEl.classList.contains("jf-collapsible--collapsed") ? "▸" : "▾"; + return; + } + + const actionEl = target.closest("[data-action]"); + if (!actionEl) return; + + const action = actionEl.dataset.action; + const node = getCurrentNode(); + if (!node) return; - panelEl.querySelector('[data-field="id"]')?.addEventListener("change", (e) => { - const newId = (e.target as HTMLInputElement).value.trim(); - if (newId && newId !== node.id) { - // Update references in other nodes - const { nodes } = getState(); - nodes.forEach((n) => { - if (n.parentBlocks.includes(node.id)) { - updateNode(n.id, { - parentBlocks: n.parentBlocks.map((p) => (p === node.id ? newId : p)), - }); - } + switch (action) { + case "close": + setState({ selectedNodeId: null }); + return; + case "delete": + removeNode(node.id); + return; + case "add-expression": { + const type = (panelEl.querySelector('[data-field="new-expression-type"]') as HTMLSelectElement | null)?.value ?? "Math"; + forcePanelRefresh(); + updateNode(node.id, { expressions: [...node.expressions, createExpression(type, node.expressions)] }); + return; + } + case "remove-expression": { + if (node.expressions.length === 1) return; + const expressionIndex = Number(actionEl.dataset.expressionIndex); + forcePanelRefresh(); + updateNode(node.id, { expressions: node.expressions.filter((_, index) => index !== expressionIndex) }); + return; + } + case "add-fork": + forcePanelRefresh(); + updateNode(node.id, { + forks: [ + ...node.forks, + { + id: generateId("fork"), + name: `Fork ${node.forks.length + 1}`, + branches: [createForkBranch()], + }, + ], }); - updateNode(node.id, { id: newId }); + return; + case "remove-fork": { + const forkIndex = Number(actionEl.dataset.forkIndex); + forcePanelRefresh(); + updateNode(node.id, { forks: node.forks.filter((_, index) => index !== forkIndex) }); + return; } - }); - - // Expression parameter editing - panelEl.querySelectorAll("[data-expr]").forEach((input) => { - input.addEventListener("input", (e) => { - const exprIdx = parseInt(input.dataset.expr!, 10); - const paramName = input.dataset.param!; - const newExprs = [...node.expressions]; - newExprs[exprIdx] = { - ...newExprs[exprIdx], - parameters: newExprs[exprIdx].parameters.map((p) => - p.name === paramName ? { ...p, value: (e.target as HTMLInputElement).value } : p + case "add-branch": { + const forkIndex = Number(actionEl.dataset.forkIndex); + forcePanelRefresh(); + const forks = node.forks.map((fork, index) => + index === forkIndex ? { ...fork, branches: [...fork.branches, createForkBranch()] } : fork + ); + updateNode(node.id, { forks }); + return; + } + case "remove-branch": { + const forkIndex = Number(actionEl.dataset.forkIndex); + const branchIndex = Number(actionEl.dataset.branchIndex); + forcePanelRefresh(); + const forks = node.forks.map((fork, index) => + index === forkIndex + ? { ...fork, branches: fork.branches.filter((_, nestedIndex) => nestedIndex !== branchIndex) } + : fork + ); + updateNode(node.id, { forks }); + return; + } + case "toggle-parent": { + const parentId = actionEl.dataset.parentId!; + const nextParents = node.parentBlocks.includes(parentId) + ? node.parentBlocks.filter((parent) => parent !== parentId) + : [...node.parentBlocks, parentId]; + forcePanelRefresh(); + updateNode(node.id, { parentBlocks: dedupeIds(nextParents) }); + return; + } + case "toggle-branch-target": { + const forkIndex = Number(actionEl.dataset.forkIndex); + const branchIndex = Number(actionEl.dataset.branchIndex); + const branchType = actionEl.dataset.branchTargetType as "true" | "false"; + const targetId = actionEl.dataset.targetId!; + forcePanelRefresh(); + updateNode(node.id, { + forks: node.forks.map((fork, index) => { + if (index !== forkIndex) return fork; + return { + ...fork, + branches: fork.branches.map((branch, nestedIndex) => { + if (nestedIndex !== branchIndex) return branch; + const currentTargets = branchType === "true" ? branch.resultTrueBlocks : branch.resultFalseBlocks; + const nextTargets = currentTargets.includes(targetId) + ? currentTargets.filter((id) => id !== targetId) + : [...currentTargets, targetId]; + return branchType === "true" + ? { ...branch, resultTrueBlocks: dedupeIds(nextTargets) } + : { ...branch, resultFalseBlocks: dedupeIds(nextTargets) }; + }), + }; + }), + }); + return; + } + case "insert-reference": { + const expressionIndex = Number(actionEl.dataset.expressionIndex); + const reference = actionEl.dataset.reference ?? ""; + forcePanelRefresh(); + const expressions = node.expressions.map((expression, index) => + index !== expressionIndex + ? expression + : { + ...expression, + parameters: expression.parameters.map((parameter, parameterIndex) => + parameterIndex === 0 ? { ...parameter, value: appendReference(parameter.value, reference) } : parameter + ), + } + ); + updateNode(node.id, { expressions }); + return; + } + case "insert-branch-reference": { + const forkIndex = Number(actionEl.dataset.forkIndex); + const branchIndex = Number(actionEl.dataset.branchIndex); + const reference = actionEl.dataset.reference ?? ""; + forcePanelRefresh(); + updateNode(node.id, { + forks: node.forks.map((fork, index) => + index !== forkIndex + ? fork + : { + ...fork, + branches: fork.branches.map((branch, nestedIndex) => + nestedIndex !== branchIndex + ? branch + : { + ...branch, + statement: insertBranchReference(branch.statement, reference), + } + ), + } ), - }; - updateNode(node.id, { expressions: newExprs }); + }); + return; + } + } +} + +function handleInput(event: Event) { + const target = event.target as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement; + const node = getCurrentNode(); + if (!node) return; + + if (target.dataset.paramName) { + const expressionIndex = Number(target.dataset.expressionIndex); + const paramName = target.dataset.paramName; + updateNode(node.id, { + expressions: node.expressions.map((expression, index) => + index !== expressionIndex + ? expression + : { + ...expression, + parameters: expression.parameters.map((parameter) => + parameter.name === paramName ? { ...parameter, value: target.value } : parameter + ), + } + ), }); - }); + return; + } - // Add parent - panelEl.querySelector('[data-action="add-parent"]')?.addEventListener("click", () => { - const other = getState().nodes.filter((n) => n.id !== node.id && !node.parentBlocks.includes(n.id)); - if (other.length === 0) { - // Set as root - updateNode(node.id, { parentBlocks: [...node.parentBlocks, "workflow"] }); - } else { - // Pick first available (in a real app we'd show a picker) - const picked = prompt("Enter parent block ID:", other[0]?.id ?? "workflow"); - if (picked) { - updateNode(node.id, { parentBlocks: [...node.parentBlocks, picked] }); - } - } - currentNodeId = null; // force re-render - }); + if (target.dataset.forkField === "name") { + const forkIndex = Number(target.dataset.forkIndex); + updateNode(node.id, { + forks: node.forks.map((fork, index) => (index === forkIndex ? { ...fork, name: target.value } : fork)), + }); + return; + } + + if (target.dataset.branchField === "statement") { + const forkIndex = Number(target.dataset.forkIndex); + const branchIndex = Number(target.dataset.branchIndex); + updateNode(node.id, { + forks: node.forks.map((fork, index) => + index !== forkIndex + ? fork + : { + ...fork, + branches: fork.branches.map((branch, nestedIndex) => + nestedIndex === branchIndex ? { ...branch, statement: target.value } : branch + ), + } + ), + }); + } +} + +function handleChange(event: Event) { + const target = event.target as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement; + const node = getCurrentNode(); + if (!node) return; + + if (target.dataset.field === "name") { + renameNodeDisplay(node, target.value); + return; + } - // Delete - panelEl.querySelector('[data-action="delete"]')?.addEventListener("click", () => { - removeNode(node.id); + if (target.dataset.expressionField === "name") { + const expressionIndex = Number(target.dataset.expressionIndex); + renameExpressionResult(node, expressionIndex, target.value); + return; + } + + if (target.dataset.expressionField === "type") { + const expressionIndex = Number(target.dataset.expressionIndex); + forcePanelRefresh(); + updateNode(node.id, { + expressions: node.expressions.map((expression, index) => + index === expressionIndex + ? { + ...expression, + type: target.value, + parameters: createExpression(target.value).parameters, + } + : expression + ), + }); + return; + } + + if (target.dataset.branchField === "statement") { + const forkIndex = Number(target.dataset.forkIndex); + const branchIndex = Number(target.dataset.branchIndex); + forcePanelRefresh(); + updateNode(node.id, { + forks: node.forks.map((fork, index) => + index !== forkIndex + ? fork + : { + ...fork, + branches: fork.branches.map((branch, nestedIndex) => + nestedIndex === branchIndex ? { ...branch, statement: parseStatementInput(target.value) } : branch + ), + } + ), + }); + } +} + +function renameNodeDisplay(node: NodeData, nextValue: string) { + const nextName = nextValue.trim() || node.name; + if (nextName === node.name) { + updateNode(node.id, { name: nextName }); + return; + } + + const { nodes } = getState(); + const renamedNodes = nodes.map((candidate) => (candidate.id === node.id ? { ...candidate, name: nextName } : candidate)); + applyReferenceAwareNodes(nodes, renamedNodes); +} + +function renameExpressionResult(node: NodeData, expressionIndex: number, nextValue: string) { + const expression = node.expressions[expressionIndex]; + if (!expression) return; + + const nextName = getNormalizedExpressionName(node, expressionIndex, nextValue); + if (nextName === expression.name) { + updateNode(node.id, { + expressions: node.expressions.map((candidate, index) => + index === expressionIndex ? { ...candidate, name: nextName } : candidate + ), + }); + return; + } + + const nextExpressions = node.expressions.map((candidate, index) => + index === expressionIndex ? { ...candidate, name: nextName } : candidate + ); + const { nodes } = getState(); + const renamedNodes = nodes.map((candidate) => + candidate.id === node.id ? { ...candidate, expressions: nextExpressions } : candidate + ); + applyReferenceAwareNodes(nodes, renamedNodes); +} + +function applyReferenceAwareNodes(previousNodes: NodeData[], nextNodes: NodeData[]) { + forcePanelRefresh(); + setState({ nodes: rewriteReferences(previousNodes, nextNodes) }); +} + +function rewriteReferences(previousNodes: NodeData[], nextNodes: NodeData[]): NodeData[] { + const previousBlockReferences = createBlockReferenceLookup(previousNodes); + const nextBlockReferences = createBlockReferenceLookup(nextNodes); + const blockChanges = previousNodes + .map((node) => ({ + previous: previousBlockReferences.get(node.id) ?? node.id, + next: nextBlockReferences.get(node.id) ?? node.id, + })) + .filter((change) => change.previous !== change.next); + + const expressionChanges = previousNodes.flatMap((previousNode) => { + const nextNode = nextNodes.find((candidate) => candidate.id === previousNode.id); + if (!nextNode) return []; + + const previousExpressions = createExpressionReferenceLookup(previousNode.expressions); + const nextExpressions = createExpressionReferenceLookup(nextNode.expressions); + const previousBlockKey = previousBlockReferences.get(previousNode.id) ?? previousNode.id; + const nextBlockKey = nextBlockReferences.get(previousNode.id) ?? previousNode.id; + + return previousNode.expressions + .map((expression) => { + const previousKey = previousExpressions.get(expression.id); + const nextKey = nextExpressions.get(expression.id); + if (!previousKey || !nextKey || previousKey === nextKey) return null; + return { previousBlockKey, nextBlockKey, previousKey, nextKey }; + }) + .filter((change): change is NonNullable => Boolean(change)); }); + + if (blockChanges.length === 0 && expressionChanges.length === 0) { + return nextNodes; + } + + return nextNodes.map((node) => ({ + ...node, + expressions: node.expressions.map((expression) => ({ + ...expression, + parameters: expression.parameters.map((parameter) => ({ + ...parameter, + value: rewriteReferenceText(parameter.value, blockChanges, expressionChanges), + })), + })), + forks: node.forks.map((fork) => ({ + ...fork, + branches: fork.branches.map((branch) => ({ + ...branch, + statement: rewriteBranchStatement(branch.statement, blockChanges, expressionChanges), + })), + })), + })); +} + +function rewriteBranchStatement( + statement: any, + blockChanges: ReferenceChange[], + expressionChanges: ExpressionReferenceChange[] +) { + const rewritten = rewriteReferenceText(stringifyStatement(statement), blockChanges, expressionChanges); + return typeof statement === "string" ? rewritten : parseStatementInput(rewritten); +} + +function rewriteReferenceText( + value: string, + blockChanges: ReferenceChange[], + expressionChanges: ExpressionReferenceChange[] +): string { + let nextValue = value; + const compiledBlockChanges = blockChanges.map((change) => ({ + ...change, + blockForkPattern: new RegExp(`\\$blocks\\.${escapeRegex(change.previous)}\\.`, "g"), + legacyBlockForkPattern: new RegExp(`\\$${escapeRegex(change.previous)}\\.`, "g"), + })); + const compiledExpressionChanges = expressionChanges.map((change) => ({ + ...change, + currentForkPattern: new RegExp(`\\$current\\.${escapeRegex(change.previousKey)}(?=\\b|\\.)`, "g"), + blockForkPattern: new RegExp( + `\\$blocks\\.${escapeRegex(change.previousBlockKey)}\\.${escapeRegex(change.previousKey)}(?=\\b|\\.)`, + "g" + ), + legacyBlockForkPattern: new RegExp( + `\\$${escapeRegex(change.previousBlockKey)}\\.${escapeRegex(change.previousKey)}(?=\\b|\\.)`, + "g" + ), + legacyCurrentForkPattern: new RegExp(`(? `$${change.nextKey}`); + } + + return nextValue; +} + +function createExpressionReferenceLookup(expressions: ExpressionData[]): Map { + const taken = new Set(); + const lookup = new Map(); + + for (const expression of expressions) { + const key = resolveExpressionReferenceKey(expression, taken); + taken.add(key); + lookup.set(expression.id, key); + } + + return lookup; +} + +function getNormalizedExpressionName(node: NodeData, expressionIndex: number, nextValue: string): string { + const expression = node.expressions[expressionIndex]; + const fallback = fallbackExpressionName(String(expression?.type || "expression"), expressionIndex); + const baseKey = toReferenceKey(nextValue.trim() || fallback, fallback); + const taken = node.expressions + .filter((_, index) => index !== expressionIndex) + .map((candidate) => candidate.name); + return ensureUniqueReferenceKey(baseKey, taken); +} + +interface ReferenceChange { + previous: string; + next: string; +} + +interface ExpressionReferenceChange { + previousBlockKey: string; + nextBlockKey: string; + previousKey: string; + nextKey: string; +} + +function getCurrentNode(): NodeData | null { + const { selectedNodeId, nodes } = getState(); + return nodes.find((node) => node.id === selectedNodeId) ?? null; +} + +function forcePanelRefresh() { + currentNodeId = null; +} + +function getParentOptions(node: NodeData) { + const otherNodes = getState().nodes.filter((candidate) => candidate.id !== node.id); + return [ + { id: "workflow", label: "Workflow root", active: node.parentBlocks.includes("workflow") }, + ...otherNodes.map((candidate) => ({ + id: candidate.id, + label: candidate.name, + active: node.parentBlocks.includes(candidate.id), + })), + ]; +} + +function getBranchTargets(node: NodeData) { + return getState().nodes + .filter((candidate) => candidate.id !== node.id) + .map((candidate) => ({ id: candidate.id, label: candidate.name })); +} + +function getReferenceOptions(node: NodeData, expressionIndex: number) { + const currentBlockReferences = node.expressions.slice(0, expressionIndex).map((expression) => ({ + label: `current.${expression.name}`, + template: `{{current.${expression.name}}}`, + forkToken: `$current.${expression.name}`, + })); + + const blockReferenceLookup = createBlockReferenceLookup(getState().nodes); + const upstreamReferences = collectUpstreamNodes(node.id).flatMap((upstreamNode) => + upstreamNode.expressions.map((expression) => ({ + label: `${upstreamNode.name} → ${expression.name}`, + template: `{{blocks.${blockReferenceLookup.get(upstreamNode.id)}.${expression.name}}}`, + forkToken: `$blocks.${blockReferenceLookup.get(upstreamNode.id)}.${expression.name}`, + })) + ); + + return [...currentBlockReferences, ...upstreamReferences]; +} + +function getForkReferenceOptions(node: NodeData) { + const blockReferenceLookup = createBlockReferenceLookup(getState().nodes); + return [ + ...node.expressions.map((expression) => ({ + label: `current.${expression.name}`, + template: `{{current.${expression.name}}}`, + forkToken: `$current.${expression.name}`, + })), + ...collectUpstreamNodes(node.id).flatMap((upstreamNode) => + upstreamNode.expressions.map((expression) => ({ + label: `${upstreamNode.name} → ${expression.name}`, + template: `{{blocks.${blockReferenceLookup.get(upstreamNode.id)}.${expression.name}}}`, + forkToken: `$blocks.${blockReferenceLookup.get(upstreamNode.id)}.${expression.name}`, + })) + ), + ]; +} + +function collectUpstreamNodes(nodeId: string): NodeData[] { + const { nodes } = getState(); + const visited = new Set(); + const queue = [...(nodes.find((node) => node.id === nodeId)?.parentBlocks ?? [])]; + const upstreamNodes: NodeData[] = []; + let pointer = 0; + + // Use a manual pointer so newly discovered parents can be appended in-place + // as the breadth-first search uncovers more ancestors. + while (pointer < queue.length) { + const parentId = queue[pointer++]; + if (!parentId || parentId === "workflow" || visited.has(parentId)) continue; + visited.add(parentId); + + const parentNode = nodes.find((node) => node.id === parentId); + if (!parentNode) continue; + + upstreamNodes.push(parentNode); + queue.push(...parentNode.parentBlocks); + } + + return upstreamNodes; +} + +function createForkBranch(): ForkBranchData { + return { + statement: ["==", "$result", ""], + resultTrueBlocks: [], + resultFalseBlocks: [], + }; } function renderPlaceholder(): string { @@ -168,11 +886,63 @@ function renderPlaceholder(): string { Properties
-
Select a node to edit its properties
+
Select a node to edit expressions, forks, and references.
`; } +function parseStatementInput(value: string): any { + const trimmed = value.trim(); + if (!trimmed) return ""; + + try { + return JSON.parse(trimmed); + } catch { + return trimmed; + } +} + +function stringifyStatement(statement: any): string { + if (typeof statement === "string") return statement; + try { + return JSON.stringify(statement); + } catch { + return String(statement ?? ""); + } +} + +function appendReference(value: string, reference: string): string { + return value ? `${value} ${reference}`.trim() : reference; +} + +function insertBranchReference(statement: any, reference: string): string { + const current = stringifyStatement(statement); + if (!current.trim()) { + return JSON.stringify(["==", reference, ""]); + } + STANDALONE_RESULT_PATTERN.lastIndex = 0; + if (STANDALONE_RESULT_PATTERN.test(current)) { + // Replace only standalone `$result` tokens so placeholders like + // `$result_value` or `$results` keep their original meaning. + STANDALONE_RESULT_PATTERN.lastIndex = 0; + return current.replace(STANDALONE_RESULT_PATTERN, () => reference); + } + return appendReference(current, reference); +} + +function escapeRegex(value: string): string { + // Escape user-facing keys before building dynamic RegExp objects. + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function dedupeIds(ids: string[]): string[] { + return Array.from(new Set(ids.filter(Boolean))); +} + +function escapeHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">"); +} + function escapeAttr(s: string): string { - return s.replace(/"/g, """).replace(/ ({ id: expr.id, + name: expr.name, type: expr.type, parameters: expr.parameters.map((p) => ({ id: p.id, @@ -45,15 +47,26 @@ export function deserialize(yamlStr: string) { if (!def) return; const nodes: NodeData[] = (def.blocks ?? []).map((block: any, index: number) => { - const expressions: ExpressionData[] = (block.expressions ?? []).map((expr: any) => ({ - id: expr.id || `expr_${Math.random().toString(36).slice(2, 6)}`, - type: expr.type, - parameters: (expr.parameters ?? []).map((p: any) => ({ - id: p.id || "", - name: p.name, - value: p.value ?? p.defaultValue ?? "", - })), - })); + const takenExpressionNames = new Set(); + const expressions: ExpressionData[] = (block.expressions ?? []).map((expr: any) => { + const id = expr.id || `expr_${Math.random().toString(36).slice(2, 6)}`; + const name = resolveExpressionReferenceKey( + { id, name: expr.name, type: expr.type }, + takenExpressionNames + ); + takenExpressionNames.add(name); + + return { + id, + name, + type: expr.type, + parameters: (expr.parameters ?? []).map((p: any) => ({ + id: p.id || "", + name: p.name, + value: p.value ?? p.defaultValue ?? "", + })), + }; + }); const forks: ForkData[] = (block.forks ?? []).map((fork: any) => ({ id: fork.id || `fork_${Math.random().toString(36).slice(2, 6)}`, diff --git a/ui/state.ts b/ui/state.ts index ce344fc..36badf6 100644 --- a/ui/state.ts +++ b/ui/state.ts @@ -58,6 +58,14 @@ export function removeNode(id: string) { .map((n) => ({ ...n, parentBlocks: n.parentBlocks.filter((p) => p !== id), + forks: n.forks.map((fork) => ({ + ...fork, + branches: fork.branches.map((branch) => ({ + ...branch, + resultTrueBlocks: branch.resultTrueBlocks.filter((targetId) => targetId !== id), + resultFalseBlocks: branch.resultFalseBlocks.filter((targetId) => targetId !== id), + })), + })), })), selectedNodeId: state.selectedNodeId === id ? null : state.selectedNodeId, }; diff --git a/ui/types.ts b/ui/types.ts index 47ba332..67be39f 100644 --- a/ui/types.ts +++ b/ui/types.ts @@ -17,6 +17,7 @@ export interface NodeData { export interface ExpressionData { id: string; + name: string; type: string; parameters: { id: string; name: string; value: string }[]; } diff --git a/workflow/expression.ts b/workflow/expression.ts index 3441fbf..01edd3c 100644 --- a/workflow/expression.ts +++ b/workflow/expression.ts @@ -3,6 +3,7 @@ // A workflow block computation depends on the type of the workflow block expression. // Workflow block expression types: Math, Data, and Control. import { evaluate } from "mathjs"; +import { toReferenceKey } from "./referenceKeys"; /** * There are 3 WorkflowBlockExpressionTypes: Math, Data, and Control. @@ -81,7 +82,7 @@ export class WorkflowExpression { constructor(id: string, name: string, parameters: WorkflowExpressionParameter[], withResult: WorkflowExpression | null = null) { this.id = id; - this.name = name; + this.name = resolveExpressionName({ id, name }); this.parameters = {}; this.withResult = withResult; for (let param of parameters) { @@ -119,22 +120,33 @@ export class WorkflowExpression { withResult = WorkflowExpression.fromObject(obj.withResult); } + const expressionName = resolveExpressionName(obj); + // use WorkflowExpressionType to instantiate the correct type if (obj.type === WorkflowExpressionType.Math) { - return new WorkflowExpressionMath(obj.id, obj.name, parameters, withResult); + return new WorkflowExpressionMath(obj.id, expressionName, parameters, withResult); } else if (obj.type === WorkflowExpressionType.ConsoleLog) { - return new WorkflowExpressionConsoleLog(obj.id, obj.name, parameters, withResult); + return new WorkflowExpressionConsoleLog(obj.id, expressionName, parameters, withResult); } else if (obj.type === WorkflowExpressionType.Wait) { - return new WorkflowExpressionWait(obj.id, obj.name, parameters, withResult); + return new WorkflowExpressionWait(obj.id, expressionName, parameters, withResult); } else if (obj.type === WorkflowExpressionType.HTTPRequest) { - return new WorkflowExpressionHTTPRequest(obj.id, obj.name, parameters, withResult); + return new WorkflowExpressionHTTPRequest(obj.id, expressionName, parameters, withResult); } throw new Error('Unknown workflow expression type: ' + obj.type); } + + protected createResult(type: WorkflowExpressionResultType, value: any): WorkflowExpressionResult { + const resultKey = this.getResultKey(); + return new WorkflowExpressionResult(this.id || resultKey, resultKey, type, stringifyResultValue(value)); + } + + protected getResultKey(): string { + return toReferenceKey(this.name || this.id || "expression", "expression"); + } } // WorkflowBlockExpressionMath is a workflow block expression that performs a mathematical operation. @@ -151,14 +163,12 @@ export class WorkflowExpressionMath extends WorkflowExpression { */ public async compute(context: any = {}): Promise { // FIXME: this.parameters.expression is untyped - let result = evaluate(this.parameters.expression); - if (context) { - result = this.contextualize(context, result); - } + const contextualizedExpression = this.contextualize(context, this.parameters.expression); + let result = evaluate(contextualizedExpression); if (this.withResult) { result = await this.withResult.compute(result); } - return new WorkflowExpressionResult("result", "result", WorkflowExpressionResultType.String, result.toString()); + return this.createResult(WorkflowExpressionResultType.String, result); } } @@ -182,7 +192,7 @@ export class WorkflowExpressionConsoleLog extends WorkflowExpression { result = await this.withResult.compute(result); } console.log(result); - return new WorkflowExpressionResult("", "", WorkflowExpressionResultType.String, result.toString()); + return this.createResult(WorkflowExpressionResultType.String, result); } } @@ -206,7 +216,7 @@ export class WorkflowExpressionWait extends WorkflowExpression { result = await this.withResult.compute(result); } await new Promise(resolve => setTimeout(resolve, result * 1000)); - return new WorkflowExpressionResult("", "", WorkflowExpressionResultType.String, result.toString()); + return this.createResult(WorkflowExpressionResultType.String, result); } } @@ -221,10 +231,13 @@ export class WorkflowExpressionHTTPRequest extends WorkflowExpression { * @returns The result of an HTTP request wrapped in a WorkflowExpressionResult. */ public async compute(context: any = {}): Promise { - const { url, method, headers, data } = this.parameters; + const url = this.contextualize(context, this.parameters.url); + const method = this.contextualize(context, this.parameters.method); + const headers = this.parameters.headers ? this.contextualize(context, this.parameters.headers) : undefined; + const data = this.parameters.data ? this.contextualize(context, this.parameters.data) : undefined; const response = await fetch(url, { method: method, - headers: headers, + headers: headers as any, body: data }); const json = await response.json(); @@ -234,7 +247,7 @@ export class WorkflowExpressionHTTPRequest extends WorkflowExpression { } else { result = JSON.stringify(json); } - return new WorkflowExpressionResult("", "", WorkflowExpressionResultType.String, result.toString()); + return this.createResult(WorkflowExpressionResultType.String, result); } } @@ -251,3 +264,23 @@ export class WorkflowExpressionResult { this.value = value; } } + +function resolveExpressionName(obj: { id?: string; name?: string; type?: string }): string { + return toReferenceKey(obj.name || obj.id || obj.type || "expression", "expression"); +} + +function stringifyResultValue(value: any): string { + if (typeof value === "string") { + return value; + } + + if (value != null && typeof value === "object") { + try { + return JSON.stringify(value); + } catch { + return String(value); + } + } + + return String(value); +} diff --git a/workflow/parser.ts b/workflow/parser.ts index beb3be6..9746b0c 100644 --- a/workflow/parser.ts +++ b/workflow/parser.ts @@ -2,6 +2,7 @@ import { Workflow } from "./index"; import { WorkflowBlock } from "./block"; import { WorkflowExpression, WorkflowExpressionParameter } from "./expression"; import { WorkflowFork } from "./fork"; +import { resolveExpressionReferenceKey } from "./referenceKeys"; // The Parser class parses a workflow definition and returns a Workflow object. @@ -20,8 +21,20 @@ export class WorkflowParser { let parentBlocks: string[] = blockDefinition.parentBlocks ?? []; // parse expressions - let expressions: WorkflowExpression[] = (blockDefinition.expressions ?? []).map((expressionDefinition: any) => - WorkflowExpression.fromObject(expressionDefinition)); + const takenExpressionNames = new Set(); + let expressions: WorkflowExpression[] = (blockDefinition.expressions ?? []).map((expressionDefinition: any) => { + const expressionName = resolveExpressionReferenceKey({ + id: expressionDefinition.id, + name: expressionDefinition.name, + type: expressionDefinition.type, + }, takenExpressionNames); + takenExpressionNames.add(expressionName); + + return WorkflowExpression.fromObject({ + ...expressionDefinition, + name: expressionName, + }); + }); // parse forks let forks: WorkflowFork[] = (blockDefinition.forks ?? []).map((forkDefinition: any) => diff --git a/workflow/referenceKeys.ts b/workflow/referenceKeys.ts new file mode 100644 index 0000000..01d23cb --- /dev/null +++ b/workflow/referenceKeys.ts @@ -0,0 +1,71 @@ +export interface ReferenceSource { + id?: string; + name?: string; + type?: string; +} + +// Prefix digit-starting keys so they remain valid template/fork identifiers. +const NUMERIC_REFERENCE_PREFIX = "n_"; + +export function toReferenceKey(value: string | undefined | null, fallback = "value"): string { + const normalized = normalizeReferenceValue(value); + if (normalized) { + return normalized; + } + + const fallbackNormalized = normalizeReferenceValue(fallback); + return fallbackNormalized || "value"; +} + +export function ensureUniqueReferenceKey(baseKey: string, taken: Iterable): string { + const normalizedBaseKey = toReferenceKey(baseKey, "value"); + const used = new Set(taken); + + if (!used.has(normalizedBaseKey)) { + return normalizedBaseKey; + } + + let counter = 2; + let candidate = `${normalizedBaseKey}_${counter}`; + while (used.has(candidate)) { + counter += 1; + candidate = `${normalizedBaseKey}_${counter}`; + } + return candidate; +} + +export function resolveExpressionReferenceKey(source: ReferenceSource, taken: Iterable = []): string { + const fallback = source.type ? toReferenceKey(source.type, "expression") : "expression"; + const baseKey = toReferenceKey(source.name || source.id || fallback, fallback); + return ensureUniqueReferenceKey(baseKey, taken); +} + +export function createBlockReferenceLookup(blocks: T[]): Map { + const taken = new Set(); + const lookup = new Map(); + + for (const block of blocks) { + const key = ensureUniqueReferenceKey(toReferenceKey(block.name || block.id, "block"), taken); + taken.add(key); + lookup.set(block.id, key); + } + + return lookup; +} + +function normalizeReferenceValue(value: string | undefined | null): string { + const normalized = String(value ?? "") + .trim() + // Convert camelCase/PascalCase names into snake_case reference tokens. + .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .replace(/[^A-Za-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") + .toLowerCase(); + + if (!normalized) { + return ""; + } + + // Prefix digit-starting names so they remain valid reference identifiers. + return /^\d/.test(normalized) ? `${NUMERIC_REFERENCE_PREFIX}${normalized}` : normalized; +} diff --git a/workflow/runner.ts b/workflow/runner.ts index bd7786b..8bb0667 100644 --- a/workflow/runner.ts +++ b/workflow/runner.ts @@ -5,6 +5,7 @@ import { Workflow, WorkflowResult } from "./index"; import { WorkflowBlockResult, WorkflowBlock } from "./block"; import { WorkflowExpression, WorkflowExpressionResult } from "./expression"; +import { createBlockReferenceLookup, toReferenceKey } from "./referenceKeys"; export enum BlockState { NotStarted, @@ -17,12 +18,14 @@ export class WorkflowRunner { public workflow: Workflow; public onBlockFinished: (block: WorkflowBlock, result: WorkflowBlockResult) => void = (block: WorkflowBlock, result: WorkflowBlockResult) => { }; private state: { [key: string]: BlockState }; + private readonly blockReferenceKeys: Map; private static MAX_DEPTH = 100; constructor(workflow: Workflow) { this.workflow = workflow; this.state = {}; + this.blockReferenceKeys = createBlockReferenceLookup(workflow.blocks); } public async run(): Promise { @@ -34,7 +37,8 @@ export class WorkflowRunner { rootBlocks.push(block); } } - const results = await Promise.all(rootBlocks.map(block => this.executeBlockTree(block, 0))); + const baseContext = this.createContext(); + const results = await Promise.all(rootBlocks.map(block => this.executeBlockTree(block, 0, baseContext))); return new WorkflowResult(this.workflow.id, this.workflow.name, results.flat(), new Date(), new Date(), new Date()); } @@ -45,17 +49,20 @@ export class WorkflowRunner { } } - private async executeBlockTree(block: WorkflowBlock, depth: number): Promise { + private async executeBlockTree(block: WorkflowBlock, depth: number, context: ExecutionContext): Promise { if (depth > WorkflowRunner.MAX_DEPTH) { throw new Error(`Workflow exceeded maximum depth of ${WorkflowRunner.MAX_DEPTH} blocks`); } try { - const result = await this.executeBlock(block); + const result = await this.executeBlock(block, context); this.setBlockState(block, BlockState.Finished, result); - const nextBlocks = [...this.findChildBlocks(block), ...this.evaluateFork(block, result)]; - const nestedResults = await Promise.all(nextBlocks.map(nextBlock => this.executeBlockTree(nextBlock, depth + 1))); + const nextContext = this.extendContext(context, block, result); + // A block can be reachable via both a direct parent edge and a fork + // branch, so collapse duplicates before recursing further. + const uniqueNextBlocks = this.dedupeBlocks([...this.findChildBlocks(block), ...this.evaluateFork(block, result, nextContext)]); + const nestedResults = await Promise.all(uniqueNextBlocks.map(nextBlock => this.executeBlockTree(nextBlock, depth + 1, nextContext))); return [result, ...nestedResults.flat()]; } catch (err) { @@ -64,28 +71,46 @@ export class WorkflowRunner { } } - private async executeBlock(block: WorkflowBlock): Promise { + private async executeBlock(block: WorkflowBlock, context: ExecutionContext): Promise { this.setBlockState(block, BlockState.Running); - const expressionsResults = await this.evaluateExpressions(block.expressions); - // Flatten the expressionsResults into a single object with all of the results.name and results.value properties + const expressionsResults = await this.evaluateExpressions(block.expressions, context); const resultsObject: { [key: string]: any } = {}; for (const result of expressionsResults) { resultsObject[result.name] = result.value; + // Preserve the stored result key while also exposing a normalized + // alias for template/fork references that require identifier-safe keys. + const normalizedResultKey = toReferenceKey(result.name, "expression"); + resultsObject[normalizedResultKey] = result.value; + } + // Keep a stable `result` alias for existing forks/samples while letting + // richer blocks expose additional named expression outputs. Explicit + // `result` expressions take precedence over this implicit fallback. + const lastExpressionResult = expressionsResults.at(-1); + if (lastExpressionResult && lastExpressionResult.name !== "result" && !("result" in resultsObject)) { + resultsObject.result = lastExpressionResult.value; } return new WorkflowBlockResult(block.id, block.name, "String", resultsObject); } - private async evaluateExpressions(expressions: WorkflowExpression[]): Promise { - // Compute all expressions by calling expression.compute() at the same time and return the results as an array of Promises - const results = await Promise.all(expressions.map(e => e.compute())); + private async evaluateExpressions(expressions: WorkflowExpression[], context: ExecutionContext): Promise { + const results: WorkflowExpressionResult[] = []; + const currentResults: { [key: string]: any } = {}; + for (const expression of expressions) { + const scopedContext = this.toExpressionContext(context, currentResults); + const result = await expression.compute(scopedContext); + results.push(result); + currentResults[result.name] = result.value; + currentResults[toReferenceKey(result.name, "expression")] = result.value; + // Keep `result` pointed at the latest expression output so later + // expressions can reference the most recent value with {{result}}. + currentResults.result = result.value; + } return results; - } - private evaluateFork(block: WorkflowBlock, results: WorkflowBlockResult): WorkflowBlock[] { - // First, flatten the results into a single object with all of the results.name and results.value properties + private evaluateFork(block: WorkflowBlock, blockResult: WorkflowBlockResult, context: ExecutionContext): WorkflowBlock[] { const blocksToExecute: WorkflowBlock[] = []; - const resultsObject = results.value; + const resultsObject = this.createScopedContext(context, blockResult.value); // for each fork, evaluate them and return the results for (const fork of block.forks) { // Evaluate the fork @@ -104,4 +129,56 @@ export class WorkflowRunner { // Filter through workflow blocks and find all blocks that are children of this block return this.workflow.blocks.filter(b => b.parentBlocks.includes(block.id)); } + + private dedupeBlocks(blocks: WorkflowBlock[]): WorkflowBlock[] { + const seen = new Set(); + return blocks.filter(block => { + if (seen.has(block.id)) return false; + seen.add(block.id); + return true; + }); + } + + private createContext(): ExecutionContext { + return { + blocks: {}, + }; + } + + private extendContext(context: ExecutionContext, block: WorkflowBlock, result: WorkflowBlockResult): ExecutionContext { + const blockResults = result.value; + const blockReferenceKey = this.blockReferenceKeys.get(block.id) ?? block.id; + return { + blocks: { + ...context.blocks, + [block.id]: blockResults, + [blockReferenceKey]: blockResults, + }, + lastResult: blockResults.result, + }; + } + + private toExpressionContext(context: ExecutionContext, currentResults: { [key: string]: any }): any { + return this.createScopedContext(context, currentResults, currentResults.result ?? context.lastResult); + } + + private createScopedContext( + context: ExecutionContext, + currentResults: { [key: string]: any }, + resultOverride?: any, + ): any { + return { + ...context.blocks, + ...currentResults, + current: currentResults, + workflow: context.blocks, + blocks: context.blocks, + result: resultOverride ?? currentResults.result, + }; + } +} + +interface ExecutionContext { + blocks: Record; + lastResult?: any; } \ No newline at end of file