From b570dca1b8f67143f222423d62c5b940194bb287 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Fri, 23 Jan 2026 10:42:40 -0300 Subject: [PATCH] feat: [US-062] - Auto-migration of existing free-text values Co-Authored-By: Claude Opus 4.5 --- .../editor/[projectId]/FlowchartEditor.tsx | 163 +++++++++++++++++- src/app/editor/[projectId]/page.tsx | 5 + src/components/editor/nodes/DialogueNode.tsx | 146 ++++++++++++++-- 3 files changed, 293 insertions(+), 21 deletions(-) diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index 9edf400..284a6ed 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -25,11 +25,13 @@ 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 Toast from '@/components/Toast' import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable, Condition } from '@/types/flowchart' type FlowchartEditorProps = { projectId: string initialData: FlowchartData + needsMigration?: boolean } // 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() + 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() + 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() + 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() + 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 -function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { +function FlowchartEditorInner({ initialData, needsMigration }: FlowchartEditorProps) { // Define custom node types - memoized to prevent re-renders const nodeTypes: NodeTypes = useMemo( () => ({ @@ -72,17 +214,21 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { 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( - toReactFlowNodes(initialData.nodes) + toReactFlowNodes(migratedData.nodes) ) const [edges, setEdges, onEdgesChange] = useEdgesState( - toReactFlowEdges(initialData.edges) + toReactFlowEdges(migratedData.edges) ) - const [characters, setCharacters] = useState(initialData.characters) - const [variables, setVariables] = useState(initialData.variables) + const [characters, setCharacters] = useState(migratedData.characters) + const [variables, setVariables] = useState(migratedData.variables) const [showSettings, setShowSettings] = useState(false) const [selectedEdgeId, setSelectedEdgeId] = useState(null) + const [toastMessage, setToastMessage] = useState(migratedData.toastMessage) const handleAddCharacter = useCallback( (name: string, color: string): string => { @@ -302,6 +448,13 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { onClose={() => setSelectedEdgeId(null)} /> )} + {toastMessage && ( + setToastMessage(null)} + /> + )} ) diff --git a/src/app/editor/[projectId]/page.tsx b/src/app/editor/[projectId]/page.tsx index 0750c85..7c5f64a 100644 --- a/src/app/editor/[projectId]/page.tsx +++ b/src/app/editor/[projectId]/page.tsx @@ -39,6 +39,10 @@ export default async function EditorPage({ params }: PageProps) { 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 (
@@ -70,6 +74,7 @@ export default async function EditorPage({ params }: PageProps) {
diff --git a/src/components/editor/nodes/DialogueNode.tsx b/src/components/editor/nodes/DialogueNode.tsx index 0867d7b..10afe97 100644 --- a/src/components/editor/nodes/DialogueNode.tsx +++ b/src/components/editor/nodes/DialogueNode.tsx @@ -1,22 +1,56 @@ 'use client' -import { useCallback, ChangeEvent } from 'react' +import { useCallback, useMemo, useState, ChangeEvent } from 'react' 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 = { speaker?: string + characterId?: 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) { 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( - (field: keyof DialogueNodeData, value: string) => { + (updates: Partial) => { setNodes((nodes) => nodes.map((node) => node.id === id - ? { ...node, data: { ...node.data, [field]: value } } + ? { ...node, data: { ...node.data, ...updates } } : node ) ) @@ -24,22 +58,52 @@ export default function DialogueNode({ id, data }: NodeProps) [id, setNodes] ) - const handleSpeakerChange = useCallback( - (e: ChangeEvent) => { - updateNodeData('speaker', e.target.value) + const handleCharacterSelect = useCallback( + (characterId: string) => { + 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( (e: ChangeEvent) => { - updateNodeData('text', e.target.value) + updateNodeData({ text: e.target.value }) }, [updateNodeData] ) return ( -
+
) Dialogue
- +
+ + {hasInvalidReference && ( +
+ Character not found +
+ )} +
+ + {showAddForm && ( +
+
+ New character +
+
+ setNewColor(e.target.value)} + className="h-6 w-6 shrink-0 cursor-pointer rounded border border-zinc-300 dark:border-zinc-600" + /> + 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 + /> +
+
+ + +
+
+ )}