diff --git a/.changeset/whole-buses-repair.md b/.changeset/whole-buses-repair.md new file mode 100644 index 000000000..fa9ff0967 --- /dev/null +++ b/.changeset/whole-buses-repair.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/admin": patch +--- + +Fixes nested-list serialization in the Portable Text editor. `convertList` now recurses into nested `bulletList`/`orderedList` children and emits each block with the correct `level` value, so Tab-indented list items in the editor round-trip through `onChange` as real nested portable-text blocks instead of being flattened to a single top-level list with every item at `level: 1`. diff --git a/packages/admin/src/components/PortableTextEditor.tsx b/packages/admin/src/components/PortableTextEditor.tsx index 3d6cbe678..b47c4d87c 100644 --- a/packages/admin/src/components/PortableTextEditor.tsx +++ b/packages/admin/src/components/PortableTextEditor.tsx @@ -381,7 +381,11 @@ function convertPMNode(node: { } } -function convertList(items: unknown[], listItem: "bullet" | "number"): PortableTextTextBlock[] { +function convertList( + items: unknown[], + listItem: "bullet" | "number", + level = 1, +): PortableTextTextBlock[] { const blocks: PortableTextTextBlock[] = []; const typedItems = items as Array<{ type: string; content?: unknown[] }>; @@ -400,11 +404,15 @@ function convertList(items: unknown[], listItem: "bullet" | "number"): PortableT _key: generateKey(), style: "normal", listItem, - level: 1, + level, children, markDefs: markDefs.length > 0 ? markDefs : undefined, }); } + } else if (child.type === "bulletList") { + blocks.push(...convertList(child.content || [], "bullet", level + 1)); + } else if (child.type === "orderedList") { + blocks.push(...convertList(child.content || [], "number", level + 1)); } } } @@ -543,9 +551,14 @@ function portableTextToProsemirror(blocks: PortableTextBlock[]): { const listBlocks: PortableTextTextBlock[] = []; const listType = block.listItem; + // A list "run" is a level=1 anchor block plus everything that nests + // under it (level > 1) or repeats it at the same root level/type. + // A level=1 block with a different listItem ends the run. while (i < blocks.length) { const current = blocks[i]!; - if (isTextBlock(current) && current.listItem === listType) { + if (!isTextBlock(current) || !current.listItem) break; + const level = current.level || 1; + if (level > 1 || current.listItem === listType) { listBlocks.push(current); i++; } else { @@ -734,22 +747,95 @@ function convertPTBlock(block: PortableTextBlock): unknown { } function convertPTList(items: PortableTextTextBlock[], listType: "bullet" | "number"): unknown { - const listItems = items.map((item) => { - const pmContent = convertPTSpans(item.children, item.markDefs || []); - return { - type: "listItem", - content: [ - { - type: "paragraph", - content: pmContent.length > 0 ? pmContent : undefined, - }, - ], - }; - }); + // Group items into root-level items (level === 1) and their nested + // descendants (level > 1). For each root item, all subsequent items with + // level > 1 belong to its nested subtree — recurse on them with level + // decremented so the inner pass sees them as its own root level. + const rootItems: unknown[] = []; + let i = 0; + + while (i < items.length) { + const item = items[i]!; + const level = item.level || 1; + + if (level === 1) { + const nestedItems: PortableTextTextBlock[] = []; + i++; + while (i < items.length && (items[i]!.level || 1) > 1) { + nestedItems.push(items[i]!); + i++; + } + rootItems.push(convertPTListItem(item, nestedItems, listType)); + } else { + // Orphan nested item with no preceding level=1 anchor — treat as root + // so we don't drop content. + rootItems.push(convertPTListItem(item, [], listType)); + i++; + } + } return { type: listType === "bullet" ? "bulletList" : "orderedList", - content: listItems, + content: rootItems, + }; +} + +function convertPTListItem( + item: PortableTextTextBlock, + nestedItems: PortableTextTextBlock[], + parentListType: "bullet" | "number", +): unknown { + const content: unknown[] = []; + + const pmContent = convertPTSpans(item.children, item.markDefs || []); + content.push({ + type: "paragraph", + content: pmContent.length > 0 ? pmContent : undefined, + }); + + if (nestedItems.length > 0) { + // The shallowest level in `nestedItems` is the effective root of this + // item's nested subtree. A new sub-list only starts when we hit + // another block at that root level with a different `listItem` type; + // deeper blocks (level > minLevel) belong to the current group as + // descendants regardless of their own `listItem`. The previous + // grouping broke on any type change at any depth, so a deep mixed + // tree like `bullet L1 → number L2 → bullet L3 → number L2` would + // emit C(L3) as a sibling list under A(L1) instead of nesting it + // under B(L2), then degrade C to L2 on round-trip. + let minLevel = Infinity; + for (const ni of nestedItems) { + const level = ni.level || 2; + if (level < minLevel) minLevel = level; + } + + let j = 0; + while (j < nestedItems.length) { + const anchorType: "bullet" | "number" = nestedItems[j]!.listItem || parentListType; + const nestedGroup: PortableTextTextBlock[] = []; + + do { + nestedGroup.push(nestedItems[j]!); + j++; + } while ( + j < nestedItems.length && + ((nestedItems[j]!.level || 2) > minLevel || + (nestedItems[j]!.listItem || parentListType) === anchorType) + ); + + if (nestedGroup.length > 0) { + const adjustedGroup = nestedGroup.map((ni) => ({ + ...ni, + level: (ni.level || 2) - 1, + })); + content.push(convertPTList(adjustedGroup, anchorType)); + } + } + } + + return { + type: "listItem", + content, }; } diff --git a/packages/admin/tests/components/PortableTextEditor.list.test.ts b/packages/admin/tests/components/PortableTextEditor.list.test.ts new file mode 100644 index 000000000..ccb7b78e9 --- /dev/null +++ b/packages/admin/tests/components/PortableTextEditor.list.test.ts @@ -0,0 +1,374 @@ +import { describe, it, expect } from "vitest"; + +import { + _portableTextToProsemirror, + _prosemirrorToPortableText, +} from "../../src/components/PortableTextEditor"; + +type ListBlock = { + _type: "block"; + style: "normal"; + listItem: "bullet" | "number"; + level: number; + children: Array<{ _type: "span"; text: string }>; +}; + +function isListBlock(b: unknown): b is ListBlock { + return ( + typeof b === "object" && + b !== null && + (b as { _type?: unknown })._type === "block" && + "listItem" in (b as Record) + ); +} + +describe("ProseMirror → PortableText: nested list level", () => { + it("emits level=1 for a single-level bullet list", () => { + const pmDoc = { + type: "doc", + content: [ + { + type: "bulletList", + content: [ + { + type: "listItem", + content: [{ type: "paragraph", content: [{ type: "text", text: "Item one" }] }], + }, + { + type: "listItem", + content: [{ type: "paragraph", content: [{ type: "text", text: "Item two" }] }], + }, + ], + }, + ], + }; + + const result = _prosemirrorToPortableText(pmDoc).filter(isListBlock); + + expect(result.map((b) => [b.listItem, b.level, b.children[0]?.text])).toEqual([ + ["bullet", 1, "Item one"], + ["bullet", 1, "Item two"], + ]); + }); + + it("emits level=2 for bullets nested inside a parent bullet", () => { + const pmDoc = { + type: "doc", + content: [ + { + type: "bulletList", + content: [ + { + type: "listItem", + content: [ + { type: "paragraph", content: [{ type: "text", text: "Parent" }] }, + { + type: "bulletList", + content: [ + { + type: "listItem", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Child" }], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }; + + const result = _prosemirrorToPortableText(pmDoc).filter(isListBlock); + + expect(result.map((b) => [b.listItem, b.level, b.children[0]?.text])).toEqual([ + ["bullet", 1, "Parent"], + ["bullet", 2, "Child"], + ]); + }); + + it("preserves listItem type when an ordered list nests inside a bullet", () => { + const pmDoc = { + type: "doc", + content: [ + { + type: "bulletList", + content: [ + { + type: "listItem", + content: [ + { type: "paragraph", content: [{ type: "text", text: "Bullet top" }] }, + { + type: "orderedList", + content: [ + { + type: "listItem", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Numbered child" }], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }; + + const result = _prosemirrorToPortableText(pmDoc).filter(isListBlock); + + expect(result.map((b) => [b.listItem, b.level, b.children[0]?.text])).toEqual([ + ["bullet", 1, "Bullet top"], + ["number", 2, "Numbered child"], + ]); + }); + + it("handles three-level nesting", () => { + const pmDoc = { + type: "doc", + content: [ + { + type: "bulletList", + content: [ + { + type: "listItem", + content: [ + { type: "paragraph", content: [{ type: "text", text: "L1" }] }, + { + type: "bulletList", + content: [ + { + type: "listItem", + content: [ + { type: "paragraph", content: [{ type: "text", text: "L2" }] }, + { + type: "bulletList", + content: [ + { + type: "listItem", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "L3" }], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }; + + const result = _prosemirrorToPortableText(pmDoc).filter(isListBlock); + + expect(result.map((b) => [b.level, b.children[0]?.text])).toEqual([ + [1, "L1"], + [2, "L2"], + [3, "L3"], + ]); + }); +}); + +type PMList = { + type: "bulletList" | "orderedList"; + content: Array<{ + type: "listItem"; + content: Array<{ type: string; content?: unknown[] }>; + }>; +}; + +function findFirstList(node: { content?: unknown[] }): PMList | null { + if (!node.content) return null; + for (const child of node.content as Array<{ type?: string }>) { + if (child.type === "bulletList" || child.type === "orderedList") return child as PMList; + } + return null; +} + +function getParagraphText(listItem: { content?: unknown[] }): string | undefined { + if (!listItem.content) return undefined; + const para = (listItem.content as Array<{ type?: string; content?: unknown[] }>).find( + (c) => c.type === "paragraph", + ); + const text = (para?.content as Array<{ type?: string; text?: string }> | undefined)?.find( + (c) => c.type === "text", + ); + return text?.text; +} + +function getNestedList(listItem: { content?: unknown[] }): PMList | undefined { + return (listItem.content as Array<{ type?: string }> | undefined)?.find( + (c) => c.type === "bulletList" || c.type === "orderedList", + ) as PMList | undefined; +} + +function pt( + listItem: "bullet" | "number", + level: number, + text: string, +): { + _type: "block"; + _key: string; + style: "normal"; + listItem: "bullet" | "number"; + level: number; + children: Array<{ _type: "span"; _key: string; text: string }>; +} { + return { + _type: "block", + _key: `b${level}-${text}`, + style: "normal", + listItem, + level, + children: [{ _type: "span", _key: `s-${text}`, text }], + }; +} + +describe("PortableText → ProseMirror: nested list level", () => { + it("nests level=2 bullets inside their parent listItem", () => { + const result = _portableTextToProsemirror([ + pt("bullet", 1, "Parent"), + pt("bullet", 2, "Child"), + ]); + const list = findFirstList(result); + expect(list).toBeTruthy(); + expect(list!.type).toBe("bulletList"); + expect(list!.content).toHaveLength(1); + expect(getParagraphText(list!.content[0]!)).toBe("Parent"); + + const nested = getNestedList(list!.content[0]!); + expect(nested?.type).toBe("bulletList"); + expect(nested?.content).toHaveLength(1); + expect(getParagraphText(nested!.content[0]!)).toBe("Child"); + }); + + it("preserves listType when an ordered list nests inside a bullet", () => { + const result = _portableTextToProsemirror([ + pt("bullet", 1, "Bullet top"), + pt("number", 2, "Numbered child"), + ]); + const outer = findFirstList(result); + expect(outer?.type).toBe("bulletList"); + expect(outer?.content).toHaveLength(1); + + const inner = getNestedList(outer!.content[0]!); + expect(inner?.type).toBe("orderedList"); + expect(inner?.content).toHaveLength(1); + expect(getParagraphText(inner!.content[0]!)).toBe("Numbered child"); + }); + + it("does not flatten level=2 siblings into a top-level number list", () => { + // Regression for the outer-loop run grouping: a number block at level=2 + // must be folded into its parent bullet's run, not start a new top-level + // orderedList. + const result = _portableTextToProsemirror([ + pt("bullet", 1, "Parent"), + pt("number", 2, "Numbered child"), + pt("bullet", 1, "Sibling"), + ]); + const lists = (result.content as Array<{ type?: string }>).filter( + (c) => c.type === "bulletList" || c.type === "orderedList", + ) as PMList[]; + expect(lists).toHaveLength(1); + expect(lists[0]!.type).toBe("bulletList"); + expect(lists[0]!.content).toHaveLength(2); + expect(getParagraphText(lists[0]!.content[0]!)).toBe("Parent"); + expect(getParagraphText(lists[0]!.content[1]!)).toBe("Sibling"); + expect(getNestedList(lists[0]!.content[0]!)?.type).toBe("orderedList"); + }); + + it("handles three-level nesting", () => { + const result = _portableTextToProsemirror([ + pt("bullet", 1, "L1"), + pt("bullet", 2, "L2"), + pt("bullet", 3, "L3"), + ]); + const l1 = findFirstList(result); + expect(getParagraphText(l1!.content[0]!)).toBe("L1"); + + const l2 = getNestedList(l1!.content[0]!); + expect(l2?.type).toBe("bulletList"); + expect(getParagraphText(l2!.content[0]!)).toBe("L2"); + + const l3 = getNestedList(l2!.content[0]!); + expect(l3?.type).toBe("bulletList"); + expect(getParagraphText(l3!.content[0]!)).toBe("L3"); + }); + + it("keeps deeper nesting under its true parent for mixed-type 3-level trees", () => { + // Regression for convertPTListItem's nested grouping: it used to + // break the group on every `listItem` change regardless of depth, + // so a level-3 block ended up as a sibling sub-list under the + // level-1 item instead of nesting under the matching level-2 item + // — and the round-trip would degrade level-3 to level-2. + const original = [ + pt("bullet", 1, "A"), + pt("number", 2, "B"), + pt("bullet", 3, "C"), + pt("number", 2, "D"), + ]; + const pm = _portableTextToProsemirror(original); + + const outer = findFirstList(pm); + expect(outer?.type).toBe("bulletList"); + expect(outer?.content).toHaveLength(1); + expect(getParagraphText(outer!.content[0]!)).toBe("A"); + + const numbered = getNestedList(outer!.content[0]!); + expect(numbered?.type).toBe("orderedList"); + expect(numbered?.content).toHaveLength(2); + expect(getParagraphText(numbered!.content[0]!)).toBe("B"); + expect(getParagraphText(numbered!.content[1]!)).toBe("D"); + + const cInBullets = getNestedList(numbered!.content[0]!); + expect(cInBullets?.type).toBe("bulletList"); + expect(getParagraphText(cInBullets!.content[0]!)).toBe("C"); + + // Round-trip must keep C at level 3, not collapse it to level 2. + const roundTripped = _prosemirrorToPortableText(pm).filter( + (b): b is (typeof original)[number] => + typeof b === "object" && b !== null && (b as { _type?: string })._type === "block", + ); + expect(roundTripped.map((b) => [b.listItem, b.level, b.children[0]?.text])).toEqual([ + ["bullet", 1, "A"], + ["number", 2, "B"], + ["bullet", 3, "C"], + ["number", 2, "D"], + ]); + }); +}); + +describe("Round-trip: PT → PM → PT preserves nested list level", () => { + it("keeps level and listItem for a 2-level bullet → number tree", () => { + const original = [ + pt("bullet", 1, "Top"), + pt("number", 2, "Nested"), + pt("bullet", 1, "Sibling"), + ]; + const pm = _portableTextToProsemirror(original); + const roundTripped = _prosemirrorToPortableText(pm).filter( + (b): b is (typeof original)[number] => + typeof b === "object" && b !== null && (b as { _type?: string })._type === "block", + ); + expect(roundTripped.map((b) => [b.listItem, b.level, b.children[0]?.text])).toEqual([ + ["bullet", 1, "Top"], + ["number", 2, "Nested"], + ["bullet", 1, "Sibling"], + ]); + }); +});