From 97f6ead03bea00307e9b25a582529d6718424903 Mon Sep 17 00:00:00 2001 From: dtoro Date: Fri, 6 Mar 2026 22:31:24 +0100 Subject: [PATCH] feat: variable node --- src/App.tsx | 20 +++- src/components/graph/ConfigNode.tsx | 63 +++++++++++- src/components/graph/RenderingNode.tsx | 34 ++++++- src/components/graph/VariableNode.tsx | 133 +++++++++++++++++++++++++ 4 files changed, 242 insertions(+), 8 deletions(-) create mode 100644 src/components/graph/VariableNode.tsx diff --git a/src/App.tsx b/src/App.tsx index 8ef4a05..9504f15 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,6 +16,7 @@ import ReactFlow, { } from 'reactflow' import ConfigNode from './components/graph/ConfigNode' import RenderingNode from './components/graph/RenderingNode' +import VariableNode from './components/graph/VariableNode' import FlowContext from './lib/flowContext' import { ContextMenu, @@ -55,7 +56,7 @@ export default function App() { const [contextTarget, setContextTarget] = React.useState(null) const nodeTypes = React.useMemo( - () => ({ config: ConfigNode, render: RenderingNode }), + () => ({ config: ConfigNode, render: RenderingNode, variable: VariableNode }), [] ) @@ -104,11 +105,21 @@ export default function App() { } const id = genId() + const typeMap: Record = { + config: 'config', + render: 'render', + variable: 'variable', + } + const dataMap: Record = { + config: { yaml: '# Enter YAML here\n', title: `config-${id}` }, + render: {}, + variable: { value: '', valueType: 'string' }, + } const newNode: Node = { id, - type: type === 'config' ? 'config' : 'render', + type: typeMap[type] ?? 'config', position: { x: position.x, y: position.y }, - data: type === 'config' ? { yaml: '# Enter YAML here\n', title: `config-${id}` } : {}, + data: dataMap[type] ?? {}, } setNodes((nds) => nds.concat(newNode)) lastClickRef.current = null @@ -141,7 +152,7 @@ export default function App() { onInit={onInit} > - ß + @@ -156,6 +167,7 @@ export default function App() { New Node createNode('config')}>Config createNode('render')}>Renderer + createNode('variable')}>Variable )} diff --git a/src/components/graph/ConfigNode.tsx b/src/components/graph/ConfigNode.tsx index 8216520..bb76a74 100644 --- a/src/components/graph/ConfigNode.tsx +++ b/src/components/graph/ConfigNode.tsx @@ -44,6 +44,8 @@ export const ConfigNode = memo(function ConfigNode({ id, data }: Props) { const incomingEdges = ctx?.edges?.filter((e: any) => e.target === id) ?? [] const incomingIds = incomingEdges.map((e: any) => e.source).sort() const connectedConfigNodes = (ctx?.nodes ?? []).filter((n: any) => incomingIds.includes(n.id) && n.type === 'config') + const connectedVariableNodes = (ctx?.nodes ?? []).filter((n: any) => incomingIds.includes(n.id) && n.type === 'variable') + const hasDependencies = connectedConfigNodes.length > 0 || connectedVariableNodes.length > 0 useEffect(() => { if (setNodes) { @@ -129,6 +131,48 @@ export const ConfigNode = memo(function ConfigNode({ id, data }: Props) { [onChange, value] ) + const insertVariableReference = useCallback( + (sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => { + const insertText = `\${${sourceNode.id}}` + const editor = editorRef.current + const monaco = monacoRef.current + + try { + if (editor && monaco) { + const model = editor.getModel() + const lineCount = model.getLineCount() + const selection = editor.getSelection() + + let range + if (mode === 'prepend') { + range = new monaco.Range(1, 1, 1, 1) + } else if (mode === 'append') { + range = new monaco.Range(lineCount + 1, 1, lineCount + 1, 1) + } else if (selection) { + range = new monaco.Range(selection.startLineNumber, selection.startColumn, selection.startLineNumber, selection.startColumn) + } else { + range = new monaco.Range(lineCount + 1, 1, lineCount + 1, 1) + } + + editor.executeEdits('insert-var', [{ range, text: insertText, forceMoveMarkers: true }]) + const newVal = editor.getModel().getValue() + onChange(newVal) + return + } + + if (mode === 'prepend') { + onChange(insertText + value) + } else { + onChange(value + insertText) + } + } catch (err) { + // eslint-disable-next-line no-console + console.error('Insert variable reference failed', err) + } + }, + [onChange, value] + ) + return ( @@ -137,7 +181,7 @@ export const ConfigNode = memo(function ConfigNode({ id, data }: Props) { - {connectedConfigNodes.length > 0 && ( + {hasDependencies && (
@@ -146,7 +190,7 @@ export const ConfigNode = memo(function ConfigNode({ id, data }: Props) { {connectedConfigNodes.map((n: any) => ( - + {n.data?.title ?? n.id} @@ -172,6 +216,21 @@ export const ConfigNode = memo(function ConfigNode({ id, data }: Props) { ))} + {connectedVariableNodes.map((n: any) => ( + + + {n.id} + + + insertVariableReference(n, 'cursor')} + > + Insert at cursor + + + + ))} diff --git a/src/components/graph/RenderingNode.tsx b/src/components/graph/RenderingNode.tsx index bb14c42..fa51656 100644 --- a/src/components/graph/RenderingNode.tsx +++ b/src/components/graph/RenderingNode.tsx @@ -52,6 +52,15 @@ export const RenderingNode = memo(function RenderingNode({ id }: Props) { [edges] ) + const variablesSignature = useMemo( + () => + nodes + .filter((n: any) => n.type === 'variable') + .map((n: any) => `${n.id}:${n.data?.value}`) + .join('|'), + [nodes] + ) + useEffect(() => { // debug // eslint-disable-next-line no-console @@ -73,6 +82,8 @@ export const RenderingNode = memo(function RenderingNode({ id }: Props) { } try { + const configIdsUsed = new Set() + const resolveIncludes = (text: string, visited = new Set()): string => { const includeRegex = /^(\s*)!include\s+([^\s]+)\s*$/gm return text.replace(includeRegex, (match: string, indent: string, ref: string) => { @@ -100,6 +111,7 @@ export const RenderingNode = memo(function RenderingNode({ id }: Props) { if (!isReachable(refNode.id, id)) throw new Error(`Included node not connected to renderer: ${ref}`) visited.add(refNode.id) + if (refNode.type === 'config') configIdsUsed.add(refNode.id) const includedRaw = String(refNode.data?.yaml ?? '') const resolved = resolveIncludes(includedRaw, visited) visited.delete(refNode.id) @@ -112,8 +124,26 @@ export const RenderingNode = memo(function RenderingNode({ id }: Props) { }) } + if (srcId && srcNode?.type === 'config') configIdsUsed.add(srcId) + const resolvedYaml = resolveIncludes(yamlText) - const parsed = yaml.load(resolvedYaml) + + const varMap: Record = {} + for (const e of edges) { + if (configIdsUsed.has(e.target)) { + const src = nodes.find((n: any) => n.id === e.source) + if (src?.type === 'variable') { + const v = src.data?.value + varMap[src.id] = v === undefined || v === null ? '' : String(v) + } + } + } + + const resolveVariables = (text: string): string => + text.replace(/\$\{([^}]+)\}/g, (_, varId: string) => varMap[varId.trim()] ?? '') + + const resolvedWithVars = resolveVariables(resolvedYaml) + const parsed = yaml.load(resolvedWithVars) const newOutput = JSON.stringify(parsed, null, 2) setError(null) setOutput((prev) => (prev === newOutput ? prev : newOutput)) @@ -122,7 +152,7 @@ export const RenderingNode = memo(function RenderingNode({ id }: Props) { setOutput('') setError({ kind: 'parse', message: msg }) } - }, [id, incomingIds.join(','), yamlText, configSignature, edgesSignature]) + }, [id, incomingIds.join(','), yamlText, configSignature, edgesSignature, variablesSignature]) return ( diff --git a/src/components/graph/VariableNode.tsx b/src/components/graph/VariableNode.tsx new file mode 100644 index 0000000..11b9519 --- /dev/null +++ b/src/components/graph/VariableNode.tsx @@ -0,0 +1,133 @@ +import React, { memo, useCallback, useContext } from 'react' +import FlowContext from '../../lib/flowContext' +import { + BaseNode, + BaseNodeContent, + BaseNodeHeader, + BaseNodeHeaderTitle, +} from './BaseNode' +import { OutputHandle } from './NodeHandles' +import { Variable } from 'lucide-react' + +type ValueType = 'string' | 'number' | 'boolean' + +type Props = { + id: string + data: { + value?: string | number | boolean + valueType?: ValueType + } +} + +const DEFAULT_BY_TYPE: Record = { + string: '', + number: 0, + boolean: false, +} + +function coerceValue(raw: string, valueType: ValueType): string | number | boolean { + switch (valueType) { + case 'number': { + const n = Number(raw) + return Number.isNaN(n) ? 0 : n + } + case 'boolean': + return /^(1|true|yes|on)$/i.test(raw.trim()) + default: + return raw + } +} + +export const VariableNode = memo(function VariableNode({ id, data }: Props) { + const ctx = useContext(FlowContext) + const setNodes = ctx?.setNodes + + const valueType: ValueType = data?.valueType ?? 'string' + const value = data?.value ?? DEFAULT_BY_TYPE[valueType] + const displayValue = typeof value === 'string' ? value : String(value) + + const updateData = useCallback( + (updates: { value?: string | number | boolean; valueType?: ValueType }) => { + if (!setNodes) return + setNodes((nds: any[]) => + nds.map((n) => + n.id === id ? { ...n, data: { ...n.data, ...updates } } : n + ) + ) + }, + [id, setNodes] + ) + + const onTypeChange = useCallback( + (e: React.ChangeEvent) => { + const nextType = e.target.value as ValueType + const raw = typeof value === 'boolean' ? (value ? 'true' : 'false') : String(value) + const nextValue = coerceValue(raw, nextType) + updateData({ valueType: nextType, value: nextValue }) + }, + [value, updateData] + ) + + const onValueChange = useCallback( + (e: React.ChangeEvent) => { + const raw = e.target.type === 'checkbox' ? (e.target.checked ? 'true' : 'false') : e.target.value + const nextValue = coerceValue(raw, valueType) + updateData({ value: nextValue }) + }, + [valueType, updateData] + ) + + return ( + + + + {id} + + + +
+ + +
+
+ + {valueType === 'boolean' ? ( + + ) : ( + + )} +
+

+ Use in config: prop: ${{id}} +

+
+ + +
+ ) +}) + +VariableNode.displayName = 'VariableNode' + +export default VariableNode