-
Notifications
You must be signed in to change notification settings - Fork 31
Refactor Stored Knowledge
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
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.
- Branch:
blockeditor-refactor(or create from current state) - All existing tests passing:
npm run typecheck && npm run test:ci && npm run e2e
| 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 |
- Run
npm run typecheck- fix any errors - Run
npm run lint:fix- auto-fix lint issues - Run
npm run prettier --write src/components/block-editor/- format code - Run
npm run test:ci- run unit tests - Run
npm run e2e- verify e2e tests pass - Git commit with descriptive message
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 (inhandleBlockFormSubmitWithSection)
**__blockEditorConditionalContext**:
- WRITE: Lines 516-518 (in
handleInsertBlockInConditional) - READ: Lines 879-881 (in
handleBlockFormSubmitWithSection) - DELETE: Lines 207-209 (in
handleBlockFormCancel) and Lines 908-914 (inhandleBlockFormSubmitWithSection)
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.
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:
- Block CRUD operations
- Nested block operations (sections)
- Conditional branch operations
- Selection operations
- Recording operations
No code changes in this phase.
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.
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
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
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
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
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"
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"
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"
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
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
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 -
BlockOperationsinterface defined intypes.ts(line 152)
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 |
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 (inhandleBlockFormSubmitWithSection)
**__blockEditorConditionalContext**:
- WRITE: Lines 516-518 (in
handleInsertBlockInConditional) - READ: Lines 879-881 (in
handleBlockFormSubmitWithSection) - DELETE: Lines 207-209 (in
handleBlockFormCancel) and Lines 908-914 (inhandleBlockFormSubmitWithSection)
Implication: Phase B1 is simpler than expected - no child components need updating to use React Context.
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
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
BlockOperationstype
Target: Reduce BlockEditor.tsx from ~1076 lines (after Plan A) to ~200-300 lines.
Each phase in Plan B has higher risk than Plan A. Follow these rules:
- One extraction per commit - never combine multiple extractions
- Full test suite after each extraction - typecheck, unit tests, e2e
- If e2e fails, STOP - do not attempt to fix inline
-
Document failures - create
block-editor-refactor-issues.mdwith analysis - Rollback point - each checkpoint is a safe revert target
-
Create backup branch before B3 - Phase B3 is highest risk; create
blockeditor-refactor-pre-b3branch after B2 succeeds
Run full verification before starting Plan B:
npm run typecheck
npm run lint
npm run test:ci
npm run e2eAll must pass. If any fail, fix issues before proceeding.
Checkpoint: No code changes, just verification
The window globals (__blockEditorSectionContext, __blockEditorConditionalContext) need to be replaced with proper React state before extracting useBlockFormState, which will become a consumer of this context.
Based on A1.1 investigation, there are NO external child component consumers of these globals. This means:
- ✅ Only
BlockEditor.tsxitself 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:
-
useBlockFormState(B2) will become a consumer - It future-proofs for potential child component needs
- It's cleaner than window globals
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;
}- Wrap the component's render in
BlockEditorContextProvider - Replace window global writes with context setters
- 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
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
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
useBlockEditorContextfrom B1
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);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,
};
}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:
handleSplitToBlockshandleConvertTypehandleSwitchBlockType
Update them to use formState.editingNestedBlock, formState.editingConditionalBranchBlock, etc.
Checkpoint: git commit -m "refactor: extract useBlockFormState hook"
Verification: Full test suite
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"
This is the highest-risk extraction because:
- Circular callback dependencies (
handleStopRecordingcallshandleSectionRecordorhandleConditionalBranchRecord) - Timing-sensitive ref (
pendingSectionIdRefwithsetTimeout) - Depends on external hooks (
actionRecorder,editor,recordingPersistence) - Complex state transitions
The original design creates a circular dependency:
-
useRecordingSessionneedsrecordingPersistence.clear -
useRecordingPersistenceneeds recording state to save/restore
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 │
└─────────────────────────────────────────────────────────────────┘
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
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
// 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
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"
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.
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
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
File: src/components/block-editor/BlockEditorModals.tsx
Extract all modal components into a single wrapper.
Modals to include:
-
GuideMetadataForm(modal) -
ConfirmModal(new guide confirmation) ImportGuideModalGitHubPRModalBlockEditorTourBlockFormModal
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
The main component should now:
- Import and use all hooks from Plan A and B
- Render all extracted components
- Wire up props using
BlockOperationstype - 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
- 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.tsxis ~200-300 lines (reduced from ~1076) - All checkpoints committed
- Backup branch
blockeditor-refactor-pre-b3exists (created before B3)
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)
- STOP - do not attempt inline fixes
- Create
block-editor-refactor-issues.mdin project root - Document:
- Which phase failed
- Full error output from
npm run e2e - Which test(s) failed
- Analysis of what likely went wrong
- Screenshots if relevant
- Consider reverting to last passing checkpoint
- Re-analyze the failing phase before retrying
| 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 |
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:
-
useRecordingState- pure state, no dependencies -
useRecordingPersistence- reads state, provides clear callback -
useRecordingActions- receives state and clear callback
This breaks the cycle by ensuring each layer only depends on the previous one.
After B3, manually verify these scenarios work:
- Create new section block → Start recording → Perform actions → Stop recording → Verify blocks added
- Refresh page during recording → Verify recording resumes with persisted state
- Create conditional block → Record into whenTrue branch → Verify blocks added to correct branch
- 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
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
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
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
Same rules as Plan B:
- One extraction per commit - never combine multiple extractions
- Full test suite after each extraction - typecheck, lint, prettier, unit tests, AND e2e tests
- If tests fail, STOP - do not attempt to fix inline
-
Document failures - create
block-editor-refactor-issues.mdwith analysis - Rollback point - each checkpoint is a safe revert target
- Create backup branch before C2 - Phase C2 modifies BlockList which is high-risk
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 e2eImportant: 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.
Run full verification before starting:
npm run prettier
npm run typecheck
npm run lint
npm run test:ci
npm run e2eAll must pass. If any fail, fix issues before proceeding.
Checkpoint: No code changes, just verification
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 typecheckThis 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 branchFile: 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;
}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 unchangedFile: 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
/>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).
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 e2eFile: 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,
]
);Replace the BlockList render (lines 678-708) with:
<BlockList blocks={state.blocks} operations={blockOperations} />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 e2eFile: 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';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 e2eFile: 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';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 e2eFile: 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';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- 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.tsxreduced from ~827 to ~400-500 lines - All checkpoints committed
- Backup branch
blockeditor-refactor-pre-c2exists
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)
└── ...
This is the highest-risk phase. If tests fail:
- STOP - do not attempt inline fixes
- Check the git diff carefully for typos in prop names
- Verify all destructured names match the interface exactly
- Check that optional props are handled (null checks)
- If unfixable, revert to
blockeditor-refactor-pre-c2branch
| 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 |