From 92d892fb73a4d3d000f9f7a00d674f1fae9589a1 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Fri, 23 Jan 2026 10:36:16 -0300 Subject: [PATCH] feat: [US-061] - Choice option condition variable dropdown Co-Authored-By: Claude Opus 4.5 --- .../editor/[projectId]/FlowchartEditor.tsx | 10 +- .../editor/OptionConditionEditor.tsx | 315 ++++++++++++++++++ src/components/editor/nodes/ChoiceNode.tsx | 197 +++++++---- src/types/flowchart.ts | 17 +- 4 files changed, 470 insertions(+), 69 deletions(-) create mode 100644 src/components/editor/OptionConditionEditor.tsx diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index b1e4c8b..9edf400 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -124,7 +124,15 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { const edgeCount = edges.filter( (e) => e.data?.condition?.variableId === variableId ).length - return nodeCount + edgeCount + const choiceOptionCount = nodes.filter( + (n) => n.type === 'choice' + ).reduce((count, n) => { + const options = n.data?.options || [] + return count + options.filter( + (opt: { condition?: { variableId?: string } }) => opt.condition?.variableId === variableId + ).length + }, 0) + return nodeCount + edgeCount + choiceOptionCount }, [nodes, edges] ) diff --git a/src/components/editor/OptionConditionEditor.tsx b/src/components/editor/OptionConditionEditor.tsx new file mode 100644 index 0000000..b96b5c3 --- /dev/null +++ b/src/components/editor/OptionConditionEditor.tsx @@ -0,0 +1,315 @@ +'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 OptionConditionEditorProps = { + condition: Condition | undefined + onChange: (condition: Condition | undefined) => void + onClose: () => void +} + +export default function OptionConditionEditor({ + condition, + onChange, + onClose, +}: OptionConditionEditorProps) { + 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]) + + const availableOperators = useMemo(() => { + if (!selectedVariable || selectedVariable.type === 'numeric') { + return [ + { value: '==', label: '==' }, + { value: '!=', label: '!=' }, + { value: '>', label: '>' }, + { value: '<', label: '<' }, + { value: '>=', label: '>=' }, + { value: '<=', label: '<=' }, + ] as const + } + 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 + const validOperator = + variable && variable.type !== 'numeric' && condition?.operator && !['==', '!='].includes(condition.operator) + ? '==' + : condition?.operator || '==' + + onChange({ + variableName: variable?.name || '', + variableId, + operator: validOperator as Condition['operator'], + value: defaultValue, + }) + }, + [variables, condition?.operator, onChange] + ) + + const handleOperatorChange = useCallback( + (operator: string) => { + if (!condition) return + onChange({ + ...condition, + operator: operator as Condition['operator'], + }) + }, + [condition, onChange] + ) + + const handleValueChange = useCallback( + (value: number | string | boolean) => { + if (!condition) return + onChange({ + ...condition, + value, + }) + }, + [condition, onChange] + ) + + const handleRemoveCondition = useCallback(() => { + onChange(undefined) + onClose() + }, [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({ + variableName: newName.trim(), + variableId: newId, + operator: '==', + value: defaultValue, + }) + setShowAddForm(false) + }, [newName, newType, onAddVariable, onChange]) + + const handleCancelNew = useCallback(() => { + setShowAddForm(false) + }, []) + + 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" + /> + ) + } + + 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 ( +
+
+
+
+

+ Option 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/components/editor/nodes/ChoiceNode.tsx b/src/components/editor/nodes/ChoiceNode.tsx index 7073d2d..1eb9e5a 100644 --- a/src/components/editor/nodes/ChoiceNode.tsx +++ b/src/components/editor/nodes/ChoiceNode.tsx @@ -1,12 +1,16 @@ 'use client' -import { useCallback, ChangeEvent } from 'react' +import { useCallback, useMemo, useState, ChangeEvent } from 'react' import { Handle, Position, NodeProps, useReactFlow } from 'reactflow' import { nanoid } from 'nanoid' +import { useEditorContext } from '@/components/editor/EditorContext' +import OptionConditionEditor from '@/components/editor/OptionConditionEditor' +import type { Condition } from '@/types/flowchart' type ChoiceOption = { id: string label: string + condition?: Condition } type ChoiceNodeData = { @@ -19,6 +23,8 @@ const MAX_OPTIONS = 6 export default function ChoiceNode({ id, data }: NodeProps) { const { setNodes } = useReactFlow() + const { variables } = useEditorContext() + const [editingConditionOptionId, setEditingConditionOptionId] = useState(null) const updatePrompt = useCallback( (e: ChangeEvent) => { @@ -54,6 +60,27 @@ export default function ChoiceNode({ id, data }: NodeProps) { [id, setNodes] ) + const updateOptionCondition = useCallback( + (optionId: string, condition: Condition | undefined) => { + setNodes((nodes) => + nodes.map((node) => + node.id === id + ? { + ...node, + data: { + ...node.data, + options: node.data.options.map((opt: ChoiceOption) => + opt.id === optionId ? { ...opt, condition } : opt + ), + }, + } + : node + ) + ) + }, + [id, setNodes] + ) + const addOption = useCallback(() => { if (data.options.length >= MAX_OPTIONS) return setNodes((nodes) => @@ -96,68 +123,118 @@ export default function ChoiceNode({ id, data }: NodeProps) { [id, data.options.length, setNodes] ) + const editingOption = useMemo(() => { + if (!editingConditionOptionId) return null + return data.options.find((opt) => opt.id === editingConditionOptionId) || null + }, [editingConditionOptionId, data.options]) + + const hasInvalidConditionReference = useCallback( + (option: ChoiceOption) => { + if (!option.condition?.variableId) return false + return !variables.some((v) => v.id === option.condition!.variableId) + }, + [variables] + ) + return ( -
- + <> +
+ -
- Choice +
+ Choice +
+ + + +
+ {data.options.map((option, index) => ( +
+
+ updateOptionLabel(option.id, e.target.value)} + placeholder={`Option ${index + 1}`} + className="flex-1 rounded border border-green-300 bg-white px-2 py-1 text-sm focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-green-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400" + /> + + + +
+ {option.condition?.variableId && ( +
+ if {option.condition.variableName} {option.condition.operator} {String(option.condition.value)} +
+ )} +
+ ))} +
+ +
- - -
- {data.options.map((option, index) => ( -
- updateOptionLabel(option.id, e.target.value)} - placeholder={`Option ${index + 1}`} - className="flex-1 rounded border border-green-300 bg-white px-2 py-1 text-sm focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-green-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400" - /> - - -
- ))} -
- - -
+ {editingOption && ( + updateOptionCondition(editingOption.id, condition)} + onClose={() => setEditingConditionOptionId(null)} + /> + )} + ) } diff --git a/src/types/flowchart.ts b/src/types/flowchart.ts index 0379054..4f4461c 100644 --- a/src/types/flowchart.ts +++ b/src/types/flowchart.ts @@ -21,6 +21,14 @@ export type Variable = { description?: string; }; +// Condition type for conditional edges and choice options +export type Condition = { + variableName: string; + variableId?: string; + operator: '>' | '<' | '==' | '>=' | '<=' | '!='; + value: number | string | boolean; +}; + // DialogueNode type: represents character speech/dialogue export type DialogueNodeData = { speaker?: string; @@ -39,6 +47,7 @@ export type DialogueNode = { export type ChoiceOption = { id: string; label: string; + condition?: Condition; }; // ChoiceNode type: represents branching decisions @@ -70,14 +79,6 @@ export type VariableNode = { // Union type for all node types export type FlowchartNode = DialogueNode | ChoiceNode | VariableNode; -// Condition type for conditional edges -export type Condition = { - variableName: string; - variableId?: string; - operator: '>' | '<' | '==' | '>=' | '<=' | '!='; - value: number | string | boolean; -}; - // FlowchartEdge type: represents connections between nodes export type FlowchartEdge = { id: string;