Files
OpenNoodl/dev-docs/tasks/phase-2-react-migration/TASK-004-runtime-migration-system/02-MIGRATION-WIZARD.md

26 KiB

02 - Migration Wizard

Overview

A step-by-step wizard that guides users through the migration process. The wizard handles project copying, scanning, reporting, and executing migrations.

Wizard Steps

  1. Confirm - Confirm source/target paths
  2. Scan - Analyze project for migration needs
  3. Report - Show what needs to change
  4. Configure - (Optional) Set up AI assistance
  5. Migrate - Execute the migration
  6. Complete - Summary and next steps

State Machine

// packages/noodl-editor/src/editor/src/models/migration/MigrationSession.ts

type MigrationStep = 
  | 'confirm'
  | 'scanning'
  | 'report'
  | 'configureAi'
  | 'migrating'
  | 'complete'
  | 'failed';

interface MigrationSession {
  id: string;
  step: MigrationStep;
  
  // Source project
  source: {
    path: string;
    name: string;
    runtimeVersion: 'react17';
  };
  
  // Target (copy) project
  target: {
    path: string;
    copied: boolean;
  };
  
  // Scan results
  scan?: {
    completedAt: string;
    totalComponents: number;
    totalNodes: number;
    customJsFiles: number;
    categories: {
      automatic: ComponentMigrationInfo[];
      simpleFixes: ComponentMigrationInfo[];
      needsReview: ComponentMigrationInfo[];
    };
  };
  
  // AI configuration
  ai?: {
    enabled: boolean;
    apiKey?: string; // Only stored in memory during session
    budget: {
      max: number;
      spent: number;
      pauseIncrement: number;
    };
  };
  
  // Migration progress
  progress?: {
    phase: 'copying' | 'automatic' | 'ai-assisted' | 'finalizing';
    current: number;
    total: number;
    currentComponent?: string;
    log: MigrationLogEntry[];
  };
  
  // Final result
  result?: {
    success: boolean;
    migrated: number;
    needsReview: number;
    failed: number;
    totalCost: number;
    duration: number;
  };
}

interface ComponentMigrationInfo {
  id: string;
  name: string;
  path: string;
  issues: MigrationIssue[];
  estimatedCost?: number;
}

interface MigrationIssue {
  id: string;
  type: MigrationIssueType;
  description: string;
  location: {
    file: string;
    line: number;
    column?: number;
  };
  autoFixable: boolean;
  fix?: {
    type: 'automatic' | 'ai-required';
    description: string;
  };
}

type MigrationIssueType = 
  | 'componentWillMount'
  | 'componentWillReceiveProps'
  | 'componentWillUpdate'
  | 'unsafeLifecycle'
  | 'stringRef'
  | 'legacyContext'
  | 'createFactory'
  | 'findDOMNode'
  | 'reactDomRender'
  | 'other';

interface MigrationLogEntry {
  timestamp: string;
  level: 'info' | 'success' | 'warning' | 'error';
  component?: string;
  message: string;
  details?: string;
  cost?: number;
}

Step 1: Confirm

// packages/noodl-editor/src/editor/src/views/migration/steps/ConfirmStep.tsx

interface ConfirmStepProps {
  session: MigrationSession;
  onUpdateTarget: (path: string) => void;
  onNext: () => void;
  onCancel: () => void;
}

function ConfirmStep({ session, onUpdateTarget, onNext, onCancel }: ConfirmStepProps) {
  const [targetPath, setTargetPath] = useState(session.target.path);
  const [targetExists, setTargetExists] = useState(false);
  
  useEffect(() => {
    checkPathExists(targetPath).then(setTargetExists);
  }, [targetPath]);
  
  const handleTargetChange = (newPath: string) => {
    setTargetPath(newPath);
    onUpdateTarget(newPath);
  };
  
  return (
    <WizardStep
      title="Migrate Project"
      subtitle="We'll create a copy of your project and migrate it to React 19"
    >
      <div className={css['confirm-step']}>
        <PathSection
          label="Original Project (will not be modified)"
          path={session.source.path}
          icon={<LockIcon />}
          readonly
        />
        
        <div className={css['arrow-down']}>
          <ArrowDownIcon />
          <span>Creates copy</span>
        </div>
        
        <PathSection
          label="Migrated Copy"
          path={targetPath}
          onChange={handleTargetChange}
          error={targetExists ? 'A folder already exists at this location' : undefined}
          icon={<FolderPlusIcon />}
        />
        
        {targetExists && (
          <div className={css['path-exists-options']}>
            <Button 
              variant="secondary" 
              size="small"
              onClick={() => handleTargetChange(`${targetPath}-${Date.now()}`)}
            >
              Use Different Name
            </Button>
            <Button
              variant="ghost"
              size="small"
              onClick={() => confirmOverwrite()}
            >
              Overwrite Existing
            </Button>
          </div>
        )}
        
        <InfoBox type="info">
          <p>
            <strong>What happens next:</strong>
          </p>
          <ol>
            <li>Your project will be copied to the new location</li>
            <li>We'll scan for compatibility issues</li>
            <li>You'll see a report of what needs to change</li>
            <li>Optionally, AI can help fix complex code</li>
          </ol>
        </InfoBox>
      </div>
      
      <WizardActions>
        <Button variant="secondary" onClick={onCancel}>
          Cancel
        </Button>
        <Button 
          variant="primary" 
          onClick={onNext}
          disabled={targetExists}
        >
          Start Migration
        </Button>
      </WizardActions>
    </WizardStep>
  );
}

Step 2: Scanning

// packages/noodl-editor/src/editor/src/views/migration/steps/ScanningStep.tsx

interface ScanningStepProps {
  session: MigrationSession;
  onComplete: (scan: MigrationScan) => void;
  onError: (error: Error) => void;
}

function ScanningStep({ session, onComplete, onError }: ScanningStepProps) {
  const [phase, setPhase] = useState<'copying' | 'scanning'>('copying');
  const [progress, setProgress] = useState(0);
  const [currentItem, setCurrentItem] = useState('');
  const [stats, setStats] = useState({ components: 0, nodes: 0, jsFiles: 0 });
  
  useEffect(() => {
    runScan();
  }, []);
  
  const runScan = async () => {
    try {
      // Phase 1: Copy project
      setPhase('copying');
      await copyProject(session.source.path, session.target.path, {
        onProgress: (p, item) => {
          setProgress(p * 50); // 0-50%
          setCurrentItem(item);
        }
      });
      
      // Phase 2: Scan for issues
      setPhase('scanning');
      const scan = await scanProject(session.target.path, {
        onProgress: (p, item, partialStats) => {
          setProgress(50 + p * 50); // 50-100%
          setCurrentItem(item);
          setStats(partialStats);
        }
      });
      
      onComplete(scan);
    } catch (error) {
      onError(error);
    }
  };
  
  return (
    <WizardStep
      title={phase === 'copying' ? 'Copying Project...' : 'Analyzing Project...'}
      subtitle={phase === 'copying' 
        ? 'Creating a safe copy before making any changes'
        : 'Scanning components for compatibility issues'
      }
    >
      <div className={css['scanning-step']}>
        <ProgressBar value={progress} max={100} />
        
        <div className={css['scanning-current']}>
          {currentItem && (
            <>
              <Spinner size={14} />
              <span>{currentItem}</span>
            </>
          )}
        </div>
        
        <div className={css['scanning-stats']}>
          <StatBox label="Components" value={stats.components} />
          <StatBox label="Nodes" value={stats.nodes} />
          <StatBox label="JS Files" value={stats.jsFiles} />
        </div>
        
        {phase === 'scanning' && (
          <div className={css['scanning-note']}>
            <InfoIcon size={14} />
            <span>
              Looking for React 17 patterns that need updating...
            </span>
          </div>
        )}
      </div>
    </WizardStep>
  );
}

Step 3: Report

// packages/noodl-editor/src/editor/src/views/migration/steps/ReportStep.tsx

interface ReportStepProps {
  session: MigrationSession;
  onConfigureAi: () => void;
  onMigrateWithoutAi: () => void;
  onMigrateWithAi: () => void;
  onCancel: () => void;
}

function ReportStep({ 
  session, 
  onConfigureAi,
  onMigrateWithoutAi, 
  onMigrateWithAi,
  onCancel 
}: ReportStepProps) {
  const { scan } = session;
  const [expandedCategory, setExpandedCategory] = useState<string | null>(null);
  
  const totalIssues = 
    scan.categories.simpleFixes.length + 
    scan.categories.needsReview.length;
  
  const estimatedCost = scan.categories.simpleFixes
    .concat(scan.categories.needsReview)
    .reduce((sum, c) => sum + (c.estimatedCost || 0), 0);
  
  const allAutomatic = totalIssues === 0;
  
  return (
    <WizardStep
      title="Migration Report"
      subtitle={`${scan.totalComponents} components analyzed`}
    >
      <div className={css['report-step']}>
        {/* Summary Stats */}
        <div className={css['report-summary']}>
          <StatCard 
            icon={<CheckCircleIcon />}
            value={scan.categories.automatic.length}
            label="Automatic"
            variant="success"
          />
          <StatCard
            icon={<ZapIcon />}
            value={scan.categories.simpleFixes.length}
            label="Simple Fixes"
            variant="info"
          />
          <StatCard
            icon={<ToolIcon />}
            value={scan.categories.needsReview.length}
            label="Needs Review"
            variant="warning"
          />
        </div>
        
        {/* Category Details */}
        <div className={css['report-categories']}>
          <CategorySection
            title="Automatic"
            description="These will migrate without any changes"
            icon={<CheckCircleIcon />}
            items={scan.categories.automatic}
            variant="success"
            expanded={expandedCategory === 'automatic'}
            onToggle={() => setExpandedCategory(
              expandedCategory === 'automatic' ? null : 'automatic'
            )}
          />
          
          {scan.categories.simpleFixes.length > 0 && (
            <CategorySection
              title="Simple Fixes"
              description="Minor syntax updates needed"
              icon={<ZapIcon />}
              items={scan.categories.simpleFixes}
              variant="info"
              expanded={expandedCategory === 'simpleFixes'}
              onToggle={() => setExpandedCategory(
                expandedCategory === 'simpleFixes' ? null : 'simpleFixes'
              )}
              showIssueDetails
            />
          )}
          
          {scan.categories.needsReview.length > 0 && (
            <CategorySection
              title="Needs Review"
              description="May require manual adjustment"
              icon={<ToolIcon />}
              items={scan.categories.needsReview}
              variant="warning"
              expanded={expandedCategory === 'needsReview'}
              onToggle={() => setExpandedCategory(
                expandedCategory === 'needsReview' ? null : 'needsReview'
              )}
              showIssueDetails
            />
          )}
        </div>
        
        {/* AI Assistance Prompt */}
        {!allAutomatic && (
          <div className={css['ai-prompt']}>
            <div className={css['ai-prompt__icon']}>
              <RobotIcon size={24} />
            </div>
            <div className={css['ai-prompt__content']}>
              <h4>AI-Assisted Migration Available</h4>
              <p>
                Claude can automatically fix the {totalIssues} components that 
                need code changes. Estimated cost: ~${estimatedCost.toFixed(2)}
              </p>
            </div>
            <Button 
              variant="secondary"
              onClick={onConfigureAi}
            >
              Configure AI Assistant
            </Button>
          </div>
        )}
      </div>
      
      <WizardActions>
        <Button variant="secondary" onClick={onCancel}>
          Cancel
        </Button>
        
        {allAutomatic ? (
          <Button variant="primary" onClick={onMigrateWithoutAi}>
            Migrate Project
          </Button>
        ) : (
          <>
            <Button variant="secondary" onClick={onMigrateWithoutAi}>
              Migrate Without AI
            </Button>
            {session.ai?.enabled && (
              <Button variant="primary" onClick={onMigrateWithAi}>
                Migrate With AI
              </Button>
            )}
          </>
        )}
      </WizardActions>
    </WizardStep>
  );
}

// Category Section Component
function CategorySection({
  title,
  description,
  icon,
  items,
  variant,
  expanded,
  onToggle,
  showIssueDetails = false
}: CategorySectionProps) {
  return (
    <div className={css['category-section', `category-section--${variant}`]}>
      <button 
        className={css['category-header']}
        onClick={onToggle}
      >
        <div className={css['category-header__left']}>
          {icon}
          <div>
            <h4>{title} ({items.length})</h4>
            <p>{description}</p>
          </div>
        </div>
        <ChevronIcon direction={expanded ? 'up' : 'down'} />
      </button>
      
      {expanded && (
        <div className={css['category-items']}>
          {items.map(item => (
            <div key={item.id} className={css['category-item']}>
              <ComponentIcon />
              <div className={css['category-item__info']}>
                <span className={css['category-item__name']}>
                  {item.name}
                </span>
                {showIssueDetails && item.issues.length > 0 && (
                  <ul className={css['category-item__issues']}>
                    {item.issues.map(issue => (
                      <li key={issue.id}>
                        <code>{issue.type}</code>
                        <span>{issue.description}</span>
                      </li>
                    ))}
                  </ul>
                )}
              </div>
              {item.estimatedCost && (
                <span className={css['category-item__cost']}>
                  ~${item.estimatedCost.toFixed(2)}
                </span>
              )}
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

Step 4: Migration Progress

// packages/noodl-editor/src/editor/src/views/migration/steps/MigratingStep.tsx

interface MigratingStepProps {
  session: MigrationSession;
  useAi: boolean;
  onPause: () => void;
  onAiDecision: (decision: AiDecision) => void;
  onComplete: (result: MigrationResult) => void;
  onError: (error: Error) => void;
}

interface AiDecision {
  componentId: string;
  action: 'retry' | 'skip' | 'manual' | 'getHelp';
}

function MigratingStep({
  session,
  useAi,
  onPause,
  onAiDecision,
  onComplete,
  onError
}: MigratingStepProps) {
  const [awaitingDecision, setAwaitingDecision] = useState<AiDecisionRequest | null>(null);
  const { progress, ai } = session;
  
  const budgetPercent = ai ? (ai.budget.spent / ai.budget.max) * 100 : 0;
  
  return (
    <WizardStep
      title={useAi ? 'AI Migration in Progress' : 'Migrating Project...'}
      subtitle={`Phase: ${progress?.phase || 'Starting'}`}
    >
      <div className={css['migrating-step']}>
        {/* Budget Display (if using AI) */}
        {useAi && ai && (
          <div className={css['budget-display']}>
            <div className={css['budget-display__header']}>
              <span>Budget</span>
              <span>${ai.budget.spent.toFixed(2)} / ${ai.budget.max.toFixed(2)}</span>
            </div>
            <ProgressBar 
              value={budgetPercent} 
              max={100}
              variant={budgetPercent > 80 ? 'warning' : 'default'}
            />
          </div>
        )}
        
        {/* Component Progress */}
        <div className={css['component-progress']}>
          {progress?.log.slice(-5).map((entry, i) => (
            <LogEntry key={i} entry={entry} />
          ))}
          
          {progress?.currentComponent && !awaitingDecision && (
            <div className={css['current-component']}>
              <Spinner size={16} />
              <span>{progress.currentComponent}</span>
              {useAi && <span className={css['estimate']}>~$0.08</span>}
            </div>
          )}
        </div>
        
        {/* AI Decision Required */}
        {awaitingDecision && (
          <AiDecisionPanel
            request={awaitingDecision}
            budget={ai?.budget}
            onDecision={(decision) => {
              setAwaitingDecision(null);
              onAiDecision(decision);
            }}
          />
        )}
        
        {/* Overall Progress */}
        <div className={css['overall-progress']}>
          <ProgressBar 
            value={progress?.current || 0} 
            max={progress?.total || 100}
          />
          <span>
            {progress?.current || 0} / {progress?.total || 0} components
          </span>
        </div>
      </div>
      
      <WizardActions>
        <Button 
          variant="secondary" 
          onClick={onPause}
          disabled={!!awaitingDecision}
        >
          Pause Migration
        </Button>
      </WizardActions>
    </WizardStep>
  );
}

// Log Entry Component
function LogEntry({ entry }: { entry: MigrationLogEntry }) {
  const icons = {
    info: <InfoIcon size={14} />,
    success: <CheckIcon size={14} />,
    warning: <WarningIcon size={14} />,
    error: <ErrorIcon size={14} />
  };
  
  return (
    <div className={css['log-entry', `log-entry--${entry.level}`]}>
      {icons[entry.level]}
      <div className={css['log-entry__content']}>
        {entry.component && (
          <span className={css['log-entry__component']}>
            {entry.component}
          </span>
        )}
        <span className={css['log-entry__message']}>
          {entry.message}
        </span>
      </div>
      {entry.cost && (
        <span className={css['log-entry__cost']}>
          ${entry.cost.toFixed(2)}
        </span>
      )}
    </div>
  );
}

// AI Decision Panel
function AiDecisionPanel({
  request,
  budget,
  onDecision
}: {
  request: AiDecisionRequest;
  budget: MigrationBudget;
  onDecision: (decision: AiDecision) => void;
}) {
  return (
    <div className={css['decision-panel']}>
      <div className={css['decision-panel__header']}>
        <ToolIcon size={20} />
        <h4>{request.componentName} - Needs Your Input</h4>
      </div>
      
      <p>
        Claude attempted {request.attempts} migrations but the component 
        still has issues. Here's what happened:
      </p>
      
      <div className={css['decision-panel__attempts']}>
        {request.attemptHistory.map((attempt, i) => (
          <div key={i} className={css['attempt-entry']}>
            <span>Attempt {i + 1}:</span>
            <span>{attempt.description}</span>
          </div>
        ))}
      </div>
      
      <div className={css['decision-panel__cost']}>
        Cost so far: ${request.costSpent.toFixed(2)}
      </div>
      
      <div className={css['decision-panel__options']}>
        <Button
          onClick={() => onDecision({ 
            componentId: request.componentId, 
            action: 'retry' 
          })}
        >
          Try Again (~${request.retryCost.toFixed(2)})
        </Button>
        
        <Button
          variant="secondary"
          onClick={() => onDecision({ 
            componentId: request.componentId, 
            action: 'skip' 
          })}
        >
          Skip Component
        </Button>
        
        <Button
          variant="secondary"
          onClick={() => onDecision({ 
            componentId: request.componentId, 
            action: 'getHelp' 
          })}
        >
          Get Suggestions (~$0.02)
        </Button>
      </div>
    </div>
  );
}

Step 5: Complete

// packages/noodl-editor/src/editor/src/views/migration/steps/CompleteStep.tsx

interface CompleteStepProps {
  session: MigrationSession;
  onViewLog: () => void;
  onOpenProject: () => void;
}

function CompleteStep({ session, onViewLog, onOpenProject }: CompleteStepProps) {
  const { result, source, target } = session;
  
  const hasIssues = result.needsReview > 0;
  
  return (
    <WizardStep
      title={hasIssues ? 'Migration Complete (With Notes)' : 'Migration Complete!'}
      icon={hasIssues ? <CheckWarningIcon /> : <CheckCircleIcon />}
    >
      <div className={css['complete-step']}>
        {/* Summary */}
        <div className={css['complete-summary']}>
          <div className={css['summary-stats']}>
            <StatCard
              icon={<CheckIcon />}
              value={result.migrated}
              label="Migrated"
              variant="success"
            />
            {result.needsReview > 0 && (
              <StatCard
                icon={<WarningIcon />}
                value={result.needsReview}
                label="Needs Review"
                variant="warning"
              />
            )}
            {result.failed > 0 && (
              <StatCard
                icon={<ErrorIcon />}
                value={result.failed}
                label="Failed"
                variant="error"
              />
            )}
          </div>
          
          {session.ai?.enabled && (
            <div className={css['summary-cost']}>
              <RobotIcon size={16} />
              <span>AI cost: ${result.totalCost.toFixed(2)}</span>
            </div>
          )}
          
          <div className={css['summary-time']}>
            <ClockIcon size={16} />
            <span>Time: {formatDuration(result.duration)}</span>
          </div>
        </div>
        
        {/* Project Paths */}
        <div className={css['complete-paths']}>
          <h4>Project Locations</h4>
          
          <PathDisplay
            label="Original (untouched)"
            path={source.path}
            icon={<LockIcon />}
          />
          
          <PathDisplay
            label="Migrated copy"
            path={target.path}
            icon={<FolderIcon />}
            actions={[
              { label: 'Show in Finder', onClick: () => showInFinder(target.path) }
            ]}
          />
        </div>
        
        {/* What's Next */}
        <div className={css['complete-next']}>
          <h4>What's Next?</h4>
          <ol>
            {result.needsReview > 0 && (
              <li>
                <WarningIcon size={14} />
                Components marked with ⚠️ have notes in the component panel - 
                click to see migration details
              </li>
            )}
            <li>
              <TestIcon size={14} />
              Test your app thoroughly before deploying
            </li>
            <li>
              <TrashIcon size={14} />
              Once confirmed working, you can archive or delete the original folder
            </li>
          </ol>
        </div>
      </div>
      
      <WizardActions>
        <Button variant="secondary" onClick={onViewLog}>
          View Migration Log
        </Button>
        <Button variant="primary" onClick={onOpenProject}>
          Open Migrated Project
        </Button>
      </WizardActions>
    </WizardStep>
  );
}

Wizard Container

// packages/noodl-editor/src/editor/src/views/migration/MigrationWizard.tsx

interface MigrationWizardProps {
  sourcePath: string;
  onComplete: (targetPath: string) => void;
  onCancel: () => void;
}

function MigrationWizard({ sourcePath, onComplete, onCancel }: MigrationWizardProps) {
  const [session, dispatch] = useReducer(migrationReducer, {
    id: generateId(),
    step: 'confirm',
    source: {
      path: sourcePath,
      name: path.basename(sourcePath),
      runtimeVersion: 'react17'
    },
    target: {
      path: `${sourcePath}-r19`,
      copied: false
    }
  });
  
  const renderStep = () => {
    switch (session.step) {
      case 'confirm':
        return (
          <ConfirmStep
            session={session}
            onUpdateTarget={(path) => dispatch({ type: 'SET_TARGET_PATH', path })}
            onNext={() => dispatch({ type: 'START_SCAN' })}
            onCancel={onCancel}
          />
        );
      
      case 'scanning':
        return (
          <ScanningStep
            session={session}
            onComplete={(scan) => dispatch({ type: 'SCAN_COMPLETE', scan })}
            onError={(error) => dispatch({ type: 'ERROR', error })}
          />
        );
      
      case 'report':
        return (
          <ReportStep
            session={session}
            onConfigureAi={() => dispatch({ type: 'CONFIGURE_AI' })}
            onMigrateWithoutAi={() => dispatch({ type: 'START_MIGRATE', useAi: false })}
            onMigrateWithAi={() => dispatch({ type: 'START_MIGRATE', useAi: true })}
            onCancel={onCancel}
          />
        );
      
      case 'configureAi':
        return (
          <AiConfigStep
            session={session}
            onSave={(config) => dispatch({ type: 'SAVE_AI_CONFIG', config })}
            onBack={() => dispatch({ type: 'BACK_TO_REPORT' })}
          />
        );
      
      case 'migrating':
        return (
          <MigratingStep
            session={session}
            useAi={session.ai?.enabled ?? false}
            onPause={() => dispatch({ type: 'PAUSE' })}
            onAiDecision={(d) => dispatch({ type: 'AI_DECISION', decision: d })}
            onComplete={(result) => dispatch({ type: 'COMPLETE', result })}
            onError={(error) => dispatch({ type: 'ERROR', error })}
          />
        );
      
      case 'complete':
        return (
          <CompleteStep
            session={session}
            onViewLog={() => openMigrationLog(session)}
            onOpenProject={() => onComplete(session.target.path)}
          />
        );
      
      case 'failed':
        return (
          <FailedStep
            session={session}
            onRetry={() => dispatch({ type: 'RETRY' })}
            onCancel={onCancel}
          />
        );
    }
  };
  
  return (
    <Dialog 
      className={css['migration-wizard']}
      size="large"
      onClose={onCancel}
    >
      <WizardProgress 
        steps={['Confirm', 'Scan', 'Report', 'Migrate', 'Complete']}
        currentStep={stepToIndex(session.step)}
      />
      
      {renderStep()}
    </Dialog>
  );
}

Testing Checklist

  • Wizard opens from project detection
  • Target path can be customized
  • Duplicate path detection works
  • Scanning shows progress
  • Report categorizes components correctly
  • AI config button appears when needed
  • Migration progress updates in real-time
  • AI decision panel appears on failure
  • Complete screen shows correct stats
  • "Open Project" launches migrated project
  • Cancel works at every step
  • Errors are handled gracefully