From b4b9f8cec926d3bff7371a0812285ff1c327c264 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Fri, 23 Jan 2026 10:30:42 -0300 Subject: [PATCH] feat: [US-060] - Edge condition variable dropdown Replace the variableName text input in edge conditions with a Combobox-based variable selector. Adds ConditionEditor modal that opens on edge click, with type-aware operators (comparison for numeric, == and != for string/boolean) and type-adaptive value inputs (number, text, or boolean toggle). Co-Authored-By: Claude Opus 4.5 --- .../editor/[projectId]/FlowchartEditor.tsx | 40 ++- src/components/editor/ConditionEditor.tsx | 322 ++++++++++++++++++ src/types/flowchart.ts | 2 +- 3 files changed, 361 insertions(+), 3 deletions(-) create mode 100644 src/components/editor/ConditionEditor.tsx diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index 275304c..b1e4c8b 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useMemo, useState } from 'react' +import React, { useCallback, useMemo, useState } from 'react' import ReactFlow, { Background, BackgroundVariant, @@ -23,8 +23,9 @@ import DialogueNode from '@/components/editor/nodes/DialogueNode' import ChoiceNode from '@/components/editor/nodes/ChoiceNode' import VariableNode from '@/components/editor/nodes/VariableNode' import ProjectSettingsModal from '@/components/editor/ProjectSettingsModal' +import ConditionEditor from '@/components/editor/ConditionEditor' import { EditorProvider } from '@/components/editor/EditorContext' -import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable } from '@/types/flowchart' +import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable, Condition } from '@/types/flowchart' type FlowchartEditorProps = { projectId: string @@ -81,6 +82,7 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { const [characters, setCharacters] = useState(initialData.characters) const [variables, setVariables] = useState(initialData.variables) const [showSettings, setShowSettings] = useState(false) + const [selectedEdgeId, setSelectedEdgeId] = useState(null) const handleAddCharacter = useCallback( (name: string, color: string): string => { @@ -219,6 +221,31 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { console.log('Deleted edges:', deletedEdges.map((e) => e.id)) }, []) + // Handle edge click to open condition editor + const onEdgeClick = useCallback((_event: React.MouseEvent, edge: Edge) => { + setSelectedEdgeId(edge.id) + }, []) + + // Handle condition change from ConditionEditor + const handleConditionChange = useCallback( + (edgeId: string, condition: Condition | undefined) => { + setEdges((eds) => + eds.map((edge) => + edge.id === edgeId + ? { ...edge, data: condition ? { condition } : undefined } + : edge + ) + ) + }, + [setEdges] + ) + + // Get the selected edge's condition data + const selectedEdge = useMemo( + () => (selectedEdgeId ? edges.find((e) => e.id === selectedEdgeId) : null), + [selectedEdgeId, edges] + ) + return (
@@ -239,6 +266,7 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onEdgesDelete={onEdgesDelete} + onEdgeClick={onEdgeClick} onConnect={onConnect} deleteKeyCode={['Delete', 'Backspace']} fitView @@ -258,6 +286,14 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { getVariableUsageCount={getVariableUsageCount} /> )} + {selectedEdge && ( + setSelectedEdgeId(null)} + /> + )}
) diff --git a/src/components/editor/ConditionEditor.tsx b/src/components/editor/ConditionEditor.tsx new file mode 100644 index 0000000..b7259f2 --- /dev/null +++ b/src/components/editor/ConditionEditor.tsx @@ -0,0 +1,322 @@ +'use client' + +import { useCallback, useMemo, useState } from 'react' +import Combobox from '@/components/editor/Combobox' +import type { ComboboxItem } from '@/components/editor/Combobox' +import { useEditorContext } from '@/components/editor/EditorContext' +import type { Condition } from '@/types/flowchart' + +type ConditionEditorProps = { + edgeId: string + condition: Condition | undefined + onChange: (edgeId: string, condition: Condition | undefined) => void + onClose: () => void +} + +export default function ConditionEditor({ + edgeId, + condition, + onChange, + onClose, +}: ConditionEditorProps) { + const { variables, onAddVariable } = useEditorContext() + + const [showAddForm, setShowAddForm] = useState(false) + 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 (!condition?.variableId) return undefined + return variables.find((v) => v.id === condition.variableId) + }, [condition?.variableId, variables]) + + const hasInvalidReference = useMemo(() => { + if (!condition?.variableId) return false + return !variables.some((v) => v.id === condition.variableId) + }, [condition?.variableId, variables]) + + // Determine operators based on variable type + const availableOperators = useMemo(() => { + if (!selectedVariable || selectedVariable.type === 'numeric') { + return [ + { value: '==', label: '==' }, + { value: '!=', label: '!=' }, + { value: '>', label: '>' }, + { value: '<', label: '<' }, + { value: '>=', label: '>=' }, + { value: '<=', label: '<=' }, + ] as const + } + // string and boolean only support == and != + return [ + { value: '==', label: '==' }, + { value: '!=', label: '!=' }, + ] as const + }, [selectedVariable]) + + const handleVariableSelect = useCallback( + (variableId: string) => { + const variable = variables.find((v) => v.id === variableId) + const defaultValue = variable + ? variable.type === 'numeric' + ? 0 + : variable.type === 'boolean' + ? false + : '' + : 0 + // Reset operator if current one is not valid for new type + const validOperator = + variable && variable.type !== 'numeric' && condition?.operator && !['==', '!='].includes(condition.operator) + ? '==' + : condition?.operator || '==' + + onChange(edgeId, { + variableName: variable?.name || '', + variableId, + operator: validOperator as Condition['operator'], + value: defaultValue, + }) + }, + [variables, condition?.operator, edgeId, onChange] + ) + + const handleOperatorChange = useCallback( + (operator: string) => { + if (!condition) return + onChange(edgeId, { + ...condition, + operator: operator as Condition['operator'], + }) + }, + [condition, edgeId, onChange] + ) + + const handleValueChange = useCallback( + (value: number | string | boolean) => { + if (!condition) return + onChange(edgeId, { + ...condition, + value, + }) + }, + [condition, edgeId, onChange] + ) + + const handleRemoveCondition = useCallback(() => { + onChange(edgeId, undefined) + onClose() + }, [edgeId, onChange, onClose]) + + 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) + onChange(edgeId, { + variableName: newName.trim(), + variableId: newId, + operator: '==', + value: defaultValue, + }) + setShowAddForm(false) + }, [newName, newType, onAddVariable, edgeId, onChange]) + + const handleCancelNew = useCallback(() => { + setShowAddForm(false) + }, []) + + // Render value input based on variable type + const renderValueInput = () => { + const varType = selectedVariable?.type || 'numeric' + + if (varType === 'boolean') { + return ( + + ) + } + + if (varType === 'string') { + return ( + handleValueChange(e.target.value)} + placeholder="Value..." + className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white dark:placeholder-zinc-400" + /> + ) + } + + // numeric + return ( + handleValueChange(parseFloat(e.target.value) || 0)} + className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white" + /> + ) + } + + return ( +
+
+
+
+

+ Edge Condition +

+ +
+ + {/* Variable selector */} +
+ +
+ +
+ {hasInvalidReference && ( +
+ Variable not found +
+ )} +
+ + {/* Inline add form */} + {showAddForm && ( +
+
+ New variable +
+
+ setNewName(e.target.value)} + placeholder="Name" + className="w-full rounded border border-zinc-300 bg-white px-2 py-0.5 text-sm focus:border-blue-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 + /> +
+
+ +
+
+ + +
+
+ )} + + {/* Operator and value (shown when variable is selected) */} + {condition?.variableId && ( + <> +
+ + +
+ +
+ + {renderValueInput()} +
+ + )} + + {/* Actions */} +
+ {condition?.variableId ? ( + + ) : ( +
+ )} + +
+
+
+ ) +} diff --git a/src/types/flowchart.ts b/src/types/flowchart.ts index 94ed51d..0379054 100644 --- a/src/types/flowchart.ts +++ b/src/types/flowchart.ts @@ -75,7 +75,7 @@ export type Condition = { variableName: string; variableId?: string; operator: '>' | '<' | '==' | '>=' | '<=' | '!='; - value: number; + value: number | string | boolean; }; // FlowchartEdge type: represents connections between nodes