From 4ef6ac5f164dc97c5ddd5b9f307bd84ec0df8871 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Sat, 20 Sep 2025 17:52:18 -0500 Subject: [PATCH] fix: resolve infinite render loop when clicking Next in tutorial player MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added isInitializing flag to prevent onValueChange callback feedback loop during step transitions. When a new step initializes, AbacusReact would call onValueChange with the startValue, causing TutorialPlayer to set the same value again, creating an infinite loop. Also removed problematic auto-click play function from EditingMode story that was interfering with component state. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../tutorial/TutorialEditor.stories.tsx | 290 +++++++++ .../components/tutorial/TutorialPlayer.tsx | 568 ++++++++++++++++++ 2 files changed, 858 insertions(+) create mode 100644 apps/web/src/components/tutorial/TutorialEditor.stories.tsx create mode 100644 apps/web/src/components/tutorial/TutorialPlayer.tsx diff --git a/apps/web/src/components/tutorial/TutorialEditor.stories.tsx b/apps/web/src/components/tutorial/TutorialEditor.stories.tsx new file mode 100644 index 00000000..6bcf7fa0 --- /dev/null +++ b/apps/web/src/components/tutorial/TutorialEditor.stories.tsx @@ -0,0 +1,290 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { action } from '@storybook/addon-actions' +import { TutorialEditor } from './TutorialEditor' +import { DevAccessProvider } from '../../hooks/useAccessControl' +import { getTutorialForEditor } from '../../utils/tutorialConverter' +import { TutorialValidation } from '../../types/tutorial' + +const meta: Meta = { + title: 'Tutorial/TutorialEditor', + component: TutorialEditor, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: ` +The TutorialEditor component provides a comprehensive editing interface for creating and modifying tutorial content. +It includes both the editor interface and an integrated preview player for testing changes. + +## Features +- Visual step editor with form-based editing +- Real-time validation and error reporting +- Integrated tutorial player for preview +- Step management (add, duplicate, delete, reorder) +- Tutorial metadata editing +- Save/load functionality hooks +- Access control integration + +## Editor Capabilities +- Edit tutorial metadata (title, description, category, difficulty, tags) +- Manage tutorial steps with detailed form controls +- Set start/target values and highlight configurations +- Edit tooltips, error messages, and multi-step instructions +- Validate tutorial structure and content +- Preview changes in real-time + ` + } + } + }, + decorators: [ + (Story) => ( + +
+ +
+
+ ) + ], + tags: ['autodocs'] +} + +export default meta +type Story = StoryObj + +const mockTutorial = getTutorialForEditor() + +// Mock validation function that returns realistic validation results +const mockValidate = async (tutorial: any): Promise => { + const errors = [] + const warnings = [] + + // Simulate some validation logic + if (!tutorial.title.trim()) { + errors.push({ + stepId: '', + field: 'title', + message: 'Tutorial title is required', + severity: 'error' as const + }) + } + + if (tutorial.steps.length === 0) { + errors.push({ + stepId: '', + field: 'steps', + message: 'Tutorial must have at least one step', + severity: 'error' as const + }) + } + + // Add some warnings for demonstration + if (tutorial.description.length < 50) { + warnings.push({ + stepId: '', + field: 'description', + message: 'Tutorial description could be more detailed', + severity: 'warning' as const + }) + } + + tutorial.steps.forEach((step: any, index: number) => { + if (step.startValue === step.targetValue) { + warnings.push({ + stepId: step.id, + field: 'values', + message: `Step ${index + 1}: Start and target values are the same`, + severity: 'warning' as const + }) + } + }) + + return { + isValid: errors.length === 0, + errors, + warnings + } +} + +export const Default: Story = { + args: { + tutorial: mockTutorial, + onSave: action('save-tutorial'), + onValidate: mockValidate, + onPreview: action('preview-step') + }, + parameters: { + docs: { + description: { + story: 'Default tutorial editor with the guided addition tutorial loaded for editing.' + } + } + } +} + +export const EditingMode: Story = { + args: { + ...Default.args + }, + parameters: { + docs: { + description: { + story: ` +Tutorial editor in editing mode. This story demonstrates: + +**Editor Features:** +- Click "Edit Tutorial" to enable editing mode +- Modify tutorial metadata in the left sidebar +- Click on steps to expand detailed editing forms +- Add, duplicate, or delete steps using step controls +- Use "Preview" buttons to test steps in the integrated player +- Real-time validation shows errors and warnings + +**Try These Actions:** +1. Click "Edit Tutorial" to enable editing +2. Modify the tutorial title or description +3. Click on a step to expand its editing form +4. Change step values and see validation updates +5. Add a new step using the "+ Add Step" button +6. Preview specific steps using the "Preview" buttons + ` + } + } + }, +} + +export const WithValidationErrors: Story = { + args: { + tutorial: { + ...mockTutorial, + title: '', // This will trigger a validation error + description: 'Short desc', // This will trigger a warning + steps: mockTutorial.steps.map(step => ({ + ...step, + startValue: step.targetValue // This will trigger warnings + })) + }, + onSave: action('save-tutorial'), + onValidate: mockValidate, + onPreview: action('preview-step') + }, + parameters: { + docs: { + description: { + story: 'Tutorial editor with validation errors and warnings to demonstrate the validation system.' + } + } + } +} + +export const MinimalTutorial: Story = { + args: { + tutorial: { + ...mockTutorial, + steps: mockTutorial.steps.slice(0, 2) // Only 2 steps for easier editing demo + }, + onSave: action('save-tutorial'), + onValidate: mockValidate, + onPreview: action('preview-step') + }, + parameters: { + docs: { + description: { + story: 'Tutorial editor with a minimal tutorial (2 steps) for easier demonstration of editing features.' + } + } + } +} + +export const ReadOnlyPreview: Story = { + args: { + tutorial: mockTutorial, + onSave: undefined, // No save function = read-only mode + onValidate: mockValidate, + onPreview: action('preview-step') + }, + parameters: { + docs: { + description: { + story: 'Tutorial editor in read-only mode (no save function provided) showing the preview functionality.' + } + } + } +} + +export const CustomTutorial: Story = { + args: { + tutorial: { + id: 'custom-tutorial', + title: 'Custom Math Tutorial', + description: 'A custom tutorial for demonstrating the editor capabilities with different content.', + category: 'Advanced Operations', + difficulty: 'intermediate' as const, + estimatedDuration: 15, + steps: [ + { + id: 'custom-1', + title: 'Custom Step 1', + problem: '5 + 5', + description: 'Add 5 to 5 using the heaven bead', + startValue: 5, + targetValue: 10, + highlightBeads: [{ columnIndex: 0, beadType: 'heaven' as const }], + expectedAction: 'add' as const, + actionDescription: 'Click the heaven bead', + tooltip: { + content: 'Using heaven bead for 10', + explanation: 'When adding 5 to 5, use the tens place heaven bead' + }, + errorMessages: { + wrongBead: 'Click the tens place heaven bead', + wrongAction: 'Move the bead down to activate', + hint: 'Think about place value' + } + }, + { + id: 'custom-2', + title: 'Custom Step 2', + problem: '7 + 8', + description: 'A more complex addition problem', + startValue: 7, + targetValue: 15, + highlightBeads: [ + { columnIndex: 1, beadType: 'heaven' as const }, + { columnIndex: 0, beadType: 'heaven' as const } + ], + expectedAction: 'multi-step' as const, + actionDescription: 'Activate both heaven beads for 15', + multiStepInstructions: [ + 'Click the tens place heaven bead', + 'Click the ones place heaven bead' + ], + tooltip: { + content: 'Complex addition', + explanation: '7 + 8 = 15, which needs both tens and ones heaven beads' + }, + errorMessages: { + wrongBead: 'Follow the two-step process', + wrongAction: 'Activate both heaven beads', + hint: '15 = 10 + 5' + } + } + ], + tags: ['custom', 'demo', 'advanced'], + author: 'Demo Author', + version: '1.0.0', + createdAt: new Date(), + updatedAt: new Date(), + isPublished: false + }, + onSave: action('save-tutorial'), + onValidate: mockValidate, + onPreview: action('preview-step') + }, + parameters: { + docs: { + description: { + story: 'Tutorial editor with custom tutorial content to demonstrate editing different types of mathematical operations.' + } + } + } +} \ No newline at end of file diff --git a/apps/web/src/components/tutorial/TutorialPlayer.tsx b/apps/web/src/components/tutorial/TutorialPlayer.tsx new file mode 100644 index 00000000..d152d652 --- /dev/null +++ b/apps/web/src/components/tutorial/TutorialPlayer.tsx @@ -0,0 +1,568 @@ +'use client' + +import { useState, useCallback, useEffect, useRef } from 'react' +import { AbacusReact } from '@soroban/abacus-react' +import { css } from '../../styled-system/css' +import { stack, hstack, vstack } from '../../styled-system/patterns' +import { Tutorial, TutorialStep, TutorialEvent, NavigationState, UIState } from '../../types/tutorial' + +interface TutorialPlayerProps { + tutorial: Tutorial + initialStepIndex?: number + isDebugMode?: boolean + showDebugPanel?: boolean + onStepChange?: (stepIndex: number, step: TutorialStep) => void + onStepComplete?: (stepIndex: number, step: TutorialStep, success: boolean) => void + onTutorialComplete?: (score: number, timeSpent: number) => void + onEvent?: (event: TutorialEvent) => void + className?: string +} + +export function TutorialPlayer({ + tutorial, + initialStepIndex = 0, + isDebugMode = false, + showDebugPanel = false, + onStepChange, + onStepComplete, + onTutorialComplete, + onEvent, + className +}: TutorialPlayerProps) { + const [currentStepIndex, setCurrentStepIndex] = useState(initialStepIndex) + const [currentValue, setCurrentValue] = useState(0) + const [isStepCompleted, setIsStepCompleted] = useState(false) + const [error, setError] = useState(null) + const [isInitializing, setIsInitializing] = useState(false) + const [events, setEvents] = useState([]) + const [startTime] = useState(Date.now()) + const [stepStartTime, setStepStartTime] = useState(Date.now()) + const [uiState, setUIState] = useState({ + isPlaying: true, + isPaused: false, + isEditing: false, + showDebugPanel, + showStepList: false, + autoAdvance: false, + playbackSpeed: 1 + }) + + const currentStep = tutorial.steps[currentStepIndex] + const beadRefs = useRef>(new Map()) + + // Navigation state + const navigationState: NavigationState = { + currentStepIndex, + canGoNext: currentStepIndex < tutorial.steps.length - 1, + canGoPrevious: currentStepIndex > 0, + totalSteps: tutorial.steps.length, + completionPercentage: (currentStepIndex / tutorial.steps.length) * 100 + } + + // Event logging + const logEvent = useCallback((event: TutorialEvent) => { + setEvents(prev => [...prev, event]) + onEvent?.(event) + }, [onEvent]) + + // Initialize step + useEffect(() => { + if (currentStep) { + setIsInitializing(true) + setCurrentValue(currentStep.startValue) + setIsStepCompleted(false) + setError(null) + setStepStartTime(Date.now()) + + logEvent({ + type: 'STEP_STARTED', + stepId: currentStep.id, + timestamp: new Date() + }) + + onStepChange?.(currentStepIndex, currentStep) + + // Clear initialization flag after a brief delay + setTimeout(() => setIsInitializing(false), 50) + } + }, [currentStepIndex, currentStep, onStepChange, logEvent]) + + // Check if step is completed + useEffect(() => { + if (currentStep && currentValue === currentStep.targetValue && !isStepCompleted) { + setIsStepCompleted(true) + setError(null) + + logEvent({ + type: 'STEP_COMPLETED', + stepId: currentStep.id, + success: true, + timestamp: new Date() + }) + + onStepComplete?.(currentStepIndex, currentStep, true) + + // Auto-advance if enabled + if (uiState.autoAdvance && navigationState.canGoNext) { + setTimeout(() => goToNextStep(), 1500) + } + } + }, [currentValue, currentStep, isStepCompleted, uiState.autoAdvance, navigationState.canGoNext, logEvent, onStepComplete, currentStepIndex]) + + // Navigation functions + const goToStep = useCallback((stepIndex: number) => { + if (stepIndex >= 0 && stepIndex < tutorial.steps.length) { + setCurrentStepIndex(stepIndex) + } + }, [tutorial.steps.length]) + + const goToNextStep = useCallback(() => { + if (navigationState.canGoNext) { + goToStep(currentStepIndex + 1) + } else if (currentStepIndex === tutorial.steps.length - 1) { + // Tutorial completed + const timeSpent = (Date.now() - startTime) / 1000 + const score = events.filter(e => e.type === 'STEP_COMPLETED' && e.success).length / tutorial.steps.length * 100 + + logEvent({ + type: 'TUTORIAL_COMPLETED', + tutorialId: tutorial.id, + score, + timestamp: new Date() + }) + + onTutorialComplete?.(score, timeSpent) + } + }, [navigationState.canGoNext, currentStepIndex, tutorial.steps.length, tutorial.id, startTime, events, logEvent, onTutorialComplete, goToStep]) + + const goToPreviousStep = useCallback(() => { + if (navigationState.canGoPrevious) { + goToStep(currentStepIndex - 1) + } + }, [navigationState.canGoPrevious, currentStepIndex, goToStep]) + + // Abacus event handlers + const handleValueChange = useCallback((newValue: number) => { + // Ignore value changes during step initialization to prevent loops + if (isInitializing) { + return + } + + const oldValue = currentValue + setCurrentValue(newValue) + + logEvent({ + type: 'VALUE_CHANGED', + stepId: currentStep.id, + oldValue, + newValue, + timestamp: new Date() + }) + }, [currentValue, currentStep, logEvent, isInitializing]) + + const handleBeadClick = useCallback((beadInfo: any) => { + logEvent({ + type: 'BEAD_CLICKED', + stepId: currentStep.id, + beadInfo, + timestamp: new Date() + }) + + // Check if this is the correct action + if (currentStep.highlightBeads && Array.isArray(currentStep.highlightBeads)) { + const isCorrectBead = currentStep.highlightBeads.some(highlight => + highlight.columnIndex === beadInfo.columnIndex && + highlight.beadType === beadInfo.beadType && + (highlight.position === undefined || highlight.position === beadInfo.position) + ) + + if (!isCorrectBead) { + setError(currentStep.errorMessages.wrongBead) + + logEvent({ + type: 'ERROR_OCCURRED', + stepId: currentStep.id, + error: currentStep.errorMessages.wrongBead, + timestamp: new Date() + }) + } else { + setError(null) + } + } + }, [currentStep, logEvent]) + + const handleBeadRef = useCallback((bead: any, element: SVGElement | null) => { + const key = `${bead.columnIndex}-${bead.type}-${bead.position}` + if (element) { + beadRefs.current.set(key, element) + } else { + beadRefs.current.delete(key) + } + }, []) + + // UI state updaters + const toggleDebugPanel = useCallback(() => { + setUIState(prev => ({ ...prev, showDebugPanel: !prev.showDebugPanel })) + }, []) + + const toggleStepList = useCallback(() => { + setUIState(prev => ({ ...prev, showStepList: !prev.showStepList })) + }, []) + + const toggleAutoAdvance = useCallback(() => { + setUIState(prev => ({ ...prev, autoAdvance: !prev.autoAdvance })) + }, []) + + if (!currentStep) { + return
No steps available
+ } + + return ( +
+ {/* Header */} +
+
+
+

+ {tutorial.title} +

+

+ Step {currentStepIndex + 1} of {tutorial.steps.length}: {currentStep.title} +

+
+ +
+ {isDebugMode && ( + <> + + + + + )} +
+
+ + {/* Progress bar */} +
+
+
+
+ +
+ {/* Step list sidebar */} + {uiState.showStepList && ( +
+

Tutorial Steps

+
+ {tutorial.steps && Array.isArray(tutorial.steps) ? tutorial.steps.map((step, index) => ( + + )) : ( +
+ No tutorial steps available +
+ )} +
+
+ )} + + {/* Main content */} +
+ {/* Step content */} +
+
+ {/* Step instructions */} +
+

+ {currentStep.problem} +

+

+ {currentStep.description} +

+

+ {currentStep.actionDescription} +

+
+ + {/* Error message */} + {error && ( +
+ {error} +
+ )} + + {/* Success message */} + {isStepCompleted && ( +
+ Great! You completed this step correctly. +
+ )} + + {/* Abacus */} +
+ ({ + ...acc, + [highlight.columnIndex]: { + [highlight.beadType]: highlight.beadType === 'earth' && highlight.position !== undefined + ? { [highlight.position]: { fill: '#fbbf24', stroke: '#f59e0b', strokeWidth: 3 } } + : { fill: '#fbbf24', stroke: '#f59e0b', strokeWidth: 3 } + } + }), {}) + } : undefined} + onValueChange={handleValueChange} + callbacks={{ + onBeadClick: handleBeadClick, + onBeadRef: handleBeadRef + }} + /> +
+ + {/* Tooltip */} + {currentStep.tooltip && ( +
+

+ {currentStep.tooltip.content} +

+

+ {currentStep.tooltip.explanation} +

+
+ )} +
+
+ + {/* Navigation controls */} +
+
+ + +
+ Step {currentStepIndex + 1} of {navigationState.totalSteps} +
+ + +
+
+
+ + {/* Debug panel */} + {uiState.showDebugPanel && ( +
+

Debug Panel

+ +
+ {/* Current state */} +
+

Current State

+
+
Step: {currentStepIndex + 1}/{navigationState.totalSteps}
+
Value: {currentValue}
+
Target: {currentStep.targetValue}
+
Completed: {isStepCompleted ? 'Yes' : 'No'}
+
Time: {Math.round((Date.now() - stepStartTime) / 1000)}s
+
+
+ + {/* Event log */} +
+

Event Log

+
+ {events.slice(-20).reverse().map((event, index) => ( +
+
+ {event.type} +
+
+ {event.timestamp.toLocaleTimeString()} +
+ {event.type === 'VALUE_CHANGED' && ( +
{event.oldValue} → {event.newValue}
+ )} + {event.type === 'ERROR_OCCURRED' && ( +
{event.error}
+ )} +
+ ))} +
+
+
+
+ )} +
+
+ ) +} \ No newline at end of file