feat: [US-064] - Export validation for undefined references
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
a85d7cbeb3
commit
0d72471f8f
|
|
@ -24,6 +24,7 @@ import ChoiceNode from '@/components/editor/nodes/ChoiceNode'
|
|||
import VariableNode from '@/components/editor/nodes/VariableNode'
|
||||
import ProjectSettingsModal from '@/components/editor/ProjectSettingsModal'
|
||||
import ConditionEditor from '@/components/editor/ConditionEditor'
|
||||
import ExportValidationModal, { type ValidationIssue } from '@/components/editor/ExportValidationModal'
|
||||
import { EditorProvider } from '@/components/editor/EditorContext'
|
||||
import Toast from '@/components/Toast'
|
||||
import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable, Condition } from '@/types/flowchart'
|
||||
|
|
@ -229,6 +230,8 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
|
|||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null)
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(migratedData.toastMessage)
|
||||
const [validationIssues, setValidationIssues] = useState<ValidationIssue[] | null>(null)
|
||||
const [warningNodeIds, setWarningNodeIds] = useState<Set<string>>(new Set())
|
||||
|
||||
const handleAddCharacter = useCallback(
|
||||
(name: string, color: string): string => {
|
||||
|
|
@ -360,8 +363,92 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
|
|||
// TODO: Implement in US-034
|
||||
}, [])
|
||||
|
||||
const performExport = useCallback(() => {
|
||||
// TODO: Actual export logic in US-035
|
||||
setValidationIssues(null)
|
||||
setWarningNodeIds(new Set())
|
||||
}, [])
|
||||
|
||||
const handleExport = useCallback(() => {
|
||||
// TODO: Implement in US-035
|
||||
const issues: ValidationIssue[] = []
|
||||
const characterIds = new Set(characters.map((c) => c.id))
|
||||
const variableIds = new Set(variables.map((v) => v.id))
|
||||
|
||||
// Scan nodes for undefined references
|
||||
nodes.forEach((node) => {
|
||||
if (node.type === 'dialogue' && node.data?.characterId) {
|
||||
if (!characterIds.has(node.data.characterId)) {
|
||||
issues.push({
|
||||
nodeId: node.id,
|
||||
nodeType: 'dialogue',
|
||||
contentSnippet: node.data.text
|
||||
? `"${node.data.text.slice(0, 40)}${node.data.text.length > 40 ? '...' : ''}"`
|
||||
: 'Empty dialogue',
|
||||
undefinedReference: node.data.characterId,
|
||||
referenceType: 'character',
|
||||
})
|
||||
}
|
||||
}
|
||||
if (node.type === 'variable' && node.data?.variableId) {
|
||||
if (!variableIds.has(node.data.variableId)) {
|
||||
issues.push({
|
||||
nodeId: node.id,
|
||||
nodeType: 'variable',
|
||||
contentSnippet: node.data.variableName
|
||||
? `Variable: ${node.data.variableName}`
|
||||
: 'Variable node',
|
||||
undefinedReference: node.data.variableId,
|
||||
referenceType: 'variable',
|
||||
})
|
||||
}
|
||||
}
|
||||
if (node.type === 'choice' && node.data?.options) {
|
||||
node.data.options.forEach((opt: { id: string; label: string; condition?: { variableId?: string; variableName?: string } }) => {
|
||||
if (opt.condition?.variableId && !variableIds.has(opt.condition.variableId)) {
|
||||
issues.push({
|
||||
nodeId: node.id,
|
||||
nodeType: 'choice',
|
||||
contentSnippet: opt.label
|
||||
? `Option: "${opt.label.slice(0, 30)}${opt.label.length > 30 ? '...' : ''}"`
|
||||
: 'Choice option',
|
||||
undefinedReference: opt.condition.variableId,
|
||||
referenceType: 'variable',
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Scan edges for undefined variable references in conditions
|
||||
edges.forEach((edge) => {
|
||||
if (edge.data?.condition?.variableId && !variableIds.has(edge.data.condition.variableId)) {
|
||||
issues.push({
|
||||
nodeId: edge.id,
|
||||
nodeType: 'edge',
|
||||
contentSnippet: edge.data.condition.variableName
|
||||
? `Condition on: ${edge.data.condition.variableName}`
|
||||
: 'Edge condition',
|
||||
undefinedReference: edge.data.condition.variableId,
|
||||
referenceType: 'variable',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if (issues.length === 0) {
|
||||
performExport()
|
||||
} else {
|
||||
setValidationIssues(issues)
|
||||
setWarningNodeIds(new Set(issues.map((i) => i.nodeId)))
|
||||
}
|
||||
}, [nodes, edges, characters, variables, performExport])
|
||||
|
||||
const handleExportAnyway = useCallback(() => {
|
||||
performExport()
|
||||
}, [performExport])
|
||||
|
||||
const handleExportCancel = useCallback(() => {
|
||||
setValidationIssues(null)
|
||||
setWarningNodeIds(new Set())
|
||||
}, [])
|
||||
|
||||
const handleImport = useCallback(() => {
|
||||
|
|
@ -394,6 +481,19 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
|
|||
[setEdges]
|
||||
)
|
||||
|
||||
// Apply warning styles to nodes with undefined references
|
||||
const styledNodes = useMemo(
|
||||
() =>
|
||||
warningNodeIds.size === 0
|
||||
? nodes
|
||||
: nodes.map((node) =>
|
||||
warningNodeIds.has(node.id)
|
||||
? { ...node, className: 'export-warning-node' }
|
||||
: node
|
||||
),
|
||||
[nodes, warningNodeIds]
|
||||
)
|
||||
|
||||
// Get the selected edge's condition data
|
||||
const selectedEdge = useMemo(
|
||||
() => (selectedEdgeId ? edges.find((e) => e.id === selectedEdgeId) : null),
|
||||
|
|
@ -414,7 +514,7 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
|
|||
/>
|
||||
<div className="flex-1">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
nodes={styledNodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodesChange={onNodesChange}
|
||||
|
|
@ -449,6 +549,13 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
|
|||
onClose={() => setSelectedEdgeId(null)}
|
||||
/>
|
||||
)}
|
||||
{validationIssues && (
|
||||
<ExportValidationModal
|
||||
issues={validationIssues}
|
||||
onExportAnyway={handleExportAnyway}
|
||||
onCancel={handleExportCancel}
|
||||
/>
|
||||
)}
|
||||
{toastMessage && (
|
||||
<Toast
|
||||
message={toastMessage}
|
||||
|
|
|
|||
|
|
@ -24,3 +24,16 @@ body {
|
|||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
/* Export validation warning highlighting for React Flow nodes */
|
||||
.react-flow__node.export-warning-node {
|
||||
outline: 3px solid #f97316;
|
||||
outline-offset: 2px;
|
||||
border-radius: 6px;
|
||||
animation: pulse-warning 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-warning {
|
||||
0%, 100% { outline-color: #f97316; }
|
||||
50% { outline-color: #fb923c; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,131 @@
|
|||
'use client'
|
||||
|
||||
export type ValidationIssue = {
|
||||
nodeId: string
|
||||
nodeType: 'dialogue' | 'choice' | 'variable' | 'edge'
|
||||
contentSnippet: string
|
||||
undefinedReference: string
|
||||
referenceType: 'character' | 'variable'
|
||||
}
|
||||
|
||||
type ExportValidationModalProps = {
|
||||
issues: ValidationIssue[]
|
||||
onExportAnyway: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export default function ExportValidationModal({
|
||||
issues,
|
||||
onExportAnyway,
|
||||
onCancel,
|
||||
}: ExportValidationModalProps) {
|
||||
const characterIssues = issues.filter((i) => i.referenceType === 'character')
|
||||
const variableIssues = issues.filter((i) => i.referenceType === 'variable')
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="mx-4 w-full max-w-lg rounded-lg bg-white shadow-xl dark:bg-zinc-800">
|
||||
<div className="border-b border-zinc-200 px-6 py-4 dark:border-zinc-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
className="h-5 w-5 text-orange-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
Export Validation Issues
|
||||
</h2>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{issues.length} undefined reference{issues.length !== 1 ? 's' : ''} found. These nodes/edges reference characters or variables that no longer exist.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[50vh] overflow-y-auto px-6 py-4">
|
||||
{characterIssues.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h3 className="mb-2 text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
||||
Undefined Characters
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{characterIssues.map((issue, idx) => (
|
||||
<li
|
||||
key={`char-${idx}`}
|
||||
className="rounded border border-orange-200 bg-orange-50 p-3 dark:border-orange-800 dark:bg-orange-950"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<span className="inline-block rounded bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900 dark:text-blue-300">
|
||||
{issue.nodeType}
|
||||
</span>
|
||||
<p className="mt-1 truncate text-sm text-zinc-700 dark:text-zinc-300">
|
||||
{issue.contentSnippet}
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs text-orange-600 dark:text-orange-400">
|
||||
ID: {issue.undefinedReference.slice(0, 8)}...
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{variableIssues.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
||||
Undefined Variables
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{variableIssues.map((issue, idx) => (
|
||||
<li
|
||||
key={`var-${idx}`}
|
||||
className="rounded border border-orange-200 bg-orange-50 p-3 dark:border-orange-800 dark:bg-orange-950"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<span className="inline-block rounded bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900 dark:text-blue-300">
|
||||
{issue.nodeType}
|
||||
</span>
|
||||
<p className="mt-1 truncate text-sm text-zinc-700 dark:text-zinc-300">
|
||||
{issue.contentSnippet}
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs text-orange-600 dark:text-orange-400">
|
||||
ID: {issue.undefinedReference.slice(0, 8)}...
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 border-t border-zinc-200 px-6 py-4 dark:border-zinc-700">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="rounded border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onExportAnyway}
|
||||
className="rounded bg-orange-500 px-4 py-2 text-sm font-medium text-white hover:bg-orange-600 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800"
|
||||
>
|
||||
Export anyway
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue