feat: [US-031] - Condition editor modal

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Gustavo Henrique Santos Souza de Miranda 2026-01-22 18:08:37 -03:00
parent 0d8a4059bc
commit fda0903872
2 changed files with 247 additions and 5 deletions

View File

@ -25,7 +25,8 @@ 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 ContextMenu, { ContextMenuType } from '@/components/editor/ContextMenu' import ContextMenu, { ContextMenuType } from '@/components/editor/ContextMenu'
import type { FlowchartData, FlowchartNode, FlowchartEdge } from '@/types/flowchart' import ConditionEditor from '@/components/editor/ConditionEditor'
import type { FlowchartData, FlowchartNode, FlowchartEdge, Condition } from '@/types/flowchart'
type ContextMenuState = { type ContextMenuState = {
x: number x: number
@ -35,6 +36,11 @@ type ContextMenuState = {
edgeId?: string edgeId?: string
} | null } | null
type ConditionEditorState = {
edgeId: string
condition?: Condition
} | null
type FlowchartEditorProps = { type FlowchartEditorProps = {
projectId: string projectId: string
initialData: FlowchartData initialData: FlowchartData
@ -81,6 +87,7 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
const { getViewport, screenToFlowPosition } = useReactFlow() const { getViewport, screenToFlowPosition } = useReactFlow()
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null) const [contextMenu, setContextMenu] = useState<ContextMenuState>(null)
const [conditionEditor, setConditionEditor] = useState<ConditionEditorState>(null)
const [nodes, setNodes, onNodesChange] = useNodesState( const [nodes, setNodes, onNodesChange] = useNodesState(
toReactFlowNodes(initialData.nodes) toReactFlowNodes(initialData.nodes)
@ -290,11 +297,69 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
setEdges((edges) => edges.filter((e) => e.id !== contextMenu.edgeId)) setEdges((edges) => edges.filter((e) => e.id !== contextMenu.edgeId))
}, [contextMenu, setEdges]) }, [contextMenu, setEdges])
// Add condition to edge (will be implemented in US-031) // Open condition editor for an edge
const openConditionEditor = useCallback(
(edgeId: string) => {
const edge = edges.find((e) => e.id === edgeId)
if (!edge) return
setConditionEditor({
edgeId,
condition: edge.data?.condition,
})
},
[edges]
)
// Add condition to edge (opens ConditionEditor modal)
const handleAddCondition = useCallback(() => { const handleAddCondition = useCallback(() => {
// TODO: Implement in US-031 - will open ConditionEditor modal if (!contextMenu?.edgeId) return
console.log('Add condition to edge:', contextMenu?.edgeId) openConditionEditor(contextMenu.edgeId)
}, [contextMenu]) }, [contextMenu, openConditionEditor])
// Handle double-click on edge to open condition editor
const onEdgeDoubleClick = useCallback(
(_event: React.MouseEvent, edge: Edge) => {
openConditionEditor(edge.id)
},
[openConditionEditor]
)
// Save condition to edge
const handleSaveCondition = useCallback(
(edgeId: string, condition: Condition) => {
setEdges((eds) =>
eds.map((edge) =>
edge.id === edgeId
? { ...edge, data: { ...edge.data, condition } }
: edge
)
)
setConditionEditor(null)
},
[setEdges]
)
// Remove condition from edge
const handleRemoveCondition = useCallback(
(edgeId: string) => {
setEdges((eds) =>
eds.map((edge) => {
if (edge.id !== edgeId) return edge
// Remove condition from data
const newData = { ...edge.data }
delete newData.condition
return { ...edge, data: newData }
})
)
setConditionEditor(null)
},
[setEdges]
)
// Close condition editor
const closeConditionEditor = useCallback(() => {
setConditionEditor(null)
}, [])
return ( return (
<div className="flex h-full w-full flex-col"> <div className="flex h-full w-full flex-col">
@ -318,6 +383,7 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
onPaneContextMenu={onPaneContextMenu} onPaneContextMenu={onPaneContextMenu}
onNodeContextMenu={onNodeContextMenu} onNodeContextMenu={onNodeContextMenu}
onEdgeContextMenu={onEdgeContextMenu} onEdgeContextMenu={onEdgeContextMenu}
onEdgeDoubleClick={onEdgeDoubleClick}
deleteKeyCode={['Delete', 'Backspace']} deleteKeyCode={['Delete', 'Backspace']}
fitView fitView
> >
@ -341,6 +407,16 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
onAddCondition={handleAddCondition} onAddCondition={handleAddCondition}
/> />
)} )}
{conditionEditor && (
<ConditionEditor
edgeId={conditionEditor.edgeId}
condition={conditionEditor.condition}
onSave={handleSaveCondition}
onRemove={handleRemoveCondition}
onCancel={closeConditionEditor}
/>
)}
</div> </div>
) )
} }

View File

@ -0,0 +1,166 @@
'use client'
import { useState, useCallback, useEffect } from 'react'
import type { Condition } from '@/types/flowchart'
type ConditionEditorProps = {
edgeId: string
condition?: Condition
onSave: (edgeId: string, condition: Condition) => void
onRemove: (edgeId: string) => void
onCancel: () => void
}
const OPERATORS: Condition['operator'][] = ['>', '<', '==', '>=', '<=', '!=']
export default function ConditionEditor({
edgeId,
condition,
onSave,
onRemove,
onCancel,
}: ConditionEditorProps) {
const [variableName, setVariableName] = useState(condition?.variableName ?? '')
const [operator, setOperator] = useState<Condition['operator']>(condition?.operator ?? '==')
const [value, setValue] = useState(condition?.value ?? 0)
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onCancel()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [onCancel])
const handleSave = useCallback(() => {
if (!variableName.trim()) return
onSave(edgeId, {
variableName: variableName.trim(),
operator,
value,
})
}, [edgeId, variableName, operator, value, onSave])
const handleRemove = useCallback(() => {
onRemove(edgeId)
}, [edgeId, onRemove])
const hasExistingCondition = !!condition
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div
className="w-full max-w-md rounded-lg border border-zinc-200 bg-white p-6 shadow-xl dark:border-zinc-700 dark:bg-zinc-800"
onClick={(e) => e.stopPropagation()}
>
<h3 className="mb-4 text-lg font-semibold text-zinc-900 dark:text-zinc-100">
{hasExistingCondition ? 'Edit Condition' : 'Add Condition'}
</h3>
<div className="space-y-4">
{/* Variable Name Input */}
<div>
<label
htmlFor="variableName"
className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
Variable Name
</label>
<input
type="text"
id="variableName"
value={variableName}
onChange={(e) => setVariableName(e.target.value)}
placeholder="e.g., score, health, affection"
autoFocus
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-100 dark:placeholder-zinc-500"
/>
</div>
{/* Operator Dropdown */}
<div>
<label
htmlFor="operator"
className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
Operator
</label>
<select
id="operator"
value={operator}
onChange={(e) => setOperator(e.target.value as Condition['operator'])}
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-100"
>
{OPERATORS.map((op) => (
<option key={op} value={op}>
{op === '==' ? '== (equals)' : op === '!=' ? '!= (not equals)' : op === '>' ? '> (greater than)' : op === '<' ? '< (less than)' : op === '>=' ? '>= (greater or equal)' : '<= (less or equal)'}
</option>
))}
</select>
</div>
{/* Value Number Input */}
<div>
<label
htmlFor="value"
className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
Value
</label>
<input
type="number"
id="value"
value={value}
onChange={(e) => setValue(parseFloat(e.target.value) || 0)}
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-100"
/>
</div>
{/* Preview */}
{variableName.trim() && (
<div className="rounded-md border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-600 dark:bg-zinc-700">
<span className="text-sm text-zinc-600 dark:text-zinc-300">
Condition: <code className="font-mono text-blue-600 dark:text-blue-400">{variableName.trim()} {operator} {value}</code>
</span>
</div>
)}
</div>
{/* Action Buttons */}
<div className="mt-6 flex justify-between">
<div>
{hasExistingCondition && (
<button
type="button"
onClick={handleRemove}
className="rounded-md border border-red-300 bg-red-50 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-100 dark:border-red-700 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50"
>
Remove
</button>
)}
</div>
<div className="flex gap-2">
<button
type="button"
onClick={onCancel}
className="rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-600"
>
Cancel
</button>
<button
type="button"
onClick={handleSave}
disabled={!variableName.trim()}
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>
Save
</button>
</div>
</div>
</div>
</div>
)
}