fix: repair malformed nested editor blocks
This commit is contained in:
@@ -500,11 +500,12 @@ describe('SingleEditorView', () => {
|
||||
expect(editor.replaceBlocks.mock.calls[0][1]).toEqual([
|
||||
expect.objectContaining({
|
||||
id: expect.any(String),
|
||||
children: [
|
||||
expect.objectContaining({
|
||||
id: expect.any(String),
|
||||
}),
|
||||
],
|
||||
children: [],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: expect.any(String),
|
||||
content: [{ type: 'text', text: 'Recovered child', styles: {} }],
|
||||
children: [],
|
||||
}),
|
||||
])
|
||||
} finally {
|
||||
|
||||
@@ -15,7 +15,7 @@ function block(type: string, text: string, children: unknown[] = []) {
|
||||
}
|
||||
|
||||
describe('repairMalformedEditorBlocks', () => {
|
||||
it('promotes numbered-list children out of paragraph blocks', () => {
|
||||
it('promotes all children out of paragraph blocks', () => {
|
||||
const nestedParagraph = block('paragraph', 'Nested paragraph')
|
||||
const nestedList = block('numberedListItem', 'Step one', [
|
||||
block('numberedListItem', 'Nested step'),
|
||||
@@ -26,8 +26,9 @@ describe('repairMalformedEditorBlocks', () => {
|
||||
expect(repairMalformedEditorBlocks([paragraph, tail])).toEqual([
|
||||
{
|
||||
...paragraph,
|
||||
children: [nestedParagraph],
|
||||
children: [],
|
||||
},
|
||||
nestedParagraph,
|
||||
nestedList,
|
||||
tail,
|
||||
])
|
||||
@@ -39,4 +40,11 @@ describe('repairMalformedEditorBlocks', () => {
|
||||
|
||||
expect(repairMalformedEditorBlocks([parent])).toEqual([parent])
|
||||
})
|
||||
|
||||
it('keeps nested toggle-list children under toggle-list items', () => {
|
||||
const nested = block('paragraph', 'Toggle detail')
|
||||
const parent = block('toggleListItem', 'Toggle summary', [nested])
|
||||
|
||||
expect(repairMalformedEditorBlocks([parent])).toEqual([parent])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
let fallbackBlockIdSequence = 0
|
||||
const LIST_ITEM_TYPES = new Set(['bulletListItem', 'numberedListItem', 'checkListItem'])
|
||||
const NESTABLE_LIST_ITEM_TYPES = new Set([
|
||||
'bulletListItem',
|
||||
'numberedListItem',
|
||||
'checkListItem',
|
||||
'toggleListItem',
|
||||
])
|
||||
|
||||
type RepairResult = {
|
||||
blocks: unknown[]
|
||||
@@ -25,8 +30,8 @@ function isEditorBlockRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isListItemBlock(block: Record<string, unknown>): boolean {
|
||||
return typeof block.type === 'string' && LIST_ITEM_TYPES.has(block.type)
|
||||
function canNestChildBlocks(block: Record<string, unknown>): boolean {
|
||||
return typeof block.type === 'string' && NESTABLE_LIST_ITEM_TYPES.has(block.type)
|
||||
}
|
||||
|
||||
function hasUsableBlockId(block: Record<string, unknown>): boolean {
|
||||
@@ -46,20 +51,11 @@ function splitChildrenForBlock(
|
||||
block: Record<string, unknown>,
|
||||
children: unknown[],
|
||||
): { safeChildren: unknown[], promotedChildren: unknown[] } {
|
||||
if (isListItemBlock(block)) {
|
||||
if (canNestChildBlocks(block)) {
|
||||
return { safeChildren: children, promotedChildren: [] }
|
||||
}
|
||||
|
||||
const safeChildren: unknown[] = []
|
||||
const promotedChildren: unknown[] = []
|
||||
for (const child of children) {
|
||||
if (isEditorBlockRecord(child) && isListItemBlock(child)) {
|
||||
promotedChildren.push(child)
|
||||
} else {
|
||||
safeChildren.push(child)
|
||||
}
|
||||
}
|
||||
return { safeChildren, promotedChildren }
|
||||
return { safeChildren: [], promotedChildren: children }
|
||||
}
|
||||
|
||||
function repairBlockList(blocks: unknown[]): RepairResult {
|
||||
|
||||
@@ -558,13 +558,14 @@ describe('useEditorTabSwap raw mode sync', () => {
|
||||
expect.objectContaining({ id: expect.any(String), type: 'paragraph', content: [], children: [] }),
|
||||
expect.objectContaining({
|
||||
id: expect.any(String),
|
||||
children: [
|
||||
expect.objectContaining({ id: expect.any(String), type: 'paragraph', content: [], children: [] }),
|
||||
expect.objectContaining({
|
||||
id: expect.any(String),
|
||||
content: [{ type: 'text', text: 'Child', styles: {} }],
|
||||
}),
|
||||
],
|
||||
content: [{ type: 'text', text: 'Parent', styles: {} }],
|
||||
children: [],
|
||||
}),
|
||||
expect.objectContaining({ id: expect.any(String), type: 'paragraph', content: [], children: [] }),
|
||||
expect.objectContaining({
|
||||
id: expect.any(String),
|
||||
content: [{ type: 'text', text: 'Child', styles: {} }],
|
||||
children: [],
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { test, expect, type Locator, type Page } from '@playwright/test'
|
||||
import { createFixtureVaultCopy, openFixtureVault, removeFixtureVaultCopy } from '../helpers/fixtureVault'
|
||||
import { executeCommand, openCommandPalette } from './helpers'
|
||||
|
||||
@@ -51,6 +51,24 @@ Procedures are long-running processes tied to a [[responsibility|Responsibility]
|
||||
Plain follow-up paragraph after the procedure checklist.
|
||||
`
|
||||
|
||||
const NESTED_PROCEDURE_NOTE_CONTENT = `---
|
||||
title: Note B
|
||||
type: Procedure
|
||||
status: Active
|
||||
---
|
||||
|
||||
# Note B
|
||||
|
||||
Procedures are long-running processes tied to a responsibility.
|
||||
|
||||
- Prepare release
|
||||
1. Validate nested numbered content
|
||||
2. Notify the owner
|
||||
- Record outcome
|
||||
|
||||
Plain follow-up paragraph after the nested procedure checklist.
|
||||
`
|
||||
|
||||
let tempVaultDir: string
|
||||
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
@@ -141,6 +159,99 @@ function expectNoBlockContainerCrash(messages: string[]) {
|
||||
))).toEqual([])
|
||||
}
|
||||
|
||||
async function loadRawContentInRichEditor(page: Page, content: string) {
|
||||
await openNote(page, 'Note B')
|
||||
await toggleRawMode(page, '.cm-content')
|
||||
await setRawEditorContent(page, content)
|
||||
await page.waitForTimeout(700)
|
||||
await toggleRawMode(page, '.bn-editor')
|
||||
}
|
||||
|
||||
async function appendMarkerToBlock(page: Page, block: Locator, marker: string) {
|
||||
await expect(block).toBeVisible({ timeout: 5_000 })
|
||||
await block.click()
|
||||
await page.keyboard.press('End')
|
||||
await page.keyboard.insertText(` ${marker}`)
|
||||
await page.waitForTimeout(900)
|
||||
}
|
||||
|
||||
async function reopenNoteB(page: Page) {
|
||||
await openNote(page, 'Note C')
|
||||
await openNote(page, 'Note B')
|
||||
}
|
||||
|
||||
async function expectListContentEditableAfterReopen(page: Page, options: {
|
||||
content: string
|
||||
markerPrefix: string
|
||||
primary: {
|
||||
selector: string
|
||||
text: string
|
||||
}
|
||||
secondary: {
|
||||
selector: string
|
||||
text: string
|
||||
}
|
||||
}) {
|
||||
const crashSignals = collectEditorCrashSignals(page)
|
||||
const markerId = Date.now()
|
||||
const primaryMarker = `${options.markerPrefix}-primary-${markerId}`
|
||||
const secondaryMarker = `${options.markerPrefix}-secondary-${markerId}`
|
||||
const reentryMarker = `${options.markerPrefix}-reentry-${markerId}`
|
||||
|
||||
await loadRawContentInRichEditor(page, options.content)
|
||||
|
||||
const primaryBlock = page.locator(options.primary.selector, {
|
||||
hasText: options.primary.text,
|
||||
}).first()
|
||||
await appendMarkerToBlock(page, primaryBlock, primaryMarker)
|
||||
|
||||
const secondaryBlock = page.locator(options.secondary.selector, {
|
||||
hasText: options.secondary.text,
|
||||
}).first()
|
||||
await appendMarkerToBlock(page, secondaryBlock, secondaryMarker)
|
||||
|
||||
await reopenNoteB(page)
|
||||
await appendMarkerToBlock(page, primaryBlock, reentryMarker)
|
||||
|
||||
expectNoBlockContainerCrash(crashSignals)
|
||||
|
||||
await toggleRawMode(page, '.cm-content')
|
||||
const raw = await getRawEditorContent(page)
|
||||
expect(raw).toContain(primaryMarker)
|
||||
expect(raw).toContain(secondaryMarker)
|
||||
expect(raw).toContain(reentryMarker)
|
||||
expectNoBlockContainerCrash(crashSignals)
|
||||
}
|
||||
|
||||
const listReopenScenarios = [
|
||||
{
|
||||
name: 'numbered list content stays editable after autosave and re-entry',
|
||||
content: NUMBERED_REENTRY_CONTENT,
|
||||
markerPrefix: 'numbered',
|
||||
primary: {
|
||||
selector: '.bn-block-content[data-content-type="numberedListItem"]',
|
||||
text: 'First numbered item',
|
||||
},
|
||||
secondary: {
|
||||
selector: '.bn-block-content',
|
||||
text: 'Plain paragraph after the numbered list.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'nested procedure list content stays editable after save and reopen',
|
||||
content: NESTED_PROCEDURE_NOTE_CONTENT,
|
||||
markerPrefix: 'nested',
|
||||
primary: {
|
||||
selector: '.bn-block-content[data-content-type="numberedListItem"]',
|
||||
text: 'Validate nested numbered content',
|
||||
},
|
||||
secondary: {
|
||||
selector: '.bn-block-content[data-content-type="bulletListItem"]',
|
||||
text: 'Record outcome',
|
||||
},
|
||||
},
|
||||
] as const
|
||||
|
||||
test('mixed rich-text blocks with Korean list content stay editable after action clicks', async ({ page }) => {
|
||||
const crashSignals = collectEditorCrashSignals(page)
|
||||
|
||||
@@ -174,52 +285,11 @@ test('mixed rich-text blocks with Korean list content stay editable after action
|
||||
expectNoBlockContainerCrash(crashSignals)
|
||||
})
|
||||
|
||||
test('numbered list content stays editable after autosave and re-entry', async ({ page }) => {
|
||||
const crashSignals = collectEditorCrashSignals(page)
|
||||
const markerId = Date.now()
|
||||
const listMarker = `list-token-${markerId}`
|
||||
const paragraphMarker = `paragraph-token-${markerId}`
|
||||
const reentryMarker = `reentry-token-${markerId}`
|
||||
|
||||
await openNote(page, 'Note B')
|
||||
await toggleRawMode(page, '.cm-content')
|
||||
await setRawEditorContent(page, NUMBERED_REENTRY_CONTENT)
|
||||
await page.waitForTimeout(700)
|
||||
await toggleRawMode(page, '.bn-editor')
|
||||
|
||||
const numberedItem = page.locator('.bn-block-content[data-content-type="numberedListItem"]', {
|
||||
hasText: 'First numbered item',
|
||||
}).first()
|
||||
await expect(numberedItem).toBeVisible({ timeout: 5_000 })
|
||||
await numberedItem.click()
|
||||
await page.keyboard.press('End')
|
||||
await page.keyboard.insertText(` ${listMarker}`)
|
||||
await page.waitForTimeout(900)
|
||||
|
||||
const paragraph = page.locator('.bn-block-content', {
|
||||
hasText: 'Plain paragraph after the numbered list.',
|
||||
}).first()
|
||||
await paragraph.click()
|
||||
await page.keyboard.press('End')
|
||||
await page.keyboard.insertText(` ${paragraphMarker}`)
|
||||
await page.waitForTimeout(900)
|
||||
|
||||
await openNote(page, 'Note C')
|
||||
await openNote(page, 'Note B')
|
||||
await numberedItem.click()
|
||||
await page.keyboard.press('End')
|
||||
await page.keyboard.insertText(` ${reentryMarker}`)
|
||||
await page.waitForTimeout(700)
|
||||
|
||||
expectNoBlockContainerCrash(crashSignals)
|
||||
|
||||
await toggleRawMode(page, '.cm-content')
|
||||
const raw = await getRawEditorContent(page)
|
||||
expect(raw).toContain(listMarker)
|
||||
expect(raw).toContain(paragraphMarker)
|
||||
expect(raw).toContain(reentryMarker)
|
||||
expectNoBlockContainerCrash(crashSignals)
|
||||
})
|
||||
for (const scenario of listReopenScenarios) {
|
||||
test(scenario.name, async ({ page }) => {
|
||||
await expectListContentEditableAfterReopen(page, scenario)
|
||||
})
|
||||
}
|
||||
|
||||
test('Procedure prose and adjacent bullet-list content stay editable through keydown edits', async ({ page }) => {
|
||||
const crashSignals = collectEditorCrashSignals(page)
|
||||
|
||||
Reference in New Issue
Block a user