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:
parent
68bfe88842
commit
b570dca1b8
|
|
@ -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<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
|
||||
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<Character[]>(initialData.characters)
|
||||
const [variables, setVariables] = useState<Variable[]>(initialData.variables)
|
||||
const [characters, setCharacters] = useState<Character[]>(migratedData.characters)
|
||||
const [variables, setVariables] = useState<Variable[]>(migratedData.variables)
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null)
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(migratedData.toastMessage)
|
||||
|
||||
const handleAddCharacter = useCallback(
|
||||
(name: string, color: string): string => {
|
||||
|
|
@ -302,6 +448,13 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
|
|||
onClose={() => setSelectedEdgeId(null)}
|
||||
/>
|
||||
)}
|
||||
{toastMessage && (
|
||||
<Toast
|
||||
message={toastMessage}
|
||||
type="success"
|
||||
onClose={() => setToastMessage(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</EditorProvider>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<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">
|
||||
|
|
@ -70,6 +74,7 @@ export default async function EditorPage({ params }: PageProps) {
|
|||
<FlowchartEditor
|
||||
projectId={project.id}
|
||||
initialData={flowchartData}
|
||||
needsMigration={needsMigration}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<DialogueNodeData>) {
|
||||
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<DialogueNodeData>) => {
|
||||
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<DialogueNodeData>)
|
|||
[id, setNodes]
|
||||
)
|
||||
|
||||
const handleSpeakerChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
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<HTMLTextAreaElement>) => {
|
||||
updateNodeData('text', e.target.value)
|
||||
updateNodeData({ text: e.target.value })
|
||||
},
|
||||
[updateNodeData]
|
||||
)
|
||||
|
||||
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
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
|
|
@ -51,13 +115,63 @@ export default function DialogueNode({ id, data }: NodeProps<DialogueNodeData>)
|
|||
Dialogue
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={data.speaker || ''}
|
||||
onChange={handleSpeakerChange}
|
||||
placeholder="Speaker"
|
||||
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"
|
||||
/>
|
||||
<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
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
</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
|
||||
value={data.text || ''}
|
||||
|
|
|
|||
Loading…
Reference in New Issue