feat: variable node

This commit is contained in:
2026-03-06 22:31:24 +01:00
parent 17739de7bd
commit 97f6ead03b
4 changed files with 242 additions and 8 deletions

View File

@@ -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<ValueType, string | number | boolean> = {
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<HTMLSelectElement>) => {
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<HTMLInputElement>) => {
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 (
<BaseNode className="w-56">
<BaseNodeHeader className="border-b">
<Variable className="size-4" />
<BaseNodeHeaderTitle>{id}</BaseNodeHeaderTitle>
</BaseNodeHeader>
<BaseNodeContent className="gap-2">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted-foreground">Type</label>
<select
value={valueType}
onChange={onTypeChange}
className="w-full rounded border border-input bg-background px-2 py-1.5 text-sm"
>
<option value="string">String</option>
<option value="number">Number</option>
<option value="boolean">Boolean</option>
</select>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted-foreground">Value</label>
{valueType === 'boolean' ? (
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={value === true}
onChange={onValueChange}
className="rounded border-input"
/>
{value ? 'true' : 'false'}
</label>
) : (
<input
type={valueType === 'number' ? 'number' : 'text'}
value={valueType === 'number' ? (value as number) : displayValue}
onChange={onValueChange}
className="w-full rounded border border-input bg-background px-2 py-1.5 text-sm"
/>
)}
</div>
<p className="text-[10px] text-muted-foreground">
Use in config: <code className="rounded bg-muted px-0.5">prop: $&#123;{id}&#125;</code>
</p>
</BaseNodeContent>
<OutputHandle id="out" />
</BaseNode>
)
})
VariableNode.displayName = 'VariableNode'
export default VariableNode