Skip to content

Refactor Stored Knowledge

David Allen edited this page Jan 29, 2026 · 1 revision

This page exists to store several mega-refactoring plans used to refactor BlockEditor, to show structural issues & guards on how you can do big, complex, multi-phase refactors and keep them safe and sane.


name: BlockEditor Plan A - Low-Risk Foundation overview: Extract cleanly separable pieces from BlockEditor.tsx, establish unit testing, and create safe rollback points. This plan focuses on pure utilities, simple hooks, and the simplest component extraction. Complete this plan before starting Plan B. todos:

  • id: a1-1-investigate-globals content: "A1.1: Search for window global consumers, document findings" status: completed
  • id: a1-2-document-props content: "A1.2: Document current prop flow to child components" status: completed
  • id: a1-3-define-blockops content: "A1.3: Add BlockOperations interface to types.ts" status: completed
  • id: a2-1-utility content: "A2.1: Create recorded-steps-processor.ts utility, run verification" status: completed
  • id: a3-1-modal-manager content: "A3.1: Create useModalManager.ts hook, run verification" status: completed
  • id: a3-2-block-selection content: "A3.2: Create useBlockSelection.ts hook, run verification" status: completed
  • id: a4-1-footer content: "A4.1: Create BlockEditorFooter.tsx component, run verification" status: completed
  • id: a5-1-unit-tests-utility content: "A5.1: Add unit tests for recorded-steps-processor.ts" status: completed
  • id: a5-2-unit-tests-modal content: "A5.2: Add unit tests for useModalManager.ts" status: completed
  • id: a5-3-unit-tests-selection content: "A5.3: Add unit tests for useBlockSelection.ts" status: completed isProject: false

BlockEditor Plan A: Low-Risk Foundation

Goal

Extract cleanly separable pieces from BlockEditor.tsx with minimal risk:

  • 1 pure utility module
  • 2 simple hooks (no complex dependencies)
  • 1 simple component
  • Unit tests for all extracted code

This plan establishes a foundation and testing infrastructure before tackling the higher-risk extractions in Plan B.

Prerequisites

  • Branch: blockeditor-refactor (or create from current state)
  • All existing tests passing: npm run typecheck && npm run test:ci && npm run e2e

Critical Constraints

E2E DOM Requirements (Must Preserve)

Test ID Purpose
data-testid="block-editor" Main container
data-testid="block-palette" Footer (extracted in A4.1)
data-testid="view-mode-toggle" Mode toggle
data-testid="copy-json-button" Copy button
data-testid="guide-metadata-button" Settings button
data-testid="block-editor-content" Content area
[data-record-overlay="banner"] Recording overlay

Verification Strategy (Each Phase)

  1. Run npm run typecheck - fix any errors
  2. Run npm run lint:fix - auto-fix lint issues
  3. Run npm run prettier --write src/components/block-editor/ - format code
  4. Run npm run test:ci - run unit tests
  5. Run npm run e2e - verify e2e tests pass
  6. Git commit with descriptive message

Phase A1: Prerequisite Investigation

A1.1: Search for Window Global Consumers

Before eliminating __blockEditorSectionContext and __blockEditorConditionalContext, find all consumers.

Commands to run:

rg "__blockEditorSectionContext" src/
rg "__blockEditorConditionalContext" src/

Findings (documented 2026-01-29):

Both globals are ONLY used within BlockEditor.tsx:

**__blockEditorSectionContext**:

  • WRITE: Line 463-464 (in handleInsertBlockInSection)
  • READ: Lines 874-875 (in handleBlockFormSubmitWithSection)
  • DELETE: Lines 202-203 (in handleBlockFormCancel) and Lines 917-918 (in handleBlockFormSubmitWithSection)

**__blockEditorConditionalContext**:

  • WRITE: Lines 516-518 (in handleInsertBlockInConditional)
  • READ: Lines 879-881 (in handleBlockFormSubmitWithSection)
  • DELETE: Lines 207-209 (in handleBlockFormCancel) and Lines 908-914 (in handleBlockFormSubmitWithSection)

Conclusion: These globals are a workaround for passing context to the form modal when inserting blocks into sections/conditionals. For Plan B, React Context would be a cleaner solution.

No code changes in this phase.


A1.2: Document Current Prop Flow

Analyze props passed from BlockEditor.tsx to child components. This informs the BlockOperations interface.

Components to analyze:

  • BlockList - what props does it receive?
  • BlockFormModal - what props does it receive?
  • BlockPalette - what props does it receive?
  • RecordModeOverlay - what props does it receive?

Findings (documented 2026-01-29):

BlockList receives extensive props:

  • Block CRUD: onBlockEdit, onBlockDelete, onBlockMove, onBlockDuplicate
  • Insertion: onInsertBlock, onNestBlock, onUnnestBlock
  • Nested blocks: onInsertBlockInSection, onNestedBlockEdit, onNestedBlockDelete, onNestedBlockDuplicate, onNestedBlockMove
  • Section recording: onSectionRecord, recordingIntoSection
  • Conditional recording: onConditionalBranchRecord, recordingIntoConditionalBranch
  • Selection state: isSelectionMode, selectedBlockIds, onToggleBlockSelection
  • Conditional operations: onInsertBlockInConditional, onConditionalBranchBlockEdit, onConditionalBranchBlockDelete, onConditionalBranchBlockDuplicate, onConditionalBranchBlockMove
  • Cross-container: onNestBlockInConditional, onUnnestBlockFromConditional, onMoveBlockBetweenConditionalBranches, onMoveBlockBetweenSections

BlockFormModal receives:

  • blockType, initialData, onSubmit, onCancel, onSubmitAndRecord, isEditing
  • Conversion: onSplitToBlocks, onConvertType, onSwitchBlockType

BlockPalette receives:

  • onSelect, embedded

RecordModeOverlay receives:

  • Recording state: isRecording, stepCount, onStop, sectionName, startingUrl
  • Multi-step: pendingMultiStepCount, isGroupingMultiStep, isMultiStepGroupingEnabled, onToggleMultiStepGrouping

Groups identified for BlockOperations interface:

  1. Block CRUD operations
  2. Nested block operations (sections)
  3. Conditional branch operations
  4. Selection operations
  5. Recording operations

No code changes in this phase.


A1.3: Add BlockOperations Interface to types.ts

File: src/components/block-editor/types.ts

Add this interface based on A1.2 findings (adjust as needed):

/**
 * Grouped operations interface to reduce prop drilling.
 * Used by extracted components to receive callbacks from BlockEditor.
 */
export interface BlockOperations {
  // Block CRUD
  onAddBlock: (type: BlockType, index?: number) => void;
  onEditBlock: (block: EditorBlock) => void;
  onDeleteBlock: (id: string) => void;
  onMoveBlock: (fromIndex: number, toIndex: number) => void;
  
  // Nested block operations (sections)
  onAddNestedBlock: (sectionId: string, type: BlockType, index?: number) => void;
  onEditNestedBlock: (sectionId: string, index: number, block: JsonBlock) => void;
  onDeleteNestedBlock: (sectionId: string, index: number) => void;
  
  // Conditional branch operations
  onAddConditionalBlock: (conditionalId: string, branch: 'whenTrue' | 'whenFalse', type: BlockType, index?: number) => void;
  onEditConditionalBlock: (conditionalId: string, branch: 'whenTrue' | 'whenFalse', index: number, block: JsonBlock) => void;
  onDeleteConditionalBlock: (conditionalId: string, branch: 'whenTrue' | 'whenFalse', index: number) => void;
  
  // Selection operations
  isSelectionMode: boolean;
  selectedBlockIds: Set<string>;
  onToggleSelectionMode: () => void;
  onToggleBlockSelection: (blockId: string) => void;
  onClearSelection: () => void;
  onMergeToMultistep: () => void;
  onMergeToGuided: () => void;
  
  // Recording operations
  isRecording: boolean;
  recordingTargetId: string | null;
  recordingTargetType: 'section' | 'conditional' | null;
  onStartSectionRecording: (sectionId: string) => void;
  onStartConditionalRecording: (conditionalId: string, branch: 'whenTrue' | 'whenFalse') => void;
  onStopRecording: () => void;
}

Checkpoint: git commit -m "chore(types): add BlockOperations interface for refactoring"

Note: This interface is not used yet. It guides the design for Plan B.


Phase A2: Pure Utility Extraction

A2.1: Create recorded-steps-processor.ts

File: src/components/block-editor/utils/recorded-steps-processor.ts

Extract the step-grouping logic from lines 606-675 and 707-765 of BlockEditor.tsx.

import type { JsonInteractiveBlock, JsonMultistepBlock, JsonStep } from '../../../types/json-guide.types';
import type { RecordedStep } from '../../../utils/devtools';

/**
 * Processed step - either a single step or a group of steps with same groupId
 */
export interface ProcessedStep {
  type: 'single' | 'group';
  steps: RecordedStep[];
}

/**
 * Groups recorded steps by their groupId.
 * Consecutive steps with the same groupId are grouped together.
 * Steps without groupId remain as singles.
 */
export function groupRecordedStepsByGroupId(steps: RecordedStep[]): ProcessedStep[] {
  const result: ProcessedStep[] = [];
  let currentGroup: RecordedStep[] = [];
  let currentGroupId: string | undefined;

  steps.forEach((step) => {
    if (step.groupId) {
      if (step.groupId === currentGroupId) {
        // Continue current group
        currentGroup.push(step);
      } else {
        // End previous group if exists
        if (currentGroup.length > 0) {
          result.push({ type: 'group', steps: currentGroup });
        }
        // Start new group
        currentGroupId = step.groupId;
        currentGroup = [step];
      }
    } else {
      // End current group if exists
      if (currentGroup.length > 0) {
        result.push({ type: 'group', steps: currentGroup });
        currentGroup = [];
        currentGroupId = undefined;
      }
      // Add single step
      result.push({ type: 'single', steps: [step] });
    }
  });

  // Don't forget the last group
  if (currentGroup.length > 0) {
    result.push({ type: 'group', steps: currentGroup });
  }

  return result;
}

/**
 * Converts a single recorded step to an interactive block.
 */
export function convertStepToInteractiveBlock(step: RecordedStep): JsonInteractiveBlock {
  return {
    type: 'interactive',
    action: step.action as JsonInteractiveBlock['action'],
    reftarget: step.selector,
    content: step.description || `${step.action} on element`,
    ...(step.value && { targetvalue: step.value }),
  };
}

/**
 * Converts a group of recorded steps to a multistep block.
 */
export function convertStepsToMultistepBlock(steps: RecordedStep[]): JsonMultistepBlock {
  const multistepSteps: JsonStep[] = steps.map((step) => ({
    action: step.action as JsonStep['action'],
    reftarget: step.selector,
    ...(step.value && { targetvalue: step.value }),
    tooltip: step.description || `${step.action} on element`,
  }));

  return {
    type: 'multistep',
    content: steps[0].description || 'Complete the following steps',
    steps: multistepSteps,
  };
}

/**
 * Converts processed steps to blocks (interactive or multistep).
 */
export function convertProcessedStepsToBlocks(
  processedSteps: ProcessedStep[]
): Array<JsonInteractiveBlock | JsonMultistepBlock> {
  return processedSteps.map((item) => {
    if (item.type === 'single') {
      return convertStepToInteractiveBlock(item.steps[0]);
    } else {
      return convertStepsToMultistepBlock(item.steps);
    }
  });
}

Update exports in src/components/block-editor/utils/index.ts:

export * from './recorded-steps-processor';

Update BlockEditor.tsx to use the utility:

Replace the inline step-processing logic in handleSectionRecord and handleConditionalBranchRecord with calls to the utility functions.

Checkpoint: git commit -m "refactor: extract recorded-steps-processor utility"

Verification: Full test suite


Phase A3: Simple Hook Extractions

A3.1: Create useModalManager.ts

File: src/components/block-editor/hooks/useModalManager.ts

import { useState, useCallback } from 'react';

/**
 * Modal names managed by this hook.
 * Note: isBlockFormOpen is NOT included - it coordinates with persistence
 * and must stay in BlockEditor.
 */
export type ModalName = 'metadata' | 'newGuideConfirm' | 'import' | 'githubPr' | 'tour';

/**
 * Manages boolean state for multiple modals.
 * Provides a cleaner API than multiple useState calls.
 */
export function useModalManager() {
  const [openModals, setOpenModals] = useState<Set<ModalName>>(new Set());

  const isOpen = useCallback((name: ModalName): boolean => {
    return openModals.has(name);
  }, [openModals]);

  const open = useCallback((name: ModalName): void => {
    setOpenModals((prev) => {
      const next = new Set(prev);
      next.add(name);
      return next;
    });
  }, []);

  const close = useCallback((name: ModalName): void => {
    setOpenModals((prev) => {
      const next = new Set(prev);
      next.delete(name);
      return next;
    });
  }, []);

  const toggle = useCallback((name: ModalName): void => {
    setOpenModals((prev) => {
      const next = new Set(prev);
      if (next.has(name)) {
        next.delete(name);
      } else {
        next.add(name);
      }
      return next;
    });
  }, []);

  return { isOpen, open, close, toggle };
}

Update exports in src/components/block-editor/hooks/index.ts:

export { useModalManager } from './useModalManager';
export type { ModalName } from './useModalManager';

Update BlockEditor.tsx:

Replace:

const [isMetadataOpen, setIsMetadataOpen] = useState(false);
const [isNewGuideConfirmOpen, setIsNewGuideConfirmOpen] = useState(false);
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
const [isGitHubPRModalOpen, setIsGitHubPRModalOpen] = useState(false);
const [isTourOpen, setIsTourOpen] = useState(false);

With:

const modals = useModalManager();
// Usage: modals.isOpen('metadata'), modals.open('metadata'), modals.close('metadata')

Update all references throughout the component.

Checkpoint: git commit -m "refactor: extract useModalManager hook"

Verification: Full test suite


A3.2: Create useBlockSelection.ts

File: src/components/block-editor/hooks/useBlockSelection.ts

import { useState, useCallback } from 'react';

/**
 * Manages block selection state for multi-select operations.
 * 
 * Note: Merge operations (handleMergeToMultistep, handleMergeToGuided) stay
 * in BlockEditor because they need access to editor.state.blocks.
 */
export function useBlockSelection() {
  const [isSelectionMode, setIsSelectionMode] = useState(false);
  const [selectedBlockIds, setSelectedBlockIds] = useState<Set<string>>(new Set());

  const toggleSelectionMode = useCallback(() => {
    setIsSelectionMode((prev) => {
      if (prev) {
        // Exiting selection mode - clear selection
        setSelectedBlockIds(new Set());
      }
      return !prev;
    });
  }, []);

  const toggleBlockSelection = useCallback((blockId: string) => {
    setSelectedBlockIds((prev) => {
      const next = new Set(prev);
      if (next.has(blockId)) {
        next.delete(blockId);
      } else {
        next.add(blockId);
      }
      return next;
    });
  }, []);

  const clearSelection = useCallback(() => {
    setSelectedBlockIds(new Set());
    setIsSelectionMode(false);
  }, []);

  const selectBlock = useCallback((blockId: string) => {
    setSelectedBlockIds((prev) => {
      const next = new Set(prev);
      next.add(blockId);
      return next;
    });
  }, []);

  const deselectBlock = useCallback((blockId: string) => {
    setSelectedBlockIds((prev) => {
      const next = new Set(prev);
      next.delete(blockId);
      return next;
    });
  }, []);

  return {
    isSelectionMode,
    selectedBlockIds,
    toggleSelectionMode,
    toggleBlockSelection,
    clearSelection,
    selectBlock,
    deselectBlock,
  };
}

Update exports in src/components/block-editor/hooks/index.ts:

export { useBlockSelection } from './useBlockSelection';

Update BlockEditor.tsx:

Replace:

const [isSelectionMode, setIsSelectionMode] = useState(false);
const [selectedBlockIds, setSelectedBlockIds] = useState<Set<string>>(new Set());

// ... and the handler implementations
const handleToggleSelectionMode = useCallback(() => { ... }, []);
const handleToggleBlockSelection = useCallback((blockId: string) => { ... }, []);
const handleClearSelection = useCallback(() => { ... }, []);

With:

const selection = useBlockSelection();
// Usage: selection.isSelectionMode, selection.toggleSelectionMode(), etc.

Important: Keep handleMergeToMultistep and handleMergeToGuided in BlockEditor - they use selection.selectedBlockIds but also need editor.state.blocks.

Checkpoint: git commit -m "refactor: extract useBlockSelection hook"

Verification: Full test suite


Phase A4: Simple Component Extraction

A4.1: Create BlockEditorFooter.tsx

File: src/components/block-editor/BlockEditorFooter.tsx

import React from 'react';
import { useStyles2 } from '@grafana/ui';
import { getBlockEditorStyles } from './block-editor.styles';
import { BlockPalette } from './BlockPalette';
import type { BlockType } from './types';

interface BlockEditorFooterProps {
  /** Whether the editor is in preview mode (hides footer) */
  isPreviewMode: boolean;
  /** Called when a block type is selected from the palette */
  onBlockTypeSelect: (type: BlockType, insertAtIndex?: number) => void;
}

/**
 * Footer component containing the block palette.
 * Hidden in preview mode.
 */
export function BlockEditorFooter({ isPreviewMode, onBlockTypeSelect }: BlockEditorFooterProps) {
  const styles = useStyles2(getBlockEditorStyles);

  if (isPreviewMode) {
    return null;
  }

  return (
    <div data-testid="block-palette" className={styles.footer}>
      <BlockPalette onSelect={onBlockTypeSelect} />
    </div>
  );
}

Update BlockEditor.tsx:

Import and use the new component:

import { BlockEditorFooter } from './BlockEditorFooter';

// In render:
<BlockEditorFooter
  isPreviewMode={editor.state.isPreviewMode}
  onBlockTypeSelect={handleBlockTypeSelect}
/>

Remove the inline footer JSX.

Checkpoint: git commit -m "refactor: extract BlockEditorFooter component"

Verification: Full test suite


Phase A5: Add Unit Tests

A5.1: Unit Tests for recorded-steps-processor.ts

File: src/components/block-editor/utils/recorded-steps-processor.test.ts

import {
  groupRecordedStepsByGroupId,
  convertStepToInteractiveBlock,
  convertStepsToMultistepBlock,
  convertProcessedStepsToBlocks,
} from './recorded-steps-processor';
import type { RecordedStep } from '../../../utils/devtools';

const makeStep = (overrides: Partial<RecordedStep> = {}): RecordedStep => ({
  action: 'click',
  selector: '[data-testid="button"]',
  timestamp: Date.now(),
  description: 'Click the button',
  ...overrides,
});

describe('groupRecordedStepsByGroupId', () => {
  it('returns empty array for empty input', () => {
    expect(groupRecordedStepsByGroupId([])).toEqual([]);
  });

  it('keeps ungrouped steps as singles', () => {
    const steps = [makeStep(), makeStep()];
    const result = groupRecordedStepsByGroupId(steps);
    
    expect(result).toHaveLength(2);
    expect(result[0].type).toBe('single');
    expect(result[1].type).toBe('single');
  });

  it('groups consecutive steps with same groupId', () => {
    const steps = [
      makeStep({ groupId: 'group-1' }),
      makeStep({ groupId: 'group-1' }),
      makeStep({ groupId: 'group-1' }),
    ];
    const result = groupRecordedStepsByGroupId(steps);
    
    expect(result).toHaveLength(1);
    expect(result[0].type).toBe('group');
    expect(result[0].steps).toHaveLength(3);
  });

  it('separates different groupIds into different groups', () => {
    const steps = [
      makeStep({ groupId: 'group-1' }),
      makeStep({ groupId: 'group-1' }),
      makeStep({ groupId: 'group-2' }),
      makeStep({ groupId: 'group-2' }),
    ];
    const result = groupRecordedStepsByGroupId(steps);
    
    expect(result).toHaveLength(2);
    expect(result[0].type).toBe('group');
    expect(result[0].steps).toHaveLength(2);
    expect(result[1].type).toBe('group');
    expect(result[1].steps).toHaveLength(2);
  });

  it('handles alternating grouped and ungrouped steps', () => {
    const steps = [
      makeStep(), // single
      makeStep({ groupId: 'group-1' }),
      makeStep({ groupId: 'group-1' }),
      makeStep(), // single
      makeStep({ groupId: 'group-2' }),
    ];
    const result = groupRecordedStepsByGroupId(steps);
    
    expect(result).toHaveLength(4);
    expect(result[0].type).toBe('single');
    expect(result[1].type).toBe('group');
    expect(result[1].steps).toHaveLength(2);
    expect(result[2].type).toBe('single');
    expect(result[3].type).toBe('group');
    expect(result[3].steps).toHaveLength(1);
  });
});

describe('convertStepToInteractiveBlock', () => {
  it('converts step to interactive block', () => {
    const step = makeStep({
      action: 'click',
      selector: '[data-testid="submit"]',
      description: 'Click submit',
    });
    const block = convertStepToInteractiveBlock(step);
    
    expect(block.type).toBe('interactive');
    expect(block.action).toBe('click');
    expect(block.reftarget).toBe('[data-testid="submit"]');
    expect(block.content).toBe('Click submit');
  });

  it('includes targetvalue when step has value', () => {
    const step = makeStep({
      action: 'fill',
      value: 'test input',
    });
    const block = convertStepToInteractiveBlock(step);
    
    expect(block.targetvalue).toBe('test input');
  });

  it('uses fallback description when none provided', () => {
    const step = makeStep({ description: undefined });
    const block = convertStepToInteractiveBlock(step);
    
    expect(block.content).toBe('click on element');
  });
});

describe('convertStepsToMultistepBlock', () => {
  it('converts steps to multistep block', () => {
    const steps = [
      makeStep({ description: 'Step 1' }),
      makeStep({ description: 'Step 2' }),
    ];
    const block = convertStepsToMultistepBlock(steps);
    
    expect(block.type).toBe('multistep');
    expect(block.steps).toHaveLength(2);
    expect(block.content).toBe('Step 1');
  });

  it('maps step properties correctly', () => {
    const steps = [
      makeStep({
        action: 'fill',
        selector: '[data-testid="input"]',
        value: 'hello',
        description: 'Fill input',
      }),
    ];
    const block = convertStepsToMultistepBlock(steps);
    
    expect(block.steps[0].action).toBe('fill');
    expect(block.steps[0].reftarget).toBe('[data-testid="input"]');
    expect(block.steps[0].targetvalue).toBe('hello');
    expect(block.steps[0].tooltip).toBe('Fill input');
  });
});

describe('convertProcessedStepsToBlocks', () => {
  it('converts singles to interactive blocks', () => {
    const processed = [{ type: 'single' as const, steps: [makeStep()] }];
    const blocks = convertProcessedStepsToBlocks(processed);
    
    expect(blocks).toHaveLength(1);
    expect(blocks[0].type).toBe('interactive');
  });

  it('converts groups to multistep blocks', () => {
    const processed = [{ type: 'group' as const, steps: [makeStep(), makeStep()] }];
    const blocks = convertProcessedStepsToBlocks(processed);
    
    expect(blocks).toHaveLength(1);
    expect(blocks[0].type).toBe('multistep');
  });

  it('handles mixed singles and groups', () => {
    const processed = [
      { type: 'single' as const, steps: [makeStep()] },
      { type: 'group' as const, steps: [makeStep(), makeStep()] },
      { type: 'single' as const, steps: [makeStep()] },
    ];
    const blocks = convertProcessedStepsToBlocks(processed);
    
    expect(blocks).toHaveLength(3);
    expect(blocks[0].type).toBe('interactive');
    expect(blocks[1].type).toBe('multistep');
    expect(blocks[2].type).toBe('interactive');
  });
});

Checkpoint: git commit -m "test: add unit tests for recorded-steps-processor"


A5.2: Unit Tests for useModalManager.ts

File: src/components/block-editor/hooks/useModalManager.test.ts

import { renderHook, act } from '@testing-library/react';
import { useModalManager } from './useModalManager';

describe('useModalManager', () => {
  it('starts with all modals closed', () => {
    const { result } = renderHook(() => useModalManager());
    
    expect(result.current.isOpen('metadata')).toBe(false);
    expect(result.current.isOpen('newGuideConfirm')).toBe(false);
    expect(result.current.isOpen('import')).toBe(false);
    expect(result.current.isOpen('githubPr')).toBe(false);
    expect(result.current.isOpen('tour')).toBe(false);
  });

  it('opens a modal', () => {
    const { result } = renderHook(() => useModalManager());
    
    act(() => {
      result.current.open('metadata');
    });
    
    expect(result.current.isOpen('metadata')).toBe(true);
  });

  it('closes a modal', () => {
    const { result } = renderHook(() => useModalManager());
    
    act(() => {
      result.current.open('metadata');
    });
    expect(result.current.isOpen('metadata')).toBe(true);
    
    act(() => {
      result.current.close('metadata');
    });
    expect(result.current.isOpen('metadata')).toBe(false);
  });

  it('toggles a modal', () => {
    const { result } = renderHook(() => useModalManager());
    
    act(() => {
      result.current.toggle('import');
    });
    expect(result.current.isOpen('import')).toBe(true);
    
    act(() => {
      result.current.toggle('import');
    });
    expect(result.current.isOpen('import')).toBe(false);
  });

  it('allows multiple modals open simultaneously', () => {
    const { result } = renderHook(() => useModalManager());
    
    act(() => {
      result.current.open('metadata');
      result.current.open('tour');
    });
    
    expect(result.current.isOpen('metadata')).toBe(true);
    expect(result.current.isOpen('tour')).toBe(true);
    expect(result.current.isOpen('import')).toBe(false);
  });

  it('closing one modal does not affect others', () => {
    const { result } = renderHook(() => useModalManager());
    
    act(() => {
      result.current.open('metadata');
      result.current.open('tour');
    });
    
    act(() => {
      result.current.close('metadata');
    });
    
    expect(result.current.isOpen('metadata')).toBe(false);
    expect(result.current.isOpen('tour')).toBe(true);
  });
});

Checkpoint: git commit -m "test: add unit tests for useModalManager"


A5.3: Unit Tests for useBlockSelection.ts

File: src/components/block-editor/hooks/useBlockSelection.test.ts

import { renderHook, act } from '@testing-library/react';
import { useBlockSelection } from './useBlockSelection';

describe('useBlockSelection', () => {
  it('starts with selection mode off and empty selection', () => {
    const { result } = renderHook(() => useBlockSelection());
    
    expect(result.current.isSelectionMode).toBe(false);
    expect(result.current.selectedBlockIds.size).toBe(0);
  });

  it('toggles selection mode on', () => {
    const { result } = renderHook(() => useBlockSelection());
    
    act(() => {
      result.current.toggleSelectionMode();
    });
    
    expect(result.current.isSelectionMode).toBe(true);
  });

  it('clears selection when exiting selection mode', () => {
    const { result } = renderHook(() => useBlockSelection());
    
    // Enter selection mode and select some blocks
    act(() => {
      result.current.toggleSelectionMode();
      result.current.toggleBlockSelection('block-1');
      result.current.toggleBlockSelection('block-2');
    });
    expect(result.current.selectedBlockIds.size).toBe(2);
    
    // Exit selection mode
    act(() => {
      result.current.toggleSelectionMode();
    });
    
    expect(result.current.isSelectionMode).toBe(false);
    expect(result.current.selectedBlockIds.size).toBe(0);
  });

  it('toggles individual block selection', () => {
    const { result } = renderHook(() => useBlockSelection());
    
    act(() => {
      result.current.toggleBlockSelection('block-1');
    });
    expect(result.current.selectedBlockIds.has('block-1')).toBe(true);
    
    act(() => {
      result.current.toggleBlockSelection('block-1');
    });
    expect(result.current.selectedBlockIds.has('block-1')).toBe(false);
  });

  it('selects multiple blocks', () => {
    const { result } = renderHook(() => useBlockSelection());
    
    act(() => {
      result.current.toggleBlockSelection('block-1');
      result.current.toggleBlockSelection('block-2');
      result.current.toggleBlockSelection('block-3');
    });
    
    expect(result.current.selectedBlockIds.size).toBe(3);
    expect(result.current.selectedBlockIds.has('block-1')).toBe(true);
    expect(result.current.selectedBlockIds.has('block-2')).toBe(true);
    expect(result.current.selectedBlockIds.has('block-3')).toBe(true);
  });

  it('clears selection explicitly', () => {
    const { result } = renderHook(() => useBlockSelection());
    
    act(() => {
      result.current.toggleSelectionMode();
      result.current.toggleBlockSelection('block-1');
      result.current.toggleBlockSelection('block-2');
    });
    
    act(() => {
      result.current.clearSelection();
    });
    
    expect(result.current.selectedBlockIds.size).toBe(0);
    expect(result.current.isSelectionMode).toBe(false);
  });

  it('selectBlock adds a block to selection', () => {
    const { result } = renderHook(() => useBlockSelection());
    
    act(() => {
      result.current.selectBlock('block-1');
    });
    
    expect(result.current.selectedBlockIds.has('block-1')).toBe(true);
  });

  it('deselectBlock removes a block from selection', () => {
    const { result } = renderHook(() => useBlockSelection());
    
    act(() => {
      result.current.selectBlock('block-1');
      result.current.selectBlock('block-2');
    });
    
    act(() => {
      result.current.deselectBlock('block-1');
    });
    
    expect(result.current.selectedBlockIds.has('block-1')).toBe(false);
    expect(result.current.selectedBlockIds.has('block-2')).toBe(true);
  });
});

Checkpoint: git commit -m "test: add unit tests for useBlockSelection"


Completion Checklist

Before moving to Plan B, verify:

  • All A1-A5 phases complete
  • All unit tests passing: npm run test:ci
  • All e2e tests passing: npm run e2e
  • No TypeScript errors: npm run typecheck
  • No lint errors: npm run lint
  • All checkpoints committed to git
  • Plan B prerequisites from A1 investigation are documented

File Structure After Plan A

src/components/block-editor/
├── BlockEditor.tsx                    (~1100 lines, reduced from ~1206)
├── BlockEditorFooter.tsx              (NEW - ~25 lines)
├── hooks/
│   ├── index.ts                       (updated exports)
│   ├── useBlockEditor.ts              (unchanged)
│   ├── useBlockPersistence.ts         (unchanged)
│   ├── useRecordingPersistence.ts     (unchanged)
│   ├── useModalManager.ts             (NEW - ~50 lines)
│   ├── useModalManager.test.ts        (NEW - ~80 lines)
│   ├── useBlockSelection.ts           (NEW - ~60 lines)
│   └── useBlockSelection.test.ts      (NEW - ~100 lines)
├── types.ts                           (updated with BlockOperations)
└── utils/
    ├── index.ts                       (updated exports)
    ├── recorded-steps-processor.ts    (NEW - ~80 lines)
    └── recorded-steps-processor.test.ts (NEW - ~150 lines)

Estimated reduction: ~100 lines from BlockEditor.tsx (modest, but safe)



name: BlockEditor Plan B - High-Risk Extractions overview: Extract complex hooks and components from BlockEditor.tsx that have significant state interdependencies. This plan should only be executed after Plan A is complete and all tests pass. Each phase includes specific risk mitigations. todos:

  • id: b0-verify-plana content: "B0: Verify Plan A complete - all tests passing, checkpoints committed" status: pending
  • id: b1-react-context content: "B1: Replace window globals with React Context" status: pending
  • id: b2-form-state content: "B2: Extract useBlockFormState hook, run verification" status: pending
  • id: b2-unit-tests content: "B2.1: Add unit tests for useBlockFormState" status: pending
  • id: b3-recording-state content: "B3.1: Extract useRecordingState hook (state only), run verification" status: pending
  • id: b3-recording-session content: "B3.2: Extract useRecordingSession hook (actions), run verification" status: pending
  • id: b3-unit-tests content: "B3.3: Add unit tests for recording hooks" status: pending
  • id: b4-1-header content: "B4.1: Extract BlockEditorHeader component, run verification" status: pending
  • id: b4-2-content content: "B4.2: Extract BlockEditorContent component, run verification" status: pending
  • id: b4-3-modals content: "B4.3: Extract BlockEditorModals component, run verification" status: pending
  • id: b5-final content: "B5: Final assembly and cleanup of BlockEditor.tsx" status: pending isProject: false

BlockEditor Plan B: High-Risk Extractions

Prerequisites

This plan requires Plan A to be complete.

Before starting, verify:

  • All Plan A phases (A1-A5) complete
  • All unit tests passing: npm run test:ci
  • All e2e tests passing: npm run e2e
  • BlockOperations interface defined in types.ts (line 152)

Plan A Completed Artifacts

The following were extracted in Plan A and are available for use:

Artifact Location Purpose
useModalManager hooks/useModalManager.ts Manages modal open/close state
useBlockSelection hooks/useBlockSelection.ts Manages block selection for merging
recorded-steps-processor utils/recorded-steps-processor.ts Groups and converts recorded steps
BlockEditorFooter BlockEditorFooter.tsx Footer with block palette
BlockOperations types.ts Interface for grouped operations

Key Findings from Plan A Investigation

Window Globals (from A1.1)

Both globals are **ONLY used within BlockEditor.tsx** - there are no external child component consumers.

**__blockEditorSectionContext**:

  • WRITE: Line 463-464 (in handleInsertBlockInSection)
  • READ: Lines 874-875 (in handleBlockFormSubmitWithSection)
  • DELETE: Lines 202-203 (in handleBlockFormCancel) and Lines 917-918 (in handleBlockFormSubmitWithSection)

**__blockEditorConditionalContext**:

  • WRITE: Lines 516-518 (in handleInsertBlockInConditional)
  • READ: Lines 879-881 (in handleBlockFormSubmitWithSection)
  • DELETE: Lines 207-209 (in handleBlockFormCancel) and Lines 908-914 (in handleBlockFormSubmitWithSection)

Implication: Phase B1 is simpler than expected - no child components need updating to use React Context.

Prop Flow (from A1.2)

BlockList receives extensive props (30+):

  • Block CRUD: onBlockEdit, onBlockDelete, onBlockMove, onBlockDuplicate
  • Insertion: onInsertBlock, onNestBlock, onUnnestBlock
  • Nested blocks: onInsertBlockInSection, onNestedBlockEdit, onNestedBlockDelete, onNestedBlockDuplicate, onNestedBlockMove
  • Section recording: onSectionRecord, recordingIntoSection
  • Conditional recording: onConditionalBranchRecord, recordingIntoConditionalBranch
  • Selection state: isSelectionMode, selectedBlockIds, onToggleBlockSelection
  • Conditional operations: onInsertBlockInConditional, onConditionalBranchBlockEdit, onConditionalBranchBlockDelete, etc.
  • Cross-container: onNestBlockInConditional, onUnnestBlockFromConditional, onMoveBlockBetweenConditionalBranches, onMoveBlockBetweenSections

BlockFormModal receives:

  • blockType, initialData, onSubmit, onCancel, onSubmitAndRecord, isEditing
  • Conversion: onSplitToBlocks, onConvertType, onSwitchBlockType

BlockPalette receives:

  • onSelect, embedded

RecordModeOverlay receives:

  • Recording state: isRecording, stepCount, onStop, sectionName, startingUrl
  • Multi-step: pendingMultiStepCount, isGroupingMultiStep, isMultiStepGroupingEnabled, onToggleMultiStepGrouping

Goal

Extract complex, interdependent code from BlockEditor.tsx:

  • 2-3 complex hooks with state interdependencies
  • 3 UI components
  • React Context to replace window globals
  • Final assembly with BlockOperations type

Target: Reduce BlockEditor.tsx from ~1076 lines (after Plan A) to ~200-300 lines.

Risk Mitigation Strategy

Each phase in Plan B has higher risk than Plan A. Follow these rules:

  1. One extraction per commit - never combine multiple extractions
  2. Full test suite after each extraction - typecheck, unit tests, e2e
  3. If e2e fails, STOP - do not attempt to fix inline
  4. Document failures - create block-editor-refactor-issues.md with analysis
  5. Rollback point - each checkpoint is a safe revert target
  6. Create backup branch before B3 - Phase B3 is highest risk; create blockeditor-refactor-pre-b3 branch after B2 succeeds

Phase B0: Verify Plan A Complete

Run full verification before starting Plan B:

npm run typecheck
npm run lint
npm run test:ci
npm run e2e

All must pass. If any fail, fix issues before proceeding.

Checkpoint: No code changes, just verification


Phase B1: Replace Window Globals with React Context

Why This Comes First

The window globals (__blockEditorSectionContext, __blockEditorConditionalContext) need to be replaced with proper React state before extracting useBlockFormState, which will become a consumer of this context.

Simplified Scope

Based on A1.1 investigation, there are NO external child component consumers of these globals. This means:

  • ✅ Only BlockEditor.tsx itself needs updating
  • ✅ No child components need to be modified to use useBlockEditorContext()
  • ✅ B1.3 (Update Consumer Components) can be skipped

The React Context is still valuable because:

  1. useBlockFormState (B2) will become a consumer
  2. It future-proofs for potential child component needs
  3. It's cleaner than window globals

B1.1: Create BlockEditorContext.tsx

File: src/components/block-editor/BlockEditorContext.tsx

import React, { createContext, useContext, useState, useCallback, useMemo, type ReactNode } from 'react';

/**
 * Context for tracking which section/conditional is being edited.
 * Replaces window.__blockEditorSectionContext and window.__blockEditorConditionalContext.
 */
export interface SectionContextValue {
  sectionId: string;
  index?: number;
}

export interface ConditionalContextValue {
  conditionalId: string;
  branch: 'whenTrue' | 'whenFalse';
  index?: number;
}

interface BlockEditorContextValue {
  /** Current section being edited (for nested block forms) */
  sectionContext: SectionContextValue | null;
  /** Current conditional being edited (for branch block forms) */
  conditionalContext: ConditionalContextValue | null;
  /** Set the section context */
  setSectionContext: (ctx: SectionContextValue | null) => void;
  /** Set the conditional context */
  setConditionalContext: (ctx: ConditionalContextValue | null) => void;
  /** Clear all editing context */
  clearContext: () => void;
}

const BlockEditorContext = createContext<BlockEditorContextValue | null>(null);

interface BlockEditorContextProviderProps {
  children: ReactNode;
}

export function BlockEditorContextProvider({ children }: BlockEditorContextProviderProps) {
  const [sectionContext, setSectionContextState] = useState<SectionContextValue | null>(null);
  const [conditionalContext, setConditionalContextState] = useState<ConditionalContextValue | null>(null);

  const setSectionContext = useCallback((ctx: SectionContextValue | null) => {
    setSectionContextState(ctx);
  }, []);

  const setConditionalContext = useCallback((ctx: ConditionalContextValue | null) => {
    setConditionalContextState(ctx);
  }, []);

  const clearContext = useCallback(() => {
    setSectionContextState(null);
    setConditionalContextState(null);
  }, []);

  const value = useMemo(
    () => ({
      sectionContext,
      conditionalContext,
      setSectionContext,
      setConditionalContext,
      clearContext,
    }),
    [sectionContext, conditionalContext, setSectionContext, setConditionalContext, clearContext]
  );

  return <BlockEditorContext.Provider value={value}>{children}</BlockEditorContext.Provider>;
}

export function useBlockEditorContext(): BlockEditorContextValue {
  const context = useContext(BlockEditorContext);
  if (!context) {
    throw new Error('useBlockEditorContext must be used within BlockEditorContextProvider');
  }
  return context;
}

B1.2: Update BlockEditor.tsx

  1. Wrap the component's render in BlockEditorContextProvider
  2. Replace window global writes with context setters
  3. Remove window global cleanup from handleBlockFormCancel
// Before:
(window as any).__blockEditorSectionContext = { sectionId, index };

// After:
setSectionContext({ sectionId, index });

Specific locations to update (line numbers from current BlockEditor.tsx):

  • Line 463-464: handleInsertBlockInSection - replace window global write
  • Line 516-518: handleInsertBlockInConditional - replace window global write
  • Lines 202-209: handleBlockFormCancel - replace window global cleanup
  • Lines 874-881: handleBlockFormSubmitWithSection - replace window global read
  • Lines 908-918: handleBlockFormSubmitWithSection - replace window global cleanup

B1.3: Update Consumer Components

SKIP THIS STEP - A1.1 investigation confirmed there are no external consumers.

Checkpoint: git commit -m "refactor: replace window globals with BlockEditorContext"

Verification: Full test suite


Phase B2: Extract useBlockFormState Hook

Risk Analysis

This is the first high-risk extraction because:

  • Multiple interdependent state variables (6 total)
  • Three different editing contexts (root, nested, conditional branch)
  • Coordinates with persistence via isBlockFormOpen
  • Complex cancel handler that resets multiple states
  • Depends on useBlockEditorContext from B1

Current State in BlockEditor.tsx

These state variables (lines 60-77) will be extracted:

const [isBlockFormOpen, setIsBlockFormOpen] = useState(false);
const [editingBlockType, setEditingBlockType] = useState<BlockType | null>(null);
const [editingBlock, setEditingBlock] = useState<EditorBlock | null>(null);
const [insertAtIndex, setInsertAtIndex] = useState<number | undefined>(undefined);
const [editingNestedBlock, setEditingNestedBlock] = useState<{...} | null>(null);
const [editingConditionalBranchBlock, setEditingConditionalBranchBlock] = useState<{...} | null>(null);

B2.1: Create useBlockFormState.ts

File: src/components/block-editor/hooks/useBlockFormState.ts

import { useState, useCallback, useContext } from 'react';
import { BlockEditorContext } from '../BlockEditorContext';
import type { BlockType, EditorBlock, JsonBlock } from '../types';

// Helper to safely get context (defensive coding for testing)
function useBlockEditorContextSafe() {
  const context = useContext(BlockEditorContext);
  // Provide no-op fallbacks for testing without provider
  return {
    setSectionContext: context?.setSectionContext ?? (() => {}),
    setConditionalContext: context?.setConditionalContext ?? (() => {}),
    clearContext: context?.clearContext ?? (() => {}),
  };
}

/**
 * State for editing a nested block within a section.
 */
export interface NestedBlockEditingState {
  sectionId: string;
  nestedIndex: number;
  block: JsonBlock;
}

/**
 * State for editing a block within a conditional branch.
 */
export interface ConditionalBranchEditingState {
  conditionalId: string;
  branch: 'whenTrue' | 'whenFalse';
  nestedIndex: number;
  block: JsonBlock;
}

/**
 * Return type for useBlockFormState hook.
 */
export interface BlockFormState {
  // Core form state
  isBlockFormOpen: boolean;
  editingBlockType: BlockType | null;
  editingBlock: EditorBlock | null;
  insertAtIndex: number | undefined;
  
  // Nested editing state
  editingNestedBlock: NestedBlockEditingState | null;
  editingConditionalBranchBlock: ConditionalBranchEditingState | null;
  
  // Actions
  openNewBlockForm: (type: BlockType, index?: number) => void;
  openEditBlockForm: (block: EditorBlock) => void;
  openNestedBlockForm: (type: BlockType, sectionId: string, index?: number) => void;
  openEditNestedBlockForm: (sectionId: string, nestedIndex: number, block: JsonBlock) => void;
  openConditionalBlockForm: (type: BlockType, conditionalId: string, branch: 'whenTrue' | 'whenFalse', index?: number) => void;
  openEditConditionalBlockForm: (conditionalId: string, branch: 'whenTrue' | 'whenFalse', nestedIndex: number, block: JsonBlock) => void;
  closeBlockForm: () => void;
  
  // State setters for form updates
  setEditingNestedBlock: (state: NestedBlockEditingState | null) => void;
  setEditingConditionalBranchBlock: (state: ConditionalBranchEditingState | null) => void;
}

/**
 * Manages block form state including editing context for root, nested, and conditional blocks.
 * 
 * IMPORTANT: isBlockFormOpen is returned so BlockEditor can pass it to useBlockPersistence
 * for auto-save coordination.
 */
export function useBlockFormState(): BlockFormState {
  const { setSectionContext, setConditionalContext, clearContext } = useBlockEditorContextSafe();
  
  // Core form state
  const [isBlockFormOpen, setIsBlockFormOpen] = useState(false);
  const [editingBlockType, setEditingBlockType] = useState<BlockType | null>(null);
  const [editingBlock, setEditingBlock] = useState<EditorBlock | null>(null);
  const [insertAtIndex, setInsertAtIndex] = useState<number | undefined>(undefined);
  
  // Nested editing state
  const [editingNestedBlock, setEditingNestedBlock] = useState<NestedBlockEditingState | null>(null);
  const [editingConditionalBranchBlock, setEditingConditionalBranchBlock] = useState<ConditionalBranchEditingState | null>(null);

  // Open form for new root-level block
  const openNewBlockForm = useCallback((type: BlockType, index?: number) => {
    setEditingBlockType(type);
    setEditingBlock(null);
    setInsertAtIndex(index);
    setEditingNestedBlock(null);
    setEditingConditionalBranchBlock(null);
    clearContext();
    setIsBlockFormOpen(true);
  }, [clearContext]);

  // Open form to edit existing root-level block
  const openEditBlockForm = useCallback((block: EditorBlock) => {
    setEditingBlockType(block.block.type as BlockType);
    setEditingBlock(block);
    setInsertAtIndex(undefined);
    setEditingNestedBlock(null);
    setEditingConditionalBranchBlock(null);
    clearContext();
    setIsBlockFormOpen(true);
  }, [clearContext]);

  // Open form for new nested block in section
  const openNestedBlockForm = useCallback((type: BlockType, sectionId: string, index?: number) => {
    setEditingBlockType(type);
    setEditingBlock(null);
    setInsertAtIndex(index);
    setEditingNestedBlock(null);
    setEditingConditionalBranchBlock(null);
    setSectionContext({ sectionId, index });
    setConditionalContext(null);
    setIsBlockFormOpen(true);
  }, [setSectionContext, setConditionalContext]);

  // Open form to edit existing nested block in section
  const openEditNestedBlockForm = useCallback((sectionId: string, nestedIndex: number, block: JsonBlock) => {
    setEditingBlockType(block.type as BlockType);
    setEditingBlock(null);
    setInsertAtIndex(undefined);
    setEditingNestedBlock({ sectionId, nestedIndex, block });
    setEditingConditionalBranchBlock(null);
    setSectionContext({ sectionId, index: nestedIndex });
    setConditionalContext(null);
    setIsBlockFormOpen(true);
  }, [setSectionContext, setConditionalContext]);

  // Open form for new block in conditional branch
  const openConditionalBlockForm = useCallback((
    type: BlockType,
    conditionalId: string,
    branch: 'whenTrue' | 'whenFalse',
    index?: number
  ) => {
    setEditingBlockType(type);
    setEditingBlock(null);
    setInsertAtIndex(index);
    setEditingNestedBlock(null);
    setEditingConditionalBranchBlock(null);
    setSectionContext(null);
    setConditionalContext({ conditionalId, branch, index });
    setIsBlockFormOpen(true);
  }, [setSectionContext, setConditionalContext]);

  // Open form to edit existing block in conditional branch
  const openEditConditionalBlockForm = useCallback((
    conditionalId: string,
    branch: 'whenTrue' | 'whenFalse',
    nestedIndex: number,
    block: JsonBlock
  ) => {
    setEditingBlockType(block.type as BlockType);
    setEditingBlock(null);
    setInsertAtIndex(undefined);
    setEditingNestedBlock(null);
    setEditingConditionalBranchBlock({ conditionalId, branch, nestedIndex, block });
    setSectionContext(null);
    setConditionalContext({ conditionalId, branch, index: nestedIndex });
    setIsBlockFormOpen(true);
  }, [setSectionContext, setConditionalContext]);

  // Close form and clear all editing state
  const closeBlockForm = useCallback(() => {
    setIsBlockFormOpen(false);
    setEditingBlockType(null);
    setEditingBlock(null);
    setInsertAtIndex(undefined);
    setEditingNestedBlock(null);
    setEditingConditionalBranchBlock(null);
    clearContext();
  }, [clearContext]);

  return {
    isBlockFormOpen,
    editingBlockType,
    editingBlock,
    insertAtIndex,
    editingNestedBlock,
    editingConditionalBranchBlock,
    openNewBlockForm,
    openEditBlockForm,
    openNestedBlockForm,
    openEditNestedBlockForm,
    openConditionalBlockForm,
    openEditConditionalBlockForm,
    closeBlockForm,
    setEditingNestedBlock,
    setEditingConditionalBranchBlock,
  };
}

B2.2: Update BlockEditor.tsx

Replace the individual state declarations and handlers with the hook:

// Before:
const [isBlockFormOpen, setIsBlockFormOpen] = useState(false);
const [editingBlockType, setEditingBlockType] = useState<BlockType | null>(null);
// ... etc

// After:
const formState = useBlockFormState();
// Usage: formState.isBlockFormOpen, formState.openNewBlockForm(), etc.

Keep these handlers in BlockEditor.tsx - they need both formState and editor:

  • handleSplitToBlocks
  • handleConvertType
  • handleSwitchBlockType

Update them to use formState.editingNestedBlock, formState.editingConditionalBranchBlock, etc.

Checkpoint: git commit -m "refactor: extract useBlockFormState hook"

Verification: Full test suite


B2.3: Add Unit Tests for useBlockFormState

File: src/components/block-editor/hooks/useBlockFormState.test.tsx

import React from 'react';
import { renderHook, act } from '@testing-library/react';
import { useBlockFormState } from './useBlockFormState';
import { BlockEditorContextProvider } from '../BlockEditorContext';

// Wrapper to provide context
const wrapper = ({ children }: { children: React.ReactNode }) => (
  <BlockEditorContextProvider>{children}</BlockEditorContextProvider>
);

describe('useBlockFormState', () => {
  it('starts with form closed', () => {
    const { result } = renderHook(() => useBlockFormState(), { wrapper });
    
    expect(result.current.isBlockFormOpen).toBe(false);
    expect(result.current.editingBlockType).toBeNull();
    expect(result.current.editingBlock).toBeNull();
  });

  describe('root-level blocks', () => {
    it('opens form for new block', () => {
      const { result } = renderHook(() => useBlockFormState(), { wrapper });
      
      act(() => {
        result.current.openNewBlockForm('markdown', 2);
      });
      
      expect(result.current.isBlockFormOpen).toBe(true);
      expect(result.current.editingBlockType).toBe('markdown');
      expect(result.current.editingBlock).toBeNull();
      expect(result.current.insertAtIndex).toBe(2);
    });

    it('opens form for editing existing block', () => {
      const { result } = renderHook(() => useBlockFormState(), { wrapper });
      const block = { id: 'block-1', block: { type: 'markdown', content: 'test' } };
      
      act(() => {
        result.current.openEditBlockForm(block);
      });
      
      expect(result.current.isBlockFormOpen).toBe(true);
      expect(result.current.editingBlockType).toBe('markdown');
      expect(result.current.editingBlock).toBe(block);
      expect(result.current.insertAtIndex).toBeUndefined();
    });
  });

  describe('nested blocks in sections', () => {
    it('opens form for new nested block', () => {
      const { result } = renderHook(() => useBlockFormState(), { wrapper });
      
      act(() => {
        result.current.openNestedBlockForm('interactive', 'section-1', 0);
      });
      
      expect(result.current.isBlockFormOpen).toBe(true);
      expect(result.current.editingBlockType).toBe('interactive');
      expect(result.current.editingNestedBlock).toBeNull();
    });

    it('opens form for editing nested block', () => {
      const { result } = renderHook(() => useBlockFormState(), { wrapper });
      const block = { type: 'interactive', action: 'click', reftarget: '[data-testid="btn"]', content: 'Click' };
      
      act(() => {
        result.current.openEditNestedBlockForm('section-1', 2, block);
      });
      
      expect(result.current.isBlockFormOpen).toBe(true);
      expect(result.current.editingBlockType).toBe('interactive');
      expect(result.current.editingNestedBlock).toEqual({
        sectionId: 'section-1',
        nestedIndex: 2,
        block,
      });
    });
  });

  describe('conditional branch blocks', () => {
    it('opens form for new conditional block', () => {
      const { result } = renderHook(() => useBlockFormState(), { wrapper });
      
      act(() => {
        result.current.openConditionalBlockForm('markdown', 'cond-1', 'whenTrue', 0);
      });
      
      expect(result.current.isBlockFormOpen).toBe(true);
      expect(result.current.editingBlockType).toBe('markdown');
      expect(result.current.editingConditionalBranchBlock).toBeNull();
    });

    it('opens form for editing conditional block', () => {
      const { result } = renderHook(() => useBlockFormState(), { wrapper });
      const block = { type: 'markdown', content: 'test' };
      
      act(() => {
        result.current.openEditConditionalBlockForm('cond-1', 'whenFalse', 1, block);
      });
      
      expect(result.current.isBlockFormOpen).toBe(true);
      expect(result.current.editingConditionalBranchBlock).toEqual({
        conditionalId: 'cond-1',
        branch: 'whenFalse',
        nestedIndex: 1,
        block,
      });
    });
  });

  describe('closeBlockForm', () => {
    it('closes form and clears all state', () => {
      const { result } = renderHook(() => useBlockFormState(), { wrapper });
      
      // Open a form first
      act(() => {
        result.current.openNewBlockForm('section', 5);
      });
      expect(result.current.isBlockFormOpen).toBe(true);
      
      // Close it
      act(() => {
        result.current.closeBlockForm();
      });
      
      expect(result.current.isBlockFormOpen).toBe(false);
      expect(result.current.editingBlockType).toBeNull();
      expect(result.current.editingBlock).toBeNull();
      expect(result.current.insertAtIndex).toBeUndefined();
      expect(result.current.editingNestedBlock).toBeNull();
      expect(result.current.editingConditionalBranchBlock).toBeNull();
    });

    it('clears nested block state when closing', () => {
      const { result } = renderHook(() => useBlockFormState(), { wrapper });
      const block = { type: 'markdown', content: 'test' };
      
      act(() => {
        result.current.openEditNestedBlockForm('section-1', 0, block);
      });
      expect(result.current.editingNestedBlock).not.toBeNull();
      
      act(() => {
        result.current.closeBlockForm();
      });
      expect(result.current.editingNestedBlock).toBeNull();
    });
  });
});

Checkpoint: git commit -m "test: add unit tests for useBlockFormState"


Phase B3: Extract Recording Hooks (Restructured)

Risk Analysis

This is the highest-risk extraction because:

  • Circular callback dependencies (handleStopRecording calls handleSectionRecord or handleConditionalBranchRecord)
  • Timing-sensitive ref (pendingSectionIdRef with setTimeout)
  • Depends on external hooks (actionRecorder, editor, recordingPersistence)
  • Complex state transitions

Critical Issue: Circular Dependency

The original design creates a circular dependency:

  • useRecordingSession needs recordingPersistence.clear
  • useRecordingPersistence needs recording state to save/restore

Solution: Three-Layer Architecture

Break the recording logic into 3 parts to avoid circular dependencies:

┌─────────────────────────────────────────────────────────────────┐
│ BlockEditor.tsx                                                  │
│                                                                  │
│  1. const recordingState = useRecordingState();                  │
│     └── Just state, no persistence calls                         │
│                                                                  │
│  2. const recordingPersistence = useRecordingPersistence({       │
│       recordingIntoSection: recordingState.recordingIntoSection, │
│       ...                                                         │
│       onRestore: recordingState.restore,                          │
│     });                                                           │
│     └── Persistence with state access                             │
│                                                                  │
│  3. const recording = useRecordingActions({                       │
│       state: recordingState,                                      │
│       actionRecorder,                                             │
│       editor,                                                     │
│       onClear: recordingPersistence.clear,                        │
│     });                                                           │
│     └── Full actions with clear callback                          │
└─────────────────────────────────────────────────────────────────┘

B3.1: Create useRecordingState.ts (State Only)

File: src/components/block-editor/hooks/useRecordingState.ts

import { useState, useCallback } from 'react';
import type { RecordedStep } from '../../../utils/devtools';

/**
 * State for recording into a conditional branch.
 */
export interface ConditionalRecordingTarget {
  conditionalId: string;
  branch: 'whenTrue' | 'whenFalse';
}

/**
 * Recording state that can be persisted and restored.
 */
export interface RecordingStateSnapshot {
  recordingIntoSection: string | null;
  recordingIntoConditionalBranch: ConditionalRecordingTarget | null;
  recordingStartUrl: string | null;
  recordedSteps: RecordedStep[];
}

/**
 * Pure state management for recording sessions.
 * No side effects or dependencies on other hooks.
 */
export function useRecordingState() {
  const [recordingIntoSection, setRecordingIntoSection] = useState<string | null>(null);
  const [recordingIntoConditionalBranch, setRecordingIntoConditionalBranch] = useState<ConditionalRecordingTarget | null>(null);
  const [recordingStartUrl, setRecordingStartUrl] = useState<string | null>(null);

  const isRecording = recordingIntoSection !== null || recordingIntoConditionalBranch !== null;

  const reset = useCallback(() => {
    setRecordingIntoSection(null);
    setRecordingIntoConditionalBranch(null);
    setRecordingStartUrl(null);
  }, []);

  const restore = useCallback((snapshot: Omit<RecordingStateSnapshot, 'recordedSteps'>) => {
    setRecordingIntoSection(snapshot.recordingIntoSection);
    setRecordingIntoConditionalBranch(snapshot.recordingIntoConditionalBranch);
    setRecordingStartUrl(snapshot.recordingStartUrl);
  }, []);

  return {
    // State
    recordingIntoSection,
    recordingIntoConditionalBranch,
    recordingStartUrl,
    isRecording,
    // Setters
    setRecordingIntoSection,
    setRecordingIntoConditionalBranch,
    setRecordingStartUrl,
    // Actions
    reset,
    restore,
  };
}

export type RecordingState = ReturnType<typeof useRecordingState>;

Checkpoint: git commit -m "refactor: extract useRecordingState hook (state only)"

Verification: Full test suite


B3.2: Create useRecordingActions.ts (Actions)

File: src/components/block-editor/hooks/useRecordingActions.ts

import { useCallback, useRef } from 'react';
import type { JsonBlock } from '../types';
import type { RecordedStep } from '../../../utils/devtools';
import type { RecordingState, ConditionalRecordingTarget } from './useRecordingState';
import {
  groupRecordedStepsByGroupId,
  convertProcessedStepsToBlocks,
} from '../utils/recorded-steps-processor';

/**
 * Dependencies required by useRecordingActions.
 */
export interface RecordingActionsDependencies {
  /** Recording state from useRecordingState */
  state: RecordingState;
  /** Action recorder from useActionRecorder */
  actionRecorder: {
    recordedSteps: RecordedStep[];
    startRecording: () => void;
    stopRecording: () => void;
    clearRecording: () => void;
    setRecordedSteps: (steps: RecordedStep[]) => void;
  };
  /** Editor for adding blocks */
  editor: {
    addBlock: (block: JsonBlock, index?: number) => string;
    addBlockToSection: (block: JsonBlock, sectionId: string, index?: number) => void;
    addBlockToConditionalBranch: (conditionalId: string, branch: 'whenTrue' | 'whenFalse', block: JsonBlock, index?: number) => void;
  };
  /** Callback to clear persistence (passed from recordingPersistence.clear) */
  onClear: () => void;
}

/**
 * Actions for controlling recording sessions.
 * Receives state from useRecordingState and clear callback from persistence.
 */
export function useRecordingActions(deps: RecordingActionsDependencies) {
  const { state, actionRecorder, editor, onClear } = deps;
  const {
    recordingIntoSection,
    recordingIntoConditionalBranch,
    setRecordingIntoSection,
    setRecordingIntoConditionalBranch,
    setRecordingStartUrl,
  } = state;

  // Ref for pending section ID (for submit-and-record flow)
  const pendingSectionIdRef = useRef<string | null>(null);

  // Process recorded steps and add to section
  const processAndAddToSection = useCallback((sectionId: string) => {
    const steps = actionRecorder.recordedSteps;
    const processed = groupRecordedStepsByGroupId(steps);
    const blocks = convertProcessedStepsToBlocks(processed);
    blocks.forEach((block) => editor.addBlockToSection(block, sectionId));
  }, [actionRecorder.recordedSteps, editor]);

  // Process recorded steps and add to conditional branch
  const processAndAddToConditional = useCallback((conditionalId: string, branch: 'whenTrue' | 'whenFalse') => {
    const steps = actionRecorder.recordedSteps;
    const processed = groupRecordedStepsByGroupId(steps);
    const blocks = convertProcessedStepsToBlocks(processed);
    blocks.forEach((block) => editor.addBlockToConditionalBranch(conditionalId, branch, block));
  }, [actionRecorder.recordedSteps, editor]);

  // Start recording into a section
  const startSectionRecording = useCallback((sectionId: string) => {
    setRecordingIntoConditionalBranch(null);
    actionRecorder.clearRecording();
    actionRecorder.startRecording();
    setRecordingIntoSection(sectionId);
    setRecordingStartUrl(window.location.href);
  }, [actionRecorder, setRecordingIntoSection, setRecordingIntoConditionalBranch, setRecordingStartUrl]);

  // Stop recording into a section
  const stopSectionRecording = useCallback((sectionId: string) => {
    actionRecorder.stopRecording();
    processAndAddToSection(sectionId);
    actionRecorder.clearRecording();
    setRecordingIntoSection(null);
    setRecordingStartUrl(null);
    onClear();
  }, [actionRecorder, processAndAddToSection, setRecordingIntoSection, setRecordingStartUrl, onClear]);

  // Toggle section recording (start or stop)
  const toggleSectionRecording = useCallback((sectionId: string) => {
    if (recordingIntoSection === sectionId) {
      stopSectionRecording(sectionId);
    } else {
      startSectionRecording(sectionId);
    }
  }, [recordingIntoSection, startSectionRecording, stopSectionRecording]);

  // Start recording into a conditional branch
  const startConditionalRecording = useCallback((conditionalId: string, branch: 'whenTrue' | 'whenFalse') => {
    setRecordingIntoSection(null);
    actionRecorder.clearRecording();
    actionRecorder.startRecording();
    setRecordingIntoConditionalBranch({ conditionalId, branch });
    setRecordingStartUrl(window.location.href);
  }, [actionRecorder, setRecordingIntoSection, setRecordingIntoConditionalBranch, setRecordingStartUrl]);

  // Stop recording into a conditional branch
  const stopConditionalRecording = useCallback((conditionalId: string, branch: 'whenTrue' | 'whenFalse') => {
    actionRecorder.stopRecording();
    processAndAddToConditional(conditionalId, branch);
    actionRecorder.clearRecording();
    setRecordingIntoConditionalBranch(null);
    setRecordingStartUrl(null);
    onClear();
  }, [actionRecorder, processAndAddToConditional, setRecordingIntoConditionalBranch, setRecordingStartUrl, onClear]);

  // Toggle conditional recording (start or stop)
  const toggleConditionalRecording = useCallback((conditionalId: string, branch: 'whenTrue' | 'whenFalse') => {
    const isRecordingThis =
      recordingIntoConditionalBranch?.conditionalId === conditionalId &&
      recordingIntoConditionalBranch?.branch === branch;
    
    if (isRecordingThis) {
      stopConditionalRecording(conditionalId, branch);
    } else {
      startConditionalRecording(conditionalId, branch);
    }
  }, [recordingIntoConditionalBranch, startConditionalRecording, stopConditionalRecording]);

  // Stop any active recording
  const stopRecording = useCallback(() => {
    if (recordingIntoSection) {
      stopSectionRecording(recordingIntoSection);
    } else if (recordingIntoConditionalBranch) {
      stopConditionalRecording(
        recordingIntoConditionalBranch.conditionalId,
        recordingIntoConditionalBranch.branch
      );
    }
  }, [recordingIntoSection, recordingIntoConditionalBranch, stopSectionRecording, stopConditionalRecording]);

  // Submit block and immediately start recording
  const submitAndStartRecording = useCallback((block: JsonBlock, insertAtIndex?: number) => {
    const editorBlockId = editor.addBlock(block, insertAtIndex);
    pendingSectionIdRef.current = editorBlockId;
    const capturedUrl = window.location.href;
    
    // Use requestAnimationFrame for more predictable timing than setTimeout
    requestAnimationFrame(() => {
      if (pendingSectionIdRef.current) {
        setRecordingIntoConditionalBranch(null);
        actionRecorder.clearRecording();
        actionRecorder.startRecording();
        setRecordingIntoSection(pendingSectionIdRef.current);
        setRecordingStartUrl(capturedUrl);
        pendingSectionIdRef.current = null;
      }
    });
  }, [editor, actionRecorder, setRecordingIntoSection, setRecordingIntoConditionalBranch, setRecordingStartUrl]);

  return {
    startSectionRecording,
    stopSectionRecording,
    toggleSectionRecording,
    startConditionalRecording,
    stopConditionalRecording,
    toggleConditionalRecording,
    stopRecording,
    submitAndStartRecording,
  };
}

Checkpoint: git commit -m "refactor: extract useRecordingActions hook"

Verification: Full test suite


B3.3: Update BlockEditor.tsx to Use Three-Layer Architecture

// 1. State layer - no dependencies on persistence
const recordingState = useRecordingState();

// 2. Persistence layer - needs state for save, provides restore callback
const recordingPersistence = useRecordingPersistence({
  recordingIntoSection: recordingState.recordingIntoSection,
  recordingIntoConditionalBranch: recordingState.recordingIntoConditionalBranch,
  recordingStartUrl: recordingState.recordingStartUrl,
  recordedSteps: actionRecorder.recordedSteps,
  onRestore: (snapshot) => {
    recordingState.restore(snapshot);
    if (snapshot.recordedSteps.length > 0) {
      actionRecorder.setRecordedSteps(snapshot.recordedSteps);
    }
    if (snapshot.recordingIntoSection || snapshot.recordingIntoConditionalBranch) {
      actionRecorder.startRecording();
    }
  },
});

// 3. Actions layer - receives state and clear callback
const recordingActions = useRecordingActions({
  state: recordingState,
  actionRecorder,
  editor: {
    addBlock: editor.addBlock,
    addBlockToSection: editor.addBlockToSection,
    addBlockToConditionalBranch: editor.addBlockToConditionalBranch,
  },
  onClear: recordingPersistence.clear,
});

// Combine for convenience
const recording = {
  ...recordingState,
  ...recordingActions,
};

Checkpoint: git commit -m "refactor: integrate three-layer recording architecture"

Verification: Full test suite + manual testing of recording flow


B3.4: Add Unit Tests for Recording Hooks

File: src/components/block-editor/hooks/useRecordingState.test.ts

import { renderHook, act } from '@testing-library/react';
import { useRecordingState } from './useRecordingState';

describe('useRecordingState', () => {
  it('starts with no active recording', () => {
    const { result } = renderHook(() => useRecordingState());
    
    expect(result.current.isRecording).toBe(false);
    expect(result.current.recordingIntoSection).toBeNull();
    expect(result.current.recordingIntoConditionalBranch).toBeNull();
    expect(result.current.recordingStartUrl).toBeNull();
  });

  it('tracks section recording state', () => {
    const { result } = renderHook(() => useRecordingState());
    
    act(() => {
      result.current.setRecordingIntoSection('section-1');
      result.current.setRecordingStartUrl('http://localhost:3000');
    });
    
    expect(result.current.isRecording).toBe(true);
    expect(result.current.recordingIntoSection).toBe('section-1');
  });

  it('tracks conditional recording state', () => {
    const { result } = renderHook(() => useRecordingState());
    
    act(() => {
      result.current.setRecordingIntoConditionalBranch({
        conditionalId: 'cond-1',
        branch: 'whenTrue',
      });
    });
    
    expect(result.current.isRecording).toBe(true);
    expect(result.current.recordingIntoConditionalBranch).toEqual({
      conditionalId: 'cond-1',
      branch: 'whenTrue',
    });
  });

  it('resets all state', () => {
    const { result } = renderHook(() => useRecordingState());
    
    act(() => {
      result.current.setRecordingIntoSection('section-1');
      result.current.setRecordingStartUrl('http://localhost:3000');
    });
    
    act(() => {
      result.current.reset();
    });
    
    expect(result.current.isRecording).toBe(false);
    expect(result.current.recordingIntoSection).toBeNull();
  });

  it('restores from snapshot', () => {
    const { result } = renderHook(() => useRecordingState());
    
    act(() => {
      result.current.restore({
        recordingIntoSection: 'section-2',
        recordingIntoConditionalBranch: null,
        recordingStartUrl: 'http://localhost:3000/page',
      });
    });
    
    expect(result.current.recordingIntoSection).toBe('section-2');
    expect(result.current.recordingStartUrl).toBe('http://localhost:3000/page');
  });
});

File: src/components/block-editor/hooks/useRecordingActions.test.ts

import { renderHook, act } from '@testing-library/react';
import { useRecordingState } from './useRecordingState';
import { useRecordingActions, type RecordingActionsDependencies } from './useRecordingActions';

const createMockDeps = (state: ReturnType<typeof useRecordingState>): RecordingActionsDependencies => ({
  state,
  actionRecorder: {
    recordedSteps: [],
    startRecording: jest.fn(),
    stopRecording: jest.fn(),
    clearRecording: jest.fn(),
    setRecordedSteps: jest.fn(),
  },
  editor: {
    addBlock: jest.fn().mockReturnValue('new-block-id'),
    addBlockToSection: jest.fn(),
    addBlockToConditionalBranch: jest.fn(),
  },
  onClear: jest.fn(),
});

describe('useRecordingActions', () => {
  it('starts section recording', () => {
    const { result: stateResult } = renderHook(() => useRecordingState());
    const deps = createMockDeps(stateResult.current);
    const { result: actionsResult } = renderHook(() => useRecordingActions(deps));
    
    act(() => {
      actionsResult.current.startSectionRecording('section-1');
    });
    
    expect(deps.actionRecorder.startRecording).toHaveBeenCalled();
    expect(deps.actionRecorder.clearRecording).toHaveBeenCalled();
  });

  it('stops section recording and clears persistence', () => {
    const { result: stateResult } = renderHook(() => useRecordingState());
    const deps = createMockDeps(stateResult.current);
    deps.actionRecorder.recordedSteps = [
      { action: 'click', selector: '[data-testid="btn"]', timestamp: Date.now() },
    ];
    
    const { result: actionsResult } = renderHook(() => useRecordingActions(deps));
    
    act(() => {
      actionsResult.current.stopSectionRecording('section-1');
    });
    
    expect(deps.actionRecorder.stopRecording).toHaveBeenCalled();
    expect(deps.editor.addBlockToSection).toHaveBeenCalled();
    expect(deps.onClear).toHaveBeenCalled();
  });

  it('starts conditional recording', () => {
    const { result: stateResult } = renderHook(() => useRecordingState());
    const deps = createMockDeps(stateResult.current);
    const { result: actionsResult } = renderHook(() => useRecordingActions(deps));
    
    act(() => {
      actionsResult.current.startConditionalRecording('cond-1', 'whenTrue');
    });
    
    expect(deps.actionRecorder.startRecording).toHaveBeenCalled();
  });
});

Checkpoint: git commit -m "test: add unit tests for recording hooks"


Phase B4: Extract UI Components

With hooks extracted and tested, component extraction is lower risk.

IMPORTANT: Run full e2e test suite after EACH component extraction. Do not batch multiple extractions.

B4.1: Create BlockEditorHeader.tsx

File: src/components/block-editor/BlockEditorHeader.tsx

Extract the header section containing toolbar buttons.

Must preserve test IDs:

  • data-testid="guide-metadata-button"
  • data-testid="view-mode-toggle"
  • data-testid="copy-json-button"

Props to pass:

interface BlockEditorHeaderProps {
  guide: { id: string; title: string };
  isPreviewMode: boolean;
  isDirty: boolean;
  onTogglePreview: () => void;
  onOpenMetadata: () => void;
  onCopy: () => void;
  onDownload: () => void;
  onNewGuide: () => void;
  onImport: () => void;
  onOpenGitHubPR: () => void;
  onOpenTour: () => void;
  // Selection mode
  isSelectionMode: boolean;
  selectedCount: number;
  onToggleSelectionMode: () => void;
  onMergeToMultistep: () => void;
  onMergeToGuided: () => void;
}

Checkpoint: git commit -m "refactor: extract BlockEditorHeader component"

Verification: Full test suite including e2e


B4.2: Create BlockEditorContent.tsx

File: src/components/block-editor/BlockEditorContent.tsx

Extract the main content area with BlockList or BlockPreview.

Must preserve test ID: data-testid="block-editor-content"

Props to pass:

interface BlockEditorContentProps {
  isPreviewMode: boolean;
  blocks: EditorBlock[];
  guide: JsonGuide;
  // All BlockList props from A1.2 investigation
  blockOperations: BlockOperations;
  // Recording state
  recordingIntoSection: string | null;
  recordingIntoConditionalBranch: ConditionalRecordingTarget | null;
}

Checkpoint: git commit -m "refactor: extract BlockEditorContent component"

Verification: Full test suite including e2e


B4.3: Create BlockEditorModals.tsx

File: src/components/block-editor/BlockEditorModals.tsx

Extract all modal components into a single wrapper.

Modals to include:

  • GuideMetadataForm (modal)
  • ConfirmModal (new guide confirmation)
  • ImportGuideModal
  • GitHubPRModal
  • BlockEditorTour
  • BlockFormModal

Props to pass:

interface BlockEditorModalsProps {
  modals: ReturnType<typeof useModalManager>;
  formState: BlockFormState;
  editor: ReturnType<typeof useBlockEditor>;
  // Form submission handlers (kept in BlockEditor)
  onBlockFormSubmit: (block: JsonBlock) => void;
  onBlockFormCancel: () => void;
  onSubmitAndRecord: (block: JsonBlock) => void;
  onSplitToBlocks: () => void;
  onConvertType: (newType: 'multistep' | 'guided') => void;
  onSwitchBlockType: (newType: BlockType) => void;
  // Metadata handlers
  onMetadataSubmit: (metadata: GuideMetadata) => void;
  onNewGuideConfirm: () => void;
  onImport: (guide: JsonGuide) => void;
}

Checkpoint: git commit -m "refactor: extract BlockEditorModals component"

Verification: Full test suite including e2e


Phase B5: Final Assembly

B5.1: Refactor BlockEditor.tsx

The main component should now:

  1. Import and use all hooks from Plan A and B
  2. Render all extracted components
  3. Wire up props using BlockOperations type
  4. Be ~200-300 lines (don't over-optimize)

Note: Some coordination logic may need to stay in BlockEditor. Prioritize working code over strict line count.

export function BlockEditor({ initialGuide, onChange, onCopy, onDownload }: BlockEditorProps) {
  const styles = useStyles2(getBlockEditorStyles);
  
  // Core editor hook (existing)
  const editor = useBlockEditor({ initialGuide, onChange });
  
  // Plan A hooks
  const modals = useModalManager();
  const selection = useBlockSelection();
  
  // Plan B hooks - three-layer recording architecture
  const recordingState = useRecordingState();
  const recordingPersistence = useRecordingPersistence({...});
  const recordingActions = useRecordingActions({...});
  const recording = { ...recordingState, ...recordingActions };
  
  // Block form state (depends on context)
  const formState = useBlockFormState();
  
  // Block persistence
  const blockPersistence = useBlockPersistence({
    editor,
    isBlockFormOpen: formState.isBlockFormOpen,
  });
  
  // Create BlockOperations for child components
  const blockOps: BlockOperations = useMemo(() => ({
    // Block CRUD
    onAddBlock: formState.openNewBlockForm,
    onEditBlock: formState.openEditBlockForm,
    onDeleteBlock: editor.deleteBlock,
    onMoveBlock: editor.moveBlock,
    // ... remaining operations
  }), [formState, editor, selection, recording]);

  // Handlers that need both formState and editor stay here
  const handleBlockFormSubmit = useCallback((block: JsonBlock) => {...}, []);
  const handleSplitToBlocks = useCallback(() => {...}, []);
  const handleConvertType = useCallback((newType) => {...}, []);
  const handleSwitchBlockType = useCallback((newType) => {...}, []);

  return (
    <BlockEditorContextProvider>
      <div data-testid="block-editor" className={styles.container}>
        <BlockEditorHeader {...headerProps} />
        <BlockEditorContent {...contentProps} blockOperations={blockOps} />
        <BlockEditorFooter 
          isPreviewMode={editor.state.isPreviewMode}
          onBlockTypeSelect={formState.openNewBlockForm}
        />
        <BlockEditorModals {...modalProps} />
        {recording.isRecording && <RecordModeOverlay {...overlayProps} />}
      </div>
    </BlockEditorContextProvider>
  );
}

Checkpoint: git commit -m "refactor: final assembly of BlockEditor with extracted hooks and components"

Verification: Full test suite


Completion Checklist

  • All B0-B5 phases complete
  • All unit tests passing: npm run test:ci
  • All e2e tests passing: npm run e2e
  • No TypeScript errors: npm run typecheck
  • No lint errors: npm run lint
  • BlockEditor.tsx is ~200-300 lines (reduced from ~1076)
  • All checkpoints committed
  • Backup branch blockeditor-refactor-pre-b3 exists (created before B3)

File Structure After Plan B

src/components/block-editor/
├── BlockEditor.tsx                    (~200-300 lines, down from ~1076)
├── BlockEditorContext.tsx             (NEW - ~60 lines)
├── BlockEditorHeader.tsx              (NEW - ~100 lines)
├── BlockEditorContent.tsx             (NEW - ~90 lines)
├── BlockEditorFooter.tsx              (from Plan A - ~25 lines)
├── BlockEditorModals.tsx              (NEW - ~100 lines)
├── hooks/
│   ├── index.ts                       (updated exports)
│   ├── useBlockEditor.ts              (unchanged)
│   ├── useBlockPersistence.ts         (unchanged)
│   ├── useRecordingPersistence.ts     (unchanged)
│   ├── useBlockFormState.ts           (NEW - ~150 lines)
│   ├── useBlockFormState.test.tsx     (NEW - ~150 lines)
│   ├── useModalManager.ts             (from Plan A)
│   ├── useModalManager.test.ts        (from Plan A)
│   ├── useBlockSelection.ts           (from Plan A)
│   ├── useBlockSelection.test.ts      (from Plan A)
│   ├── useRecordingState.ts           (NEW - ~60 lines)
│   ├── useRecordingState.test.ts      (NEW - ~80 lines)
│   ├── useRecordingActions.ts         (NEW - ~150 lines)
│   └── useRecordingActions.test.ts    (NEW - ~100 lines)
├── types.ts                           (with BlockOperations interface)
└── utils/
    ├── index.ts                       (updated exports)
    ├── recorded-steps-processor.ts    (from Plan A)
    └── recorded-steps-processor.test.ts (from Plan A)

Troubleshooting

If E2E Tests Fail

  1. STOP - do not attempt inline fixes
  2. Create block-editor-refactor-issues.md in project root
  3. Document:
  • Which phase failed
  • Full error output from npm run e2e
  • Which test(s) failed
  • Analysis of what likely went wrong
  • Screenshots if relevant
  1. Consider reverting to last passing checkpoint
  2. Re-analyze the failing phase before retrying

Common Issues

Symptom Likely Cause Solution
Modal doesn't open Event handler not wired correctly Check callback prop names match
Test can't find element Test ID not preserved in extraction Verify data-testid attributes
Recording doesn't start actionRecorder not passed correctly Check dependency injection
Form state not clearing closeBlockForm not calling clearContext Verify context integration
Circular dependency error Hook ordering issue Use three-layer architecture (state → persistence → actions)
Context not found error Hook used outside provider Check BlockEditorContextProvider wraps component tree
Steps not converted to blocks recorded-steps-processor not imported Verify import from ../utils/recorded-steps-processor
Persistence not restoring onRestore callback not wired Check useRecordingPersistence onRestore prop

Circular Dependency Resolution

If you encounter circular dependency errors in B3, the issue is likely:

useRecordingSession needs recordingPersistence.clear
       ↑                           ↓
recordingPersistence needs recording state

Solution: Use the three-layer architecture:

  1. useRecordingState - pure state, no dependencies
  2. useRecordingPersistence - reads state, provides clear callback
  3. useRecordingActions - receives state and clear callback

This breaks the cycle by ensuring each layer only depends on the previous one.

Manual Testing Checklist for Recording

After B3, manually verify these scenarios work:

  1. Create new section block → Start recording → Perform actions → Stop recording → Verify blocks added
  2. Refresh page during recording → Verify recording resumes with persisted state
  3. Create conditional block → Record into whenTrue branch → Verify blocks added to correct branch
  4. Use "Create and Record" button → Verify section created AND recording starts immediately

name: Plan C BlockOps Consolidation overview: Complete the BlockEditor refactoring by expanding the BlockOperations interface to consolidate 29+ individual props into logical groups, then extract BlockEditorContent and BlockEditorModals components. This plan is self-contained with all context from Plan B phases B4.2 and B4.3. todos:

  • id: c0-verify content: "C0: Verify current state - all tests passing, checkpoints committed" status: pending
  • id: c1-interface content: "C1: Expand BlockOperations interface in types.ts with all 29+ operations" status: pending
  • id: c2-blocklist content: "C2: Update BlockList, SectionNestedBlocks, ConditionalBranches to use BlockOperations" status: pending
  • id: c3-blockeditor content: "C3: Update BlockEditor to create and pass BlockOperations object" status: pending
  • id: c4-content content: "C4: Extract BlockEditorContent component" status: pending
  • id: c5-modals content: "C5: Extract BlockEditorModals component" status: pending
  • id: c6-cleanup content: "C6: Final cleanup and export updates" status: pending isProject: false

Plan C: BlockOperations Consolidation and Component Extraction

Prerequisites

This plan continues from Plan B completion. Before starting, verify:

  • All Plan B phases (B0-B5) complete (except skipped B4.2 and B4.3)
  • All tests passing: npm run test:ci
  • TypeScript compiles: npm run typecheck
  • BlockEditor.tsx is currently ~827 lines

Current State Analysis

The Problem: 29+ Props in BlockList

BlockListProps (in BlockList.tsx lines 40-144) currently defines 29 individual props:

Block CRUD (5): blocks, onBlockEdit, onBlockDelete, onBlockMove, onBlockDuplicate
Insertion (3): onInsertBlock, onNestBlock, onUnnestBlock
Section nested (5): onInsertBlockInSection, onNestedBlockEdit, onNestedBlockDelete, 
                    onNestedBlockDuplicate, onNestedBlockMove
Recording (4): onSectionRecord, recordingIntoSection, 
               onConditionalBranchRecord, recordingIntoConditionalBranch
Selection (3): isSelectionMode, selectedBlockIds, onToggleBlockSelection
Conditional (7): onInsertBlockInConditional, onConditionalBranchBlockEdit,
                 onConditionalBranchBlockDelete, onConditionalBranchBlockDuplicate,
                 onConditionalBranchBlockMove, onNestBlockInConditional,
                 onUnnestBlockFromConditional
Cross-container (2): onMoveBlockBetweenConditionalBranches, onMoveBlockBetweenSections

Current BlockOperations Interface (Incomplete)

The existing BlockOperations interface in types.ts (lines 152-195) is incomplete:

Missing operations:

  • onBlockDuplicate
  • onNestBlock, onUnnestBlock
  • onNestedBlockDuplicate, onNestedBlockMove
  • onConditionalBranchBlockDuplicate, onConditionalBranchBlockMove
  • onNestBlockInConditional, onUnnestBlockFromConditional
  • onMoveBlockBetweenConditionalBranches, onMoveBlockBetweenSections

Risk Mitigation Strategy

Same rules as Plan B:

  1. One extraction per commit - never combine multiple extractions
  2. Full test suite after each extraction - typecheck, lint, prettier, unit tests, AND e2e tests
  3. If tests fail, STOP - do not attempt to fix inline
  4. Document failures - create block-editor-refactor-issues.md with analysis
  5. Rollback point - each checkpoint is a safe revert target
  6. Create backup branch before C2 - Phase C2 modifies BlockList which is high-risk

Full Verification Command Sequence

After each checkpoint, run the complete verification suite in this order:

# 1. Fix formatting (prettier can fail CI if not run)
npm run prettier

# 2. Type checking
npm run typecheck

# 3. Linting
npm run lint

# 4. Unit tests
npm run test:ci

# 5. End-to-end tests (critical for UI component changes)
npm run e2e

Important: Prettier must be run BEFORE committing, as formatting issues will fail CI. If any step fails, fix the issue before proceeding to the next phase.


Phase C0: Verify Current State

Run full verification before starting:

npm run prettier
npm run typecheck
npm run lint
npm run test:ci
npm run e2e

All must pass. If any fail, fix issues before proceeding.

Checkpoint: No code changes, just verification


Phase C1: Expand BlockOperations Interface

C1.1: Define Complete BlockOperations Interface

File: src/components/block-editor/types.ts

Replace the existing BlockOperations interface (lines 152-195) with:

/**
 * Grouped operations interface to reduce prop drilling.
 * Consolidates 29+ individual callbacks into logical groups.
 * 
 * This interface serves as the contract between BlockEditor and its
 * child components (BlockList, SectionNestedBlocks, ConditionalBranches).
 */
export interface BlockOperations {
  // ============ ROOT BLOCK CRUD ============
  /** Edit a root-level block */
  onBlockEdit: (block: EditorBlock) => void;
  /** Delete a root-level block by ID */
  onBlockDelete: (id: string) => void;
  /** Move a root-level block from one index to another */
  onBlockMove: (fromIndex: number, toIndex: number) => void;
  /** Duplicate a root-level block, returns new block ID or null */
  onBlockDuplicate: (id: string) => string | null;
  /** Insert a new block of given type at optional index */
  onInsertBlock: (type: BlockType, index?: number) => void;

  // ============ SECTION NESTING ============
  /** Nest a root block into a section */
  onNestBlock: (blockId: string, sectionId: string, insertIndex?: number) => void;
  /** Unnest a block from a section back to root level */
  onUnnestBlock: (nestedBlockId: string, sectionId: string, insertAtRootIndex?: number) => void;
  /** Insert a new block directly into a section */
  onInsertBlockInSection: (type: BlockType, sectionId: string, index?: number) => void;
  /** Edit a nested block within a section */
  onNestedBlockEdit: (sectionId: string, nestedIndex: number, block: JsonBlock) => void;
  /** Delete a nested block within a section */
  onNestedBlockDelete: (sectionId: string, nestedIndex: number) => void;
  /** Duplicate a nested block within a section */
  onNestedBlockDuplicate: (sectionId: string, nestedIndex: number) => void;
  /** Move a nested block within its section */
  onNestedBlockMove: (sectionId: string, fromIndex: number, toIndex: number) => void;

  // ============ CONDITIONAL BRANCH OPERATIONS ============
  /** Insert a new block into a conditional branch */
  onInsertBlockInConditional: (
    type: BlockType,
    conditionalId: string,
    branch: 'whenTrue' | 'whenFalse',
    index?: number
  ) => void;
  /** Edit a block within a conditional branch */
  onConditionalBranchBlockEdit: (
    conditionalId: string,
    branch: 'whenTrue' | 'whenFalse',
    nestedIndex: number,
    block: JsonBlock
  ) => void;
  /** Delete a block from a conditional branch */
  onConditionalBranchBlockDelete: (
    conditionalId: string,
    branch: 'whenTrue' | 'whenFalse',
    nestedIndex: number
  ) => void;
  /** Duplicate a block within a conditional branch */
  onConditionalBranchBlockDuplicate: (
    conditionalId: string,
    branch: 'whenTrue' | 'whenFalse',
    nestedIndex: number
  ) => void;
  /** Move a block within a conditional branch */
  onConditionalBranchBlockMove: (
    conditionalId: string,
    branch: 'whenTrue' | 'whenFalse',
    fromIndex: number,
    toIndex: number
  ) => void;
  /** Nest a root block into a conditional branch */
  onNestBlockInConditional: (
    blockId: string,
    conditionalId: string,
    branch: 'whenTrue' | 'whenFalse',
    insertIndex?: number
  ) => void;
  /** Unnest a block from a conditional branch back to root */
  onUnnestBlockFromConditional: (
    conditionalId: string,
    branch: 'whenTrue' | 'whenFalse',
    nestedIndex: number,
    insertAtRootIndex?: number
  ) => void;
  /** Move a block between conditional branches */
  onMoveBlockBetweenConditionalBranches: (
    conditionalId: string,
    fromBranch: 'whenTrue' | 'whenFalse',
    fromIndex: number,
    toBranch: 'whenTrue' | 'whenFalse',
    toIndex?: number
  ) => void;

  // ============ CROSS-CONTAINER MOVES ============
  /** Move a block from one section to another */
  onMoveBlockBetweenSections: (
    fromSectionId: string,
    fromIndex: number,
    toSectionId: string,
    toIndex?: number
  ) => void;

  // ============ SELECTION STATE ============
  /** Whether selection mode is active */
  isSelectionMode: boolean;
  /** Set of currently selected block IDs */
  selectedBlockIds: Set<string>;
  /** Toggle selection of a block */
  onToggleBlockSelection: (blockId: string) => void;

  // ============ RECORDING STATE ============
  /** ID of section currently being recorded into (if any) */
  recordingIntoSection: string | null;
  /** Branch currently being recorded into (if any) */
  recordingIntoConditionalBranch: { conditionalId: string; branch: 'whenTrue' | 'whenFalse' } | null;
  /** Start/stop recording into a section */
  onSectionRecord: (sectionId: string) => void;
  /** Start/stop recording into a conditional branch */
  onConditionalBranchRecord: (conditionalId: string, branch: 'whenTrue' | 'whenFalse') => void;
}

Checkpoint: git commit -m "feat(types): expand BlockOperations interface with all operations"

Verification: TypeScript compile only (no runtime changes yet)

npm run prettier
npm run typecheck

Phase C2: Update BlockList to Use BlockOperations

Risk Analysis

This is the highest-risk phase because:

  • BlockList is a complex 833-line component with drag-and-drop logic
  • Changes affect SectionNestedBlocks and ConditionalBranches
  • Multiple components depend on the prop interface

Create backup branch before starting:

git checkout -b blockeditor-refactor-pre-c2
git checkout -  # Return to main branch

C2.1: Update BlockListProps Interface

File: src/components/block-editor/BlockList.tsx

Replace the current BlockListProps interface (lines 40-144) with:

export interface BlockListProps {
  /** List of blocks to render */
  blocks: EditorBlock[];
  /** All block operations - consolidated interface */
  operations: BlockOperations;
}

C2.2: Update BlockList Function Signature and Destructuring

Update the function signature (lines 149-179) to destructure from operations:

export function BlockList({ blocks, operations }: BlockListProps) {
  const styles = useStyles2(getBlockListStyles);
  const nestedStyles = useStyles2(getNestedStyles);
  const conditionalStyles = useStyles2(getConditionalStyles);

  // Destructure operations for convenience
  const {
    onBlockEdit,
    onBlockDelete,
    onBlockMove,
    onBlockDuplicate,
    onInsertBlock,
    onNestBlock,
    onUnnestBlock,
    onInsertBlockInSection,
    onNestedBlockEdit,
    onNestedBlockDelete,
    onNestedBlockDuplicate,
    onNestedBlockMove,
    onSectionRecord,
    recordingIntoSection,
    onConditionalBranchRecord,
    recordingIntoConditionalBranch,
    isSelectionMode,
    selectedBlockIds,
    onToggleBlockSelection,
    onInsertBlockInConditional,
    onConditionalBranchBlockEdit,
    onConditionalBranchBlockDelete,
    onConditionalBranchBlockDuplicate,
    onConditionalBranchBlockMove,
    onNestBlockInConditional,
    onUnnestBlockFromConditional,
    onMoveBlockBetweenConditionalBranches,
    onMoveBlockBetweenSections,
  } = operations;
  
  // ... rest of component unchanged

C2.3: Update SectionNestedBlocks Props

File: src/components/block-editor/SectionNestedBlocks.tsx

Update interface (lines 18-36) to pass through relevant operations:

export interface SectionNestedBlocksProps {
  block: EditorBlock;
  sectionBlocks: JsonBlock[];
  isCollapsed: boolean;
  nestedStyles: ReturnType<typeof getNestedStyles>;
  // DnD state
  activeId: UniqueIdentifier | null;
  activeDropZone: string | null;
  activeDragData: DragData | null;
  isDraggingUnNestable: boolean;
  // From operations
  isSelectionMode: boolean;
  selectedBlockIds: Set<string>;
  onToggleBlockSelection: (blockId: string) => void;
  onNestedBlockEdit: (sectionId: string, nestedIndex: number, block: JsonBlock) => void;
  onNestedBlockDelete: (sectionId: string, nestedIndex: number) => void;
  onNestedBlockDuplicate: (sectionId: string, nestedIndex: number) => void;
  onInsertBlockInSection: (type: BlockType, sectionId: string, index?: number) => void;
  // Animation
  justDroppedId?: string | null;
}

Update the component to use onInsertBlockInSection directly instead of handleInsertInSection:

// In the BlockPalette render:
<BlockPalette
  onSelect={(type) => onInsertBlockInSection(type, block.id)}
  excludeTypes={['section', 'conditional']}
  embedded
/>

C2.4: Update ConditionalBranches Props

File: src/components/block-editor/ConditionalBranches.tsx

Update interface (lines 19-57) - keep same structure but ensure all props are required (remove optional markers for operations that are always provided).

C2.5: Update BlockList Calls to Child Components

In BlockList.tsx, update the SectionNestedBlocks render (lines 781-800):

<SectionNestedBlocks
  block={block}
  sectionBlocks={sectionBlocks}
  isCollapsed={collapsedSections.has(block.id)}
  nestedStyles={nestedStyles}
  activeId={activeId}
  activeDropZone={activeDropZone}
  activeDragData={activeDragData}
  isDraggingUnNestable={isDraggingUnNestable}
  isSelectionMode={isSelectionMode}
  selectedBlockIds={selectedBlockIds}
  onToggleBlockSelection={onToggleBlockSelection}
  onNestedBlockEdit={onNestedBlockEdit}
  onNestedBlockDelete={onNestedBlockDelete}
  onNestedBlockDuplicate={onNestedBlockDuplicate}
  onInsertBlockInSection={onInsertBlockInSection}
  justDroppedId={justDroppedId}
/>

Update ConditionalBranches render (lines 759-779) similarly.

Checkpoint: git commit -m "refactor(BlockList): use BlockOperations interface"

Verification: Full test suite (highest risk phase - verify thoroughly)

npm run prettier
npm run typecheck
npm run lint
npm run test:ci
npm run e2e

Phase C3: Update BlockEditor to Create BlockOperations

C3.1: Create BlockOperations Object in BlockEditor

File: src/components/block-editor/BlockEditor.tsx

Add a memoized blockOperations object after the recording hooks (around line 150):

// Create BlockOperations for child components
const blockOperations: BlockOperations = useMemo(
  () => ({
    // Root block CRUD
    onBlockEdit: formState.openEditBlockForm,
    onBlockDelete: editor.removeBlock,
    onBlockMove: editor.moveBlock,
    onBlockDuplicate: editor.duplicateBlock,
    onInsertBlock: formState.openNewBlockForm,

    // Section nesting
    onNestBlock: editor.nestBlockInSection,
    onUnnestBlock: editor.unnestBlockFromSection,
    onInsertBlockInSection: formState.openNestedBlockForm,
    onNestedBlockEdit: formState.openEditNestedBlockForm,
    onNestedBlockDelete: editor.deleteNestedBlock,
    onNestedBlockDuplicate: editor.duplicateNestedBlock,
    onNestedBlockMove: editor.moveNestedBlock,

    // Conditional branch operations
    onInsertBlockInConditional: formState.openConditionalBlockForm,
    onConditionalBranchBlockEdit: formState.openEditConditionalBlockForm,
    onConditionalBranchBlockDelete: editor.deleteConditionalBranchBlock,
    onConditionalBranchBlockDuplicate: editor.duplicateConditionalBranchBlock,
    onConditionalBranchBlockMove: editor.moveConditionalBranchBlock,
    onNestBlockInConditional: editor.nestBlockInConditional,
    onUnnestBlockFromConditional: editor.unnestBlockFromConditional,
    onMoveBlockBetweenConditionalBranches: editor.moveBlockBetweenConditionalBranches,

    // Cross-container moves
    onMoveBlockBetweenSections: editor.moveBlockBetweenSections,

    // Selection state
    isSelectionMode: selection.isSelectionMode,
    selectedBlockIds: selection.selectedBlockIds,
    onToggleBlockSelection: selection.toggleBlockSelection,

    // Recording state
    recordingIntoSection,
    recordingIntoConditionalBranch,
    onSectionRecord: recordingActions.toggleSectionRecording,
    onConditionalBranchRecord: recordingActions.toggleConditionalRecording,
  }),
  [
    formState,
    editor,
    selection,
    recordingIntoSection,
    recordingIntoConditionalBranch,
    recordingActions,
  ]
);

C3.2: Update BlockList Usage in BlockEditor

Replace the BlockList render (lines 678-708) with:

<BlockList blocks={state.blocks} operations={blockOperations} />

C3.3: Remove Now-Unused Handler Functions

Remove these individual handlers from BlockEditor.tsx as they're now accessed via blockOperations:

  • handleBlockEdit (line 194)
  • handleBlockTypeSelect (line 191)
  • handleInsertBlockInSection (line 446)
  • handleNestedBlockEdit (line 449)
  • handleNestedBlockDelete (lines 452-455)
  • handleNestedBlockDuplicate (lines 460-463)
  • handleNestedBlockMove (lines 468-471)
  • handleInsertBlockInConditional (line 478)
  • handleConditionalBranchBlockEdit (line 481)
  • handleConditionalBranchBlockDelete (lines 484-489)
  • handleConditionalBranchBlockDuplicate (lines 492-497)
  • handleConditionalBranchBlockMove (lines 500-505)
  • handleNestBlock (lines 430-434)
  • handleUnnestBlock (lines 438-442)
  • handleNestBlockInConditional (lines 508-513)
  • handleUnnestBlockFromConditional (lines 516-521)
  • handleMoveBlockBetweenConditionalBranches (lines 524-535)
  • handleMoveBlockBetweenSections (lines 538-542)
  • handleSectionRecord (line 546)
  • handleConditionalBranchRecord (line 547)

Checkpoint: git commit -m "refactor(BlockEditor): use BlockOperations object for BlockList"

Verification: Full test suite

npm run prettier
npm run typecheck
npm run lint
npm run test:ci
npm run e2e

Phase C4: Extract BlockEditorContent Component

C4.1: Create BlockEditorContent.tsx

File: src/components/block-editor/BlockEditorContent.tsx

/**
 * BlockEditorContent Component
 *
 * Main content area of the block editor containing:
 * - Selection controls for merge operations
 * - BlockList (edit mode) or BlockPreview (preview mode)
 * - Empty state for new guides
 */

import React from 'react';
import { Button } from '@grafana/ui';
import { BlockList } from './BlockList';
import { BlockPreview } from './BlockPreview';
import type { EditorBlock, BlockOperations, JsonGuide } from './types';

export interface BlockEditorContentProps {
  /** Whether in preview mode */
  isPreviewMode: boolean;
  /** List of blocks */
  blocks: EditorBlock[];
  /** Full guide for preview mode */
  guide: JsonGuide;
  /** Consolidated block operations */
  operations: BlockOperations;
  /** Whether there are any blocks */
  hasBlocks: boolean;
  /** Style classes */
  styles: {
    content: string;
    selectionControls: string;
    selectionCount: string;
    emptyState: string;
    emptyStateIcon: string;
    emptyStateText: string;
  };
  /** Merge handlers */
  onMergeToMultistep: () => void;
  onMergeToGuided: () => void;
  onClearSelection: () => void;
  /** Empty state actions */
  onLoadTemplate: () => void;
  onOpenTour: () => void;
}

export function BlockEditorContent({
  isPreviewMode,
  blocks,
  guide,
  operations,
  hasBlocks,
  styles,
  onMergeToMultistep,
  onMergeToGuided,
  onClearSelection,
  onLoadTemplate,
  onOpenTour,
}: BlockEditorContentProps) {
  const { isSelectionMode, selectedBlockIds } = operations;
  const selectedCount = selectedBlockIds.size;

  return (
    <div className={styles.content} data-testid="block-editor-content">
      {/* Selection controls - shown in edit mode, above blocks */}
      {!isPreviewMode && hasBlocks && (
        <div className={styles.selectionControls}>
          {isSelectionMode && selectedCount >= 2 ? (
            <>
              <span className={styles.selectionCount}>{selectedCount} blocks selected</span>
              <Button variant="primary" size="sm" onClick={onMergeToMultistep}>
                Create multistep
              </Button>
              <Button variant="primary" size="sm" onClick={onMergeToGuided}>
                Create guided
              </Button>
              <Button variant="secondary" size="sm" onClick={onClearSelection}>
                Cancel
              </Button>
            </>
          ) : (
            <Button
              variant={isSelectionMode ? 'primary' : 'secondary'}
              size="sm"
              icon="check-square"
              onClick={operations.onToggleBlockSelection ? () => {} : undefined}
              tooltip={
                isSelectionMode
                  ? 'Click to exit selection mode'
                  : 'Select blocks to merge into multistep/guided'
              }
            >
              {isSelectionMode ? 'Done selecting' : 'Select blocks'}
            </Button>
          )}
        </div>
      )}

      {isPreviewMode ? (
        <BlockPreview guide={guide} />
      ) : hasBlocks ? (
        <BlockList blocks={blocks} operations={operations} />
      ) : (
        <div className={styles.emptyState}>
          <div className={styles.emptyStateIcon}>📄</div>
          <p className={styles.emptyStateText}>Your guide is empty. Add your first block to get started.</p>
          <div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
            <Button variant="secondary" onClick={onLoadTemplate} icon="file-alt">
              Load example guide
            </Button>
            <Button variant="secondary" onClick={onOpenTour} icon="question-circle">
              Take a tour
            </Button>
          </div>
        </div>
      )}
    </div>
  );
}

BlockEditorContent.displayName = 'BlockEditorContent';

C4.2: Update BlockEditor to Use BlockEditorContent

Replace the content section in BlockEditor.tsx (lines 639-723) with:

<BlockEditorContent
  isPreviewMode={state.isPreviewMode}
  blocks={state.blocks}
  guide={editor.getGuide()}
  operations={blockOperations}
  hasBlocks={hasBlocks}
  styles={{
    content: styles.content,
    selectionControls: styles.selectionControls,
    selectionCount: styles.selectionCount,
    emptyState: styles.emptyState,
    emptyStateIcon: styles.emptyStateIcon,
    emptyStateText: styles.emptyStateText,
  }}
  onMergeToMultistep={handleMergeToMultistep}
  onMergeToGuided={handleMergeToGuided}
  onClearSelection={selection.clearSelection}
  onLoadTemplate={handleLoadTemplate}
  onOpenTour={() => modals.open('tour')}
/>

Note: The selection toggle button needs adjustment - the current implementation in BlockEditorContent should call selection.toggleSelectionMode from the parent. Update the props interface:

// Add to BlockEditorContentProps
onToggleSelectionMode: () => void;

// Update the button onClick
onClick={onToggleSelectionMode}

Checkpoint: git commit -m "refactor: extract BlockEditorContent component"

Verification: Full test suite

npm run prettier
npm run typecheck
npm run lint
npm run test:ci
npm run e2e

Phase C5: Extract BlockEditorModals Component

C5.1: Create BlockEditorModals.tsx

File: src/components/block-editor/BlockEditorModals.tsx

/**
 * BlockEditorModals Component
 *
 * Container for all modal dialogs in the block editor:
 * - GuideMetadataForm
 * - BlockFormModal
 * - ConfirmModal (new guide)
 * - ImportGuideModal
 * - GitHubPRModal
 * - BlockEditorTour
 */

import React from 'react';
import { ConfirmModal } from '@grafana/ui';
import { GuideMetadataForm } from './GuideMetadataForm';
import { BlockFormModal } from './BlockFormModal';
import { ImportGuideModal } from './ImportGuideModal';
import { GitHubPRModal } from './GitHubPRModal';
import { BlockEditorTour } from './BlockEditorTour';
import type { BlockType, JsonBlock, JsonGuide } from './types';
import type { ModalName } from './hooks/useModalManager';

export interface BlockEditorModalsProps {
  // Modal visibility state
  isModalOpen: (name: ModalName) => boolean;
  closeModal: (name: ModalName) => void;
  
  // Guide state
  guide: JsonGuide;
  isDirty: boolean;
  hasBlocks: boolean;
  
  // Guide metadata handlers
  onUpdateGuideMetadata: (metadata: Partial<JsonGuide>) => void;
  
  // New guide handlers
  onNewGuideConfirm: () => void;
  
  // Import handlers
  onImportGuide: (guide: JsonGuide) => void;
  
  // Block form state
  isBlockFormOpen: boolean;
  editingBlockType: BlockType | null;
  editingBlock: { id: string; block: JsonBlock } | null;
  editingNestedBlock: { sectionId: string; nestedIndex: number; block: JsonBlock } | null;
  editingConditionalBranchBlock: {
    conditionalId: string;
    branch: 'whenTrue' | 'whenFalse';
    nestedIndex: number;
    block: JsonBlock;
  } | null;
  
  // Block form handlers
  onBlockFormSubmit: (block: JsonBlock) => void;
  onBlockFormCancel: () => void;
  onSubmitAndRecord?: (block: JsonBlock) => void;
  onSplitToBlocks?: () => void;
  onConvertType?: (newType: 'multistep' | 'guided') => void;
  onSwitchBlockType?: (newType: BlockType) => void;
}

export function BlockEditorModals({
  isModalOpen,
  closeModal,
  guide,
  isDirty,
  hasBlocks,
  onUpdateGuideMetadata,
  onNewGuideConfirm,
  onImportGuide,
  isBlockFormOpen,
  editingBlockType,
  editingBlock,
  editingNestedBlock,
  editingConditionalBranchBlock,
  onBlockFormSubmit,
  onBlockFormCancel,
  onSubmitAndRecord,
  onSplitToBlocks,
  onConvertType,
  onSwitchBlockType,
}: BlockEditorModalsProps) {
  // Derive initial data for block form
  const initialData = editingConditionalBranchBlock?.block 
    ?? editingNestedBlock?.block 
    ?? editingBlock?.block;
  
  // Determine if editing (vs creating)
  const isEditing = !!editingBlock || !!editingNestedBlock || !!editingConditionalBranchBlock;
  
  // Determine if multistep/guided actions are available
  const showMultistepActions = 
    (editingBlockType === 'multistep' || editingBlockType === 'guided') && isEditing;

  return (
    <>
      <GuideMetadataForm
        isOpen={isModalOpen('metadata')}
        guide={guide}
        onUpdate={onUpdateGuideMetadata}
        onClose={() => closeModal('metadata')}
      />

      {isBlockFormOpen && editingBlockType && (
        <BlockFormModal
          blockType={editingBlockType}
          initialData={initialData}
          onSubmit={onBlockFormSubmit}
          onSubmitAndRecord={editingBlockType === 'section' ? onSubmitAndRecord : undefined}
          onCancel={onBlockFormCancel}
          isEditing={isEditing}
          onSplitToBlocks={showMultistepActions ? onSplitToBlocks : undefined}
          onConvertType={showMultistepActions ? onConvertType : undefined}
          onSwitchBlockType={isEditing ? onSwitchBlockType : undefined}
        />
      )}

      <ConfirmModal
        isOpen={isModalOpen('newGuideConfirm')}
        title="Start new guide"
        body="Are you sure you want to start a new guide? Your current work will be deleted and cannot be recovered."
        confirmText="Start new"
        dismissText="Cancel"
        onConfirm={onNewGuideConfirm}
        onDismiss={() => closeModal('newGuideConfirm')}
      />

      <ImportGuideModal
        isOpen={isModalOpen('import')}
        onImport={onImportGuide}
        onClose={() => closeModal('import')}
        hasUnsavedChanges={isDirty || hasBlocks}
      />

      <GitHubPRModal
        isOpen={isModalOpen('githubPr')}
        guide={guide}
        onClose={() => closeModal('githubPr')}
      />

      {isModalOpen('tour') && <BlockEditorTour onClose={() => closeModal('tour')} />}
    </>
  );
}

BlockEditorModals.displayName = 'BlockEditorModals';

C5.2: Update BlockEditor to Use BlockEditorModals

Replace the modals section in BlockEditor.tsx (lines 729-809) with:

{/* Modals */}
<BlockEditorModals
  isModalOpen={modals.isOpen}
  closeModal={modals.close}
  guide={state.guide}
  isDirty={state.isDirty}
  hasBlocks={hasBlocks}
  onUpdateGuideMetadata={editor.updateGuideMetadata}
  onNewGuideConfirm={handleNewGuide}
  onImportGuide={handleImportGuide}
  isBlockFormOpen={isBlockFormOpen}
  editingBlockType={editingBlockType}
  editingBlock={editingBlock}
  editingNestedBlock={editingNestedBlock}
  editingConditionalBranchBlock={editingConditionalBranchBlock}
  onBlockFormSubmit={handleBlockFormSubmitWithSection}
  onBlockFormCancel={handleBlockFormCancel}
  onSubmitAndRecord={handleSubmitAndStartRecording}
  onSplitToBlocks={handleSplitToBlocks}
  onConvertType={handleConvertType}
  onSwitchBlockType={handleSwitchBlockType}
/>

{/* Record mode overlay (not a modal - stays in BlockEditor) */}
{(recordingIntoSection || recordingIntoConditionalBranch) && (
  <RecordModeOverlay
    isRecording={actionRecorder.isRecording}
    stepCount={actionRecorder.recordedSteps.length}
    onStop={handleStopRecording}
    sectionName={/* ... existing logic ... */}
    startingUrl={recordingStartUrl ?? undefined}
    pendingMultiStepCount={actionRecorder.pendingGroupSteps.length}
    isGroupingMultiStep={actionRecorder.activeModal !== null}
    isMultiStepGroupingEnabled={isSectionMultiStepGroupingEnabled}
    onToggleMultiStepGrouping={() => setIsSectionMultiStepGroupingEnabled((prev) => !prev)}
  />
)}

Note: Keep RecordModeOverlay in BlockEditor.tsx since it has complex dependencies on actionRecorder state.

Checkpoint: git commit -m "refactor: extract BlockEditorModals component"

Verification: Full test suite

npm run prettier
npm run typecheck
npm run lint
npm run test:ci
npm run e2e

Phase C6: Final Cleanup and Export Updates

C6.1: Update index.ts Exports

File: src/components/block-editor/index.ts

Add exports for new components:

export { BlockEditorContent } from './BlockEditorContent';
export type { BlockEditorContentProps } from './BlockEditorContent';

export { BlockEditorModals } from './BlockEditorModals';
export type { BlockEditorModalsProps } from './BlockEditorModals';

C6.2: Remove Dead Code in BlockEditor

Review BlockEditor.tsx and remove any unused imports or variables after the extractions.

Checkpoint: git commit -m "chore: cleanup exports and remove dead code"

Verification: Full test suite

npm run prettier
npm run typecheck
npm run lint
npm run test:ci
npm run e2e

Completion Checklist

  • All C0-C6 phases complete
  • Code formatted: npm run prettier (no changes needed)
  • No TypeScript errors: npm run typecheck
  • No lint errors: npm run lint
  • All unit tests passing: npm run test:ci
  • All e2e tests passing: npm run e2e
  • BlockEditor.tsx reduced from ~827 to ~400-500 lines
  • All checkpoints committed
  • Backup branch blockeditor-refactor-pre-c2 exists

File Structure After Plan C

src/components/block-editor/
├── BlockEditor.tsx              (~400-500 lines, down from ~827)
├── BlockEditorContext.tsx       (unchanged from Plan B)
├── BlockEditorContent.tsx       (NEW - ~120 lines)
├── BlockEditorFooter.tsx        (unchanged from Plan A)
├── BlockEditorHeader.tsx        (unchanged from Plan B)
├── BlockEditorModals.tsx        (NEW - ~130 lines)
├── BlockList.tsx                (updated props interface)
├── SectionNestedBlocks.tsx      (updated props interface)
├── ConditionalBranches.tsx      (updated props interface)
├── hooks/                       (unchanged)
├── types.ts                     (expanded BlockOperations)
└── ...

Troubleshooting

If Tests Fail After C2

This is the highest-risk phase. If tests fail:

  1. STOP - do not attempt inline fixes
  2. Check the git diff carefully for typos in prop names
  3. Verify all destructured names match the interface exactly
  4. Check that optional props are handled (null checks)
  5. If unfixable, revert to blockeditor-refactor-pre-c2 branch

Common Issues

Symptom Likely Cause Solution
TypeScript: Property 'X' does not exist Missing prop in BlockOperations Add to interface
Runtime: Cannot read property of undefined Optional prop accessed without check Add null check or make required
Test: Expected function to be called Handler not wired correctly Verify prop name matches
DnD not working operations not passed to child Check BlockList render
CI fails on formatting Prettier not run before commit Run npm run prettier before commit
E2E test timeout Modal or overlay blocking Check data-testid attributes

Clone this wiki locally