feat: [US-059] - Variable node variable dropdown

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Gustavo Henrique Santos Souza de Miranda 2026-01-23 10:25:57 -03:00
parent 548f3743d1
commit 5493adf44a
3 changed files with 237 additions and 67 deletions

View File

@ -23,6 +23,7 @@ import DialogueNode from '@/components/editor/nodes/DialogueNode'
import ChoiceNode from '@/components/editor/nodes/ChoiceNode' import ChoiceNode from '@/components/editor/nodes/ChoiceNode'
import VariableNode from '@/components/editor/nodes/VariableNode' import VariableNode from '@/components/editor/nodes/VariableNode'
import ProjectSettingsModal from '@/components/editor/ProjectSettingsModal' import ProjectSettingsModal from '@/components/editor/ProjectSettingsModal'
import { EditorProvider } from '@/components/editor/EditorContext'
import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable } from '@/types/flowchart' import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable } from '@/types/flowchart'
type FlowchartEditorProps = { type FlowchartEditorProps = {
@ -81,6 +82,31 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
const [variables, setVariables] = useState<Variable[]>(initialData.variables) const [variables, setVariables] = useState<Variable[]>(initialData.variables)
const [showSettings, setShowSettings] = useState(false) const [showSettings, setShowSettings] = useState(false)
const handleAddCharacter = useCallback(
(name: string, color: string): string => {
const id = nanoid()
const newCharacter: Character = { id, name, color }
setCharacters((prev) => [...prev, newCharacter])
return id
},
[]
)
const handleAddVariableDefinition = useCallback(
(name: string, type: 'numeric' | 'string' | 'boolean', initialValue: number | string | boolean): string => {
const id = nanoid()
const newVariable: Variable = { id, name, type, initialValue }
setVariables((prev) => [...prev, newVariable])
return id
},
[]
)
const editorContextValue = useMemo(
() => ({ characters, onAddCharacter: handleAddCharacter, variables, onAddVariable: handleAddVariableDefinition }),
[characters, handleAddCharacter, variables, handleAddVariableDefinition]
)
const getCharacterUsageCount = useCallback( const getCharacterUsageCount = useCallback(
(characterId: string) => { (characterId: string) => {
return nodes.filter((n) => n.type === 'dialogue' && n.data?.characterId === characterId).length return nodes.filter((n) => n.type === 'dialogue' && n.data?.characterId === characterId).length
@ -194,6 +220,7 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
}, []) }, [])
return ( return (
<EditorProvider value={editorContextValue}>
<div className="flex h-full w-full flex-col"> <div className="flex h-full w-full flex-col">
<Toolbar <Toolbar
onAddDialogue={handleAddDialogue} onAddDialogue={handleAddDialogue}
@ -232,6 +259,7 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
/> />
)} )}
</div> </div>
</EditorProvider>
) )
} }

View File

@ -0,0 +1,24 @@
'use client'
import { createContext, useContext } from 'react'
import type { Character, Variable } from '@/types/flowchart'
type EditorContextValue = {
characters: Character[]
onAddCharacter: (name: string, color: string) => string // returns new character id
variables: Variable[]
onAddVariable: (name: string, type: 'numeric' | 'string' | 'boolean', initialValue: number | string | boolean) => string // returns new variable id
}
const EditorContext = createContext<EditorContextValue>({
characters: [],
onAddCharacter: () => '',
variables: [],
onAddVariable: () => '',
})
export const EditorProvider = EditorContext.Provider
export function useEditorContext() {
return useContext(EditorContext)
}

View File

@ -1,23 +1,52 @@
'use client' 'use client'
import { useCallback, ChangeEvent } from 'react' import { useCallback, useMemo, useState, ChangeEvent } from 'react'
import { Handle, Position, NodeProps, useReactFlow } from 'reactflow' import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
import Combobox from '@/components/editor/Combobox'
import type { ComboboxItem } from '@/components/editor/Combobox'
import { useEditorContext } from '@/components/editor/EditorContext'
type VariableNodeData = { type VariableNodeData = {
variableName: string variableName: string
variableId?: string
operation: 'set' | 'add' | 'subtract' operation: 'set' | 'add' | 'subtract'
value: number value: number
} }
export default function VariableNode({ id, data }: NodeProps<VariableNodeData>) { export default function VariableNode({ id, data }: NodeProps<VariableNodeData>) {
const { setNodes } = useReactFlow() const { setNodes } = useReactFlow()
const { variables, onAddVariable } = useEditorContext()
const updateVariableName = useCallback( const [showAddForm, setShowAddForm] = useState(false)
(e: ChangeEvent<HTMLInputElement>) => { const [newName, setNewName] = useState('')
const [newType, setNewType] = useState<'numeric' | 'string' | 'boolean'>('numeric')
const variableItems: ComboboxItem[] = useMemo(
() =>
variables.map((v) => ({
id: v.id,
label: v.name,
badge: v.type,
})),
[variables]
)
const selectedVariable = useMemo(() => {
if (!data.variableId) return undefined
return variables.find((v) => v.id === data.variableId)
}, [data.variableId, variables])
const hasInvalidReference = useMemo(() => {
if (!data.variableId) return false
return !variables.some((v) => v.id === data.variableId)
}, [data.variableId, variables])
const updateNodeData = useCallback(
(updates: Partial<VariableNodeData>) => {
setNodes((nodes) => setNodes((nodes) =>
nodes.map((node) => nodes.map((node) =>
node.id === id node.id === id
? { ...node, data: { ...node.data, variableName: e.target.value } } ? { ...node, data: { ...node.data, ...updates } }
: node : node
) )
) )
@ -25,35 +54,69 @@ export default function VariableNode({ id, data }: NodeProps<VariableNodeData>)
[id, setNodes] [id, setNodes]
) )
const handleVariableSelect = useCallback(
(variableId: string) => {
const variable = variables.find((v) => v.id === variableId)
const updates: Partial<VariableNodeData> = {
variableId,
variableName: variable?.name || '',
}
// Reset operation to 'set' if current operation is not valid for the variable's type
if (variable && variable.type !== 'numeric' && (data.operation === 'add' || data.operation === 'subtract')) {
updates.operation = 'set'
}
updateNodeData(updates)
},
[variables, data.operation, updateNodeData]
)
const handleAddNew = useCallback(() => {
setShowAddForm(true)
setNewName('')
setNewType('numeric')
}, [])
const handleSubmitNew = useCallback(() => {
if (!newName.trim()) return
const defaultValue = newType === 'numeric' ? 0 : newType === 'boolean' ? false : ''
const newId = onAddVariable(newName.trim(), newType, defaultValue)
updateNodeData({
variableId: newId,
variableName: newName.trim(),
})
setShowAddForm(false)
}, [newName, newType, onAddVariable, updateNodeData])
const handleCancelNew = useCallback(() => {
setShowAddForm(false)
}, [])
const updateOperation = useCallback( const updateOperation = useCallback(
(e: ChangeEvent<HTMLSelectElement>) => { (e: ChangeEvent<HTMLSelectElement>) => {
setNodes((nodes) => updateNodeData({ operation: e.target.value as 'set' | 'add' | 'subtract' })
nodes.map((node) =>
node.id === id
? { ...node, data: { ...node.data, operation: e.target.value as 'set' | 'add' | 'subtract' } }
: node
)
)
}, },
[id, setNodes] [updateNodeData]
) )
const updateValue = useCallback( const updateValue = useCallback(
(e: ChangeEvent<HTMLInputElement>) => { (e: ChangeEvent<HTMLInputElement>) => {
const value = parseFloat(e.target.value) || 0 const value = parseFloat(e.target.value) || 0
setNodes((nodes) => updateNodeData({ value })
nodes.map((node) =>
node.id === id
? { ...node, data: { ...node.data, value } }
: node
)
)
}, },
[id, setNodes] [updateNodeData]
) )
// Filter operations based on selected variable type
const isNumeric = !selectedVariable || selectedVariable.type === 'numeric'
return ( return (
<div className="min-w-[200px] rounded-lg border-2 border-orange-500 bg-orange-50 p-3 shadow-md dark:border-orange-400 dark:bg-orange-950"> <div
className={`min-w-[200px] rounded-lg border-2 ${
hasInvalidReference
? 'border-orange-500 dark:border-orange-400'
: 'border-orange-500 dark:border-orange-400'
} bg-orange-50 p-3 shadow-md dark:bg-orange-950`}
>
<Handle <Handle
type="target" type="target"
position={Position.Top} position={Position.Top}
@ -65,13 +128,68 @@ export default function VariableNode({ id, data }: NodeProps<VariableNodeData>)
Variable Variable
</div> </div>
<div className={`mb-2 ${hasInvalidReference ? 'rounded ring-2 ring-orange-500' : ''}`}>
<Combobox
items={variableItems}
value={data.variableId}
onChange={handleVariableSelect}
placeholder="Select variable..."
onAddNew={handleAddNew}
/>
{hasInvalidReference && (
<div className="mt-1 text-xs text-orange-600 dark:text-orange-400">
Variable not found
</div>
)}
</div>
{showAddForm && (
<div className="mb-2 rounded border border-orange-300 bg-white p-2 dark:border-orange-600 dark:bg-zinc-800">
<div className="mb-1 text-xs font-medium text-zinc-600 dark:text-zinc-300">
New variable
</div>
<div className="flex items-center gap-1.5">
<input <input
type="text" type="text"
value={data.variableName || ''} value={newName}
onChange={updateVariableName} onChange={(e) => setNewName(e.target.value)}
placeholder="variableName" placeholder="Name"
className="mb-2 w-full rounded border border-orange-300 bg-white px-2 py-1 text-sm focus:border-orange-500 focus:outline-none focus:ring-1 focus:ring-orange-500 dark:border-orange-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400" className="w-full rounded border border-zinc-300 bg-white px-2 py-0.5 text-sm focus:border-orange-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white dark:placeholder-zinc-400"
onKeyDown={(e) => {
if (e.key === 'Enter') handleSubmitNew()
if (e.key === 'Escape') handleCancelNew()
}}
autoFocus
/> />
</div>
<div className="mt-1.5 flex items-center gap-1.5">
<select
value={newType}
onChange={(e) => setNewType(e.target.value as 'numeric' | 'string' | 'boolean')}
className="rounded border border-zinc-300 bg-white px-2 py-0.5 text-sm focus:border-orange-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white"
>
<option value="numeric">numeric</option>
<option value="string">string</option>
<option value="boolean">boolean</option>
</select>
</div>
<div className="mt-1.5 flex justify-end gap-1">
<button
onClick={handleCancelNew}
className="rounded px-2 py-0.5 text-xs text-zinc-500 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-700"
>
Cancel
</button>
<button
onClick={handleSubmitNew}
disabled={!newName.trim()}
className="rounded bg-orange-600 px-2 py-0.5 text-xs text-white hover:bg-orange-700 disabled:opacity-50"
>
Add
</button>
</div>
</div>
)}
<div className="mb-2 flex gap-2"> <div className="mb-2 flex gap-2">
<select <select
@ -80,8 +198,8 @@ export default function VariableNode({ id, data }: NodeProps<VariableNodeData>)
className="flex-1 rounded border border-orange-300 bg-white px-2 py-1 text-sm focus:border-orange-500 focus:outline-none focus:ring-1 focus:ring-orange-500 dark:border-orange-600 dark:bg-zinc-800 dark:text-white" className="flex-1 rounded border border-orange-300 bg-white px-2 py-1 text-sm focus:border-orange-500 focus:outline-none focus:ring-1 focus:ring-orange-500 dark:border-orange-600 dark:bg-zinc-800 dark:text-white"
> >
<option value="set">set</option> <option value="set">set</option>
<option value="add">add</option> {isNumeric && <option value="add">add</option>}
<option value="subtract">subtract</option> {isNumeric && <option value="subtract">subtract</option>}
</select> </select>
<input <input