feat: [US-062] - Auto-migration of existing free-text values

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:42:40 -03:00
parent 68bfe88842
commit b570dca1b8
3 changed files with 293 additions and 21 deletions

View File

@ -25,11 +25,13 @@ import VariableNode from '@/components/editor/nodes/VariableNode'
import ProjectSettingsModal from '@/components/editor/ProjectSettingsModal' import ProjectSettingsModal from '@/components/editor/ProjectSettingsModal'
import ConditionEditor from '@/components/editor/ConditionEditor' import ConditionEditor from '@/components/editor/ConditionEditor'
import { EditorProvider } from '@/components/editor/EditorContext' import { EditorProvider } from '@/components/editor/EditorContext'
import Toast from '@/components/Toast'
import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable, Condition } from '@/types/flowchart' import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable, Condition } from '@/types/flowchart'
type FlowchartEditorProps = { type FlowchartEditorProps = {
projectId: string projectId: string
initialData: FlowchartData initialData: FlowchartData
needsMigration?: boolean
} }
// Convert our FlowchartNode type to React Flow Node type // Convert our FlowchartNode type to React Flow Node type
@ -58,8 +60,148 @@ function toReactFlowEdges(edges: FlowchartEdge[]): Edge[] {
})) }))
} }
const RANDOM_COLORS = [
'#EF4444', '#F97316', '#F59E0B', '#10B981',
'#3B82F6', '#8B5CF6', '#EC4899', '#14B8A6',
'#6366F1', '#F43F5E', '#84CC16', '#06B6D4',
]
function randomHexColor(): string {
return RANDOM_COLORS[Math.floor(Math.random() * RANDOM_COLORS.length)]
}
// Compute auto-migration of existing free-text values to character/variable definitions
function computeMigration(initialData: FlowchartData, shouldMigrate: boolean) {
if (!shouldMigrate) {
return {
characters: initialData.characters,
variables: initialData.variables,
nodes: initialData.nodes,
edges: initialData.edges,
toastMessage: null as string | null,
}
}
// Collect unique speaker names from dialogue nodes
const speakerNames = new Set<string>()
initialData.nodes.forEach((node) => {
if (node.type === 'dialogue' && node.data?.speaker) {
speakerNames.add(node.data.speaker)
}
})
// Create character definitions from unique speaker names
const newCharacters: Character[] = []
const speakerToCharacterId = new Map<string, string>()
speakerNames.forEach((name) => {
const id = nanoid()
newCharacters.push({ id, name, color: randomHexColor() })
speakerToCharacterId.set(name, id)
})
// Collect unique variable names from variable nodes, edge conditions, and choice option conditions
const variableNames = new Set<string>()
initialData.nodes.forEach((node) => {
if (node.type === 'variable' && node.data.variableName) {
variableNames.add(node.data.variableName)
}
if (node.type === 'choice' && node.data.options) {
node.data.options.forEach((opt) => {
if (opt.condition?.variableName) {
variableNames.add(opt.condition.variableName)
}
})
}
})
initialData.edges.forEach((edge) => {
if (edge.data?.condition?.variableName) {
variableNames.add(edge.data.condition.variableName)
}
})
// Create variable definitions from unique variable names
const newVariables: Variable[] = []
const varNameToId = new Map<string, string>()
variableNames.forEach((name) => {
const id = nanoid()
newVariables.push({ id, name, type: 'numeric', initialValue: 0 })
varNameToId.set(name, id)
})
// If nothing to migrate, return original data
if (newCharacters.length === 0 && newVariables.length === 0) {
return {
characters: initialData.characters,
variables: initialData.variables,
nodes: initialData.nodes,
edges: initialData.edges,
toastMessage: null as string | null,
}
}
// Update nodes with characterId/variableId references
const migratedNodes = initialData.nodes.map((node) => {
if (node.type === 'dialogue' && node.data.speaker) {
const characterId = speakerToCharacterId.get(node.data.speaker)
if (characterId) {
return { ...node, data: { ...node.data, characterId } }
}
}
if (node.type === 'variable' && node.data.variableName) {
const variableId = varNameToId.get(node.data.variableName)
if (variableId) {
return { ...node, data: { ...node.data, variableId } }
}
}
if (node.type === 'choice' && node.data.options) {
const updatedOptions = node.data.options.map((opt) => {
if (opt.condition?.variableName) {
const variableId = varNameToId.get(opt.condition.variableName)
if (variableId) {
return { ...opt, condition: { ...opt.condition, variableId } }
}
}
return opt
})
return { ...node, data: { ...node.data, options: updatedOptions } }
}
return node
}) as typeof initialData.nodes
// Update edges with variableId references
const migratedEdges = initialData.edges.map((edge) => {
if (edge.data?.condition?.variableName) {
const variableId = varNameToId.get(edge.data.condition.variableName)
if (variableId) {
return {
...edge,
data: { ...edge.data, condition: { ...edge.data.condition, variableId } },
}
}
}
return edge
})
// Build toast message
const parts: string[] = []
if (newCharacters.length > 0) {
parts.push(`${newCharacters.length} character${newCharacters.length > 1 ? 's' : ''}`)
}
if (newVariables.length > 0) {
parts.push(`${newVariables.length} variable${newVariables.length > 1 ? 's' : ''}`)
}
return {
characters: newCharacters,
variables: newVariables,
nodes: migratedNodes,
edges: migratedEdges,
toastMessage: `Auto-imported ${parts.join(' and ')} from existing data`,
}
}
// Inner component that uses useReactFlow hook // Inner component that uses useReactFlow hook
function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { function FlowchartEditorInner({ initialData, needsMigration }: FlowchartEditorProps) {
// Define custom node types - memoized to prevent re-renders // Define custom node types - memoized to prevent re-renders
const nodeTypes: NodeTypes = useMemo( const nodeTypes: NodeTypes = useMemo(
() => ({ () => ({
@ -72,17 +214,21 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
const { getViewport } = useReactFlow() const { getViewport } = useReactFlow()
// Compute migrated data once on first render using a lazy state initializer
const [migratedData] = useState(() => computeMigration(initialData, !!needsMigration))
const [nodes, setNodes, onNodesChange] = useNodesState( const [nodes, setNodes, onNodesChange] = useNodesState(
toReactFlowNodes(initialData.nodes) toReactFlowNodes(migratedData.nodes)
) )
const [edges, setEdges, onEdgesChange] = useEdgesState( const [edges, setEdges, onEdgesChange] = useEdgesState(
toReactFlowEdges(initialData.edges) toReactFlowEdges(migratedData.edges)
) )
const [characters, setCharacters] = useState<Character[]>(initialData.characters) const [characters, setCharacters] = useState<Character[]>(migratedData.characters)
const [variables, setVariables] = useState<Variable[]>(initialData.variables) const [variables, setVariables] = useState<Variable[]>(migratedData.variables)
const [showSettings, setShowSettings] = useState(false) const [showSettings, setShowSettings] = useState(false)
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null) const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null)
const [toastMessage, setToastMessage] = useState<string | null>(migratedData.toastMessage)
const handleAddCharacter = useCallback( const handleAddCharacter = useCallback(
(name: string, color: string): string => { (name: string, color: string): string => {
@ -302,6 +448,13 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
onClose={() => setSelectedEdgeId(null)} onClose={() => setSelectedEdgeId(null)}
/> />
)} )}
{toastMessage && (
<Toast
message={toastMessage}
type="success"
onClose={() => setToastMessage(null)}
/>
)}
</div> </div>
</EditorProvider> </EditorProvider>
) )

View File

@ -39,6 +39,10 @@ export default async function EditorPage({ params }: PageProps) {
variables: rawData.variables || [], variables: rawData.variables || [],
} }
// Migration flag: if the raw data doesn't have characters/variables arrays,
// the project was created before these features existed and may need auto-migration
const needsMigration = !rawData.characters && !rawData.variables
return ( return (
<div className="flex h-screen flex-col"> <div className="flex h-screen flex-col">
<header className="flex items-center justify-between border-b border-zinc-200 bg-white px-4 py-3 dark:border-zinc-700 dark:bg-zinc-900"> <header className="flex items-center justify-between border-b border-zinc-200 bg-white px-4 py-3 dark:border-zinc-700 dark:bg-zinc-900">
@ -70,6 +74,7 @@ export default async function EditorPage({ params }: PageProps) {
<FlowchartEditor <FlowchartEditor
projectId={project.id} projectId={project.id}
initialData={flowchartData} initialData={flowchartData}
needsMigration={needsMigration}
/> />
</div> </div>
</div> </div>

View File

@ -1,22 +1,56 @@
'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 DialogueNodeData = { type DialogueNodeData = {
speaker?: string speaker?: string
characterId?: string
text: string text: string
} }
const RANDOM_COLORS = [
'#EF4444', '#F97316', '#F59E0B', '#10B981',
'#3B82F6', '#8B5CF6', '#EC4899', '#14B8A6',
'#6366F1', '#F43F5E', '#84CC16', '#06B6D4',
]
function randomColor(): string {
return RANDOM_COLORS[Math.floor(Math.random() * RANDOM_COLORS.length)]
}
export default function DialogueNode({ id, data }: NodeProps<DialogueNodeData>) { export default function DialogueNode({ id, data }: NodeProps<DialogueNodeData>) {
const { setNodes } = useReactFlow() const { setNodes } = useReactFlow()
const { characters, onAddCharacter } = useEditorContext()
const [showAddForm, setShowAddForm] = useState(false)
const [newName, setNewName] = useState('')
const [newColor, setNewColor] = useState(randomColor)
const characterItems: ComboboxItem[] = useMemo(
() =>
characters.map((c) => ({
id: c.id,
label: c.name,
color: c.color,
})),
[characters]
)
const hasInvalidReference = useMemo(() => {
if (!data.characterId) return false
return !characters.some((c) => c.id === data.characterId)
}, [data.characterId, characters])
const updateNodeData = useCallback( const updateNodeData = useCallback(
(field: keyof DialogueNodeData, value: string) => { (updates: Partial<DialogueNodeData>) => {
setNodes((nodes) => setNodes((nodes) =>
nodes.map((node) => nodes.map((node) =>
node.id === id node.id === id
? { ...node, data: { ...node.data, [field]: value } } ? { ...node, data: { ...node.data, ...updates } }
: node : node
) )
) )
@ -24,22 +58,52 @@ export default function DialogueNode({ id, data }: NodeProps<DialogueNodeData>)
[id, setNodes] [id, setNodes]
) )
const handleSpeakerChange = useCallback( const handleCharacterSelect = useCallback(
(e: ChangeEvent<HTMLInputElement>) => { (characterId: string) => {
updateNodeData('speaker', e.target.value) const character = characters.find((c) => c.id === characterId)
updateNodeData({
characterId,
speaker: character?.name || '',
})
}, },
[updateNodeData] [characters, updateNodeData]
) )
const handleAddNew = useCallback(() => {
setShowAddForm(true)
setNewName('')
setNewColor(randomColor())
}, [])
const handleSubmitNew = useCallback(() => {
if (!newName.trim()) return
const newId = onAddCharacter(newName.trim(), newColor)
updateNodeData({
characterId: newId,
speaker: newName.trim(),
})
setShowAddForm(false)
}, [newName, newColor, onAddCharacter, updateNodeData])
const handleCancelNew = useCallback(() => {
setShowAddForm(false)
}, [])
const handleTextChange = useCallback( const handleTextChange = useCallback(
(e: ChangeEvent<HTMLTextAreaElement>) => { (e: ChangeEvent<HTMLTextAreaElement>) => {
updateNodeData('text', e.target.value) updateNodeData({ text: e.target.value })
}, },
[updateNodeData] [updateNodeData]
) )
return ( return (
<div className="min-w-[200px] rounded-lg border-2 border-blue-500 bg-blue-50 p-3 shadow-md dark:border-blue-400 dark:bg-blue-950"> <div
className={`min-w-[200px] rounded-lg border-2 ${
hasInvalidReference
? 'border-orange-500 dark:border-orange-400'
: 'border-blue-500 dark:border-blue-400'
} bg-blue-50 p-3 shadow-md dark:bg-blue-950`}
>
<Handle <Handle
type="target" type="target"
position={Position.Top} position={Position.Top}
@ -51,13 +115,63 @@ export default function DialogueNode({ id, data }: NodeProps<DialogueNodeData>)
Dialogue Dialogue
</div> </div>
<div className="mb-2">
<Combobox
items={characterItems}
value={data.characterId}
onChange={handleCharacterSelect}
placeholder="Select speaker..."
onAddNew={handleAddNew}
/>
{hasInvalidReference && (
<div className="mt-1 text-xs text-orange-600 dark:text-orange-400">
Character not found
</div>
)}
</div>
{showAddForm && (
<div className="mb-2 rounded border border-blue-300 bg-white p-2 dark:border-blue-600 dark:bg-zinc-800">
<div className="mb-1 text-xs font-medium text-zinc-600 dark:text-zinc-300">
New character
</div>
<div className="flex items-center gap-1.5">
<input
type="color"
value={newColor}
onChange={(e) => setNewColor(e.target.value)}
className="h-6 w-6 shrink-0 cursor-pointer rounded border border-zinc-300 dark:border-zinc-600"
/>
<input <input
type="text" type="text"
value={data.speaker || ''} value={newName}
onChange={handleSpeakerChange} onChange={(e) => setNewName(e.target.value)}
placeholder="Speaker" placeholder="Name"
className="mb-2 w-full rounded border border-blue-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-blue-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-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
/> />
</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-blue-600 px-2 py-0.5 text-xs text-white hover:bg-blue-700 disabled:opacity-50"
>
Add
</button>
</div>
</div>
)}
<textarea <textarea
value={data.text || ''} value={data.text || ''}