feat: [US-031] - Condition editor modal
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
0d8a4059bc
commit
fda0903872
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue