From 0d72471f8f7956bb483d8d63ac8f46913a2be1ca Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Fri, 23 Jan 2026 15:21:09 -0300 Subject: [PATCH] feat: [US-064] - Export validation for undefined references Co-Authored-By: Claude Opus 4.5 --- .../editor/[projectId]/FlowchartEditor.tsx | 111 ++++++++++++++- src/app/globals.css | 13 ++ .../editor/ExportValidationModal.tsx | 131 ++++++++++++++++++ 3 files changed, 253 insertions(+), 2 deletions(-) create mode 100644 src/components/editor/ExportValidationModal.tsx diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index f468ed6..ade31fe 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -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(null) const [toastMessage, setToastMessage] = useState(migratedData.toastMessage) + const [validationIssues, setValidationIssues] = useState(null) + const [warningNodeIds, setWarningNodeIds] = useState>(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 />
setSelectedEdgeId(null)} /> )} + {validationIssues && ( + + )} {toastMessage && ( 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 ( +
+
+
+
+ + + +

+ Export Validation Issues +

+
+

+ {issues.length} undefined reference{issues.length !== 1 ? 's' : ''} found. These nodes/edges reference characters or variables that no longer exist. +

+
+ +
+ {characterIssues.length > 0 && ( +
+

+ Undefined Characters +

+
    + {characterIssues.map((issue, idx) => ( +
  • +
    +
    + + {issue.nodeType} + +

    + {issue.contentSnippet} +

    +
    + + ID: {issue.undefinedReference.slice(0, 8)}... + +
    +
  • + ))} +
+
+ )} + + {variableIssues.length > 0 && ( +
+

+ Undefined Variables +

+
    + {variableIssues.map((issue, idx) => ( +
  • +
    +
    + + {issue.nodeType} + +

    + {issue.contentSnippet} +

    +
    + + ID: {issue.undefinedReference.slice(0, 8)}... + +
    +
  • + ))} +
+
+ )} +
+ +
+ + +
+
+
+ ) +}