From 4aa237755626065392bd4dbc5bb2f4b0b7d5e660 Mon Sep 17 00:00:00 2001 From: orange Date: Fri, 22 May 2026 19:38:53 +0800 Subject: [PATCH 1/6] fix(admin): emit nested-list level in prosemirrorToPortableText MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PortableTextEditor`'s `convertList` walks `listItem` content but skips any nested `bulletList`/`orderedList` siblings of the inner paragraph, and hardcodes `level: 1` on every emitted block. As a result, Tab- indenting a bullet in the editor (which produces a valid nested ProseMirror tree) saves a flat list where every item has `level: 1`. `packages/core/src/content/converters/prosemirror-to-portable-text.ts` already has the correct recursive behavior — this change mirrors it in the admin's local copy so the editor's onChange output matches. Adds a focused unit test covering single-level, 2-level nest, mixed bullet+number nesting, and 3-level deep nesting. --- .../src/components/PortableTextEditor.tsx | 12 +- .../PortableTextEditor.list.test.ts | 186 ++++++++++++++++++ 2 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 packages/admin/tests/components/PortableTextEditor.list.test.ts diff --git a/packages/admin/src/components/PortableTextEditor.tsx b/packages/admin/src/components/PortableTextEditor.tsx index 35db81913..2f1b198c5 100644 --- a/packages/admin/src/components/PortableTextEditor.tsx +++ b/packages/admin/src/components/PortableTextEditor.tsx @@ -380,7 +380,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[] }>; @@ -399,11 +403,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)); } } } 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..524b79821 --- /dev/null +++ b/packages/admin/tests/components/PortableTextEditor.list.test.ts @@ -0,0 +1,186 @@ +import { describe, it, expect } from "vitest"; + +import { _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"], + ]); + }); +}); From 50f853836aec630dc4f7e15d4388d5db628c6b6c Mon Sep 17 00:00:00 2001 From: "emdashbot[bot]" Date: Fri, 22 May 2026 11:39:59 +0000 Subject: [PATCH 2/6] style: format --- .../tests/components/PortableTextEditor.list.test.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/admin/tests/components/PortableTextEditor.list.test.ts b/packages/admin/tests/components/PortableTextEditor.list.test.ts index 524b79821..e7d7c45b1 100644 --- a/packages/admin/tests/components/PortableTextEditor.list.test.ts +++ b/packages/admin/tests/components/PortableTextEditor.list.test.ts @@ -29,15 +29,11 @@ describe("ProseMirror → PortableText: nested list level", () => { content: [ { type: "listItem", - content: [ - { type: "paragraph", content: [{ type: "text", text: "Item one" }] }, - ], + content: [{ type: "paragraph", content: [{ type: "text", text: "Item one" }] }], }, { type: "listItem", - content: [ - { type: "paragraph", content: [{ type: "text", text: "Item two" }] }, - ], + content: [{ type: "paragraph", content: [{ type: "text", text: "Item two" }] }], }, ], }, From af6d6e35a063329c32c82ad76861ae05004bff6a Mon Sep 17 00:00:00 2001 From: orange Date: Fri, 22 May 2026 19:57:41 +0800 Subject: [PATCH 3/6] chore: add changeset --- .changeset/whole-buses-repair.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/whole-buses-repair.md 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`. From e6ccb5b98641cdd4aeb7408cbfb2bee51bc912b7 Mon Sep 17 00:00:00 2001 From: orange Date: Mon, 25 May 2026 10:36:57 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix(admin):=20preserve=20nested=20list=20le?= =?UTF-8?q?vel=20on=20PT=20=E2=86=92=20ProseMirror?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the forward-direction fix: `convertPTList` used to flatten every list item into a single sibling array regardless of `block.level`, and `portableTextToProsemirror`'s run-grouping broke on `listItem`-type changes — so a tree like [bullet L1, number L2, bullet L1] came back into the editor as three separate top-level lists instead of one bullet list with a numbered sub-list under the first item. That made nested-list round-trips drop their hierarchy as soon as the user re-opened the document. This change: - Rewrites `convertPTList` to walk root items (`level === 1`) and group trailing `level > 1` blocks as their nested subtree, then recurse with level decremented — same shape as `@emdash-cms/core`'s `portable-text-to-prosemirror.convertList`. - Extends the outer run-grouping in `portableTextToProsemirror` to fold `level > 1` blocks into the current run regardless of their `listItem` type, so a numbered child stays nested inside a bullet parent. - Adds focused tests for PT → PM 2-level nesting, mixed-type nesting, the regression around level=2 type switches, 3-level deep nesting, and a PT → PM → PT round-trip. --- .../src/components/PortableTextEditor.tsx | 94 +++++++++-- .../PortableTextEditor.list.test.ts | 152 +++++++++++++++++- 2 files changed, 231 insertions(+), 15 deletions(-) diff --git a/packages/admin/src/components/PortableTextEditor.tsx b/packages/admin/src/components/PortableTextEditor.tsx index d6a5581eb..a2689e73b 100644 --- a/packages/admin/src/components/PortableTextEditor.tsx +++ b/packages/admin/src/components/PortableTextEditor.tsx @@ -550,9 +550,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 { @@ -741,22 +746,83 @@ 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) { + // A single listItem can contain multiple consecutive nested sub-lists if + // the type alternates (e.g. bullet → number → bullet). Group adjacent + // nested items by listType, then recurse with level decremented so each + // sub-list sees its own root level as 1. + let j = 0; + while (j < nestedItems.length) { + const nestedListType = nestedItems[j]!.listItem || parentListType; + const nestedGroup: PortableTextTextBlock[] = []; + + while ( + j < nestedItems.length && + (nestedItems[j]!.listItem || parentListType) === nestedListType + ) { + nestedGroup.push(nestedItems[j]!); + j++; + } + + if (nestedGroup.length > 0) { + const adjustedGroup = nestedGroup.map((ni) => ({ + ...ni, + level: (ni.level || 2) - 1, + })); + content.push(convertPTList(adjustedGroup, nestedListType)); + } + } + } + + return { + type: "listItem", + content, }; } diff --git a/packages/admin/tests/components/PortableTextEditor.list.test.ts b/packages/admin/tests/components/PortableTextEditor.list.test.ts index e7d7c45b1..9f144ec87 100644 --- a/packages/admin/tests/components/PortableTextEditor.list.test.ts +++ b/packages/admin/tests/components/PortableTextEditor.list.test.ts @@ -1,6 +1,9 @@ import { describe, it, expect } from "vitest"; -import { _prosemirrorToPortableText } from "../../src/components/PortableTextEditor"; +import { + _portableTextToProsemirror, + _prosemirrorToPortableText, +} from "../../src/components/PortableTextEditor"; type ListBlock = { _type: "block"; @@ -180,3 +183,150 @@ describe("ProseMirror → PortableText: nested list level", () => { ]); }); }); + +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"); + }); +}); + +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"], + ]); + }); +}); From 8a3182b8323185a69a030116af4d56f7b2138f83 Mon Sep 17 00:00:00 2001 From: orange Date: Mon, 25 May 2026 13:17:39 +0800 Subject: [PATCH 5/6] fix(admin): keep convertPTListItem grouping aware of nested item levels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the core companion fix. `convertPTListItem`'s nested-group loop broke the run on any `listItem` type change at any depth, not just at the group's root level. For a mixed-type 3-level tree like bullet L1 → number L2 → bullet L3 → number L2 it emitted the level-3 bullet as a sibling sub-list under the level-1 item (between two level-2 ordered sub-lists) instead of nesting it under the matching level-2 number item. Round-tripping that PM tree back to portable text then degraded the L3 block to L2, permanently shrinking the hierarchy. Fix: track the shallowest level in `nestedItems` as the group's effective root. New groups only start at that root level when `listItem` switches; blocks at deeper levels fold into the current group as descendants regardless of their own type. The recursion adjusts levels (`level - 1`) so each recursive call sees its own root as level 1. Adds a focused regression test asserting both the PM tree shape and the PT round-trip identity for the 4-block mixed-type case. --- .../src/components/PortableTextEditor.tsx | 35 +++++++++++----- .../PortableTextEditor.list.test.ts | 42 +++++++++++++++++++ 2 files changed, 66 insertions(+), 11 deletions(-) diff --git a/packages/admin/src/components/PortableTextEditor.tsx b/packages/admin/src/components/PortableTextEditor.tsx index a2689e73b..65546ee6e 100644 --- a/packages/admin/src/components/PortableTextEditor.tsx +++ b/packages/admin/src/components/PortableTextEditor.tsx @@ -793,29 +793,42 @@ function convertPTListItem( }); if (nestedItems.length > 0) { - // A single listItem can contain multiple consecutive nested sub-lists if - // the type alternates (e.g. bullet → number → bullet). Group adjacent - // nested items by listType, then recurse with level decremented so each - // sub-list sees its own root level as 1. + // 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 nestedListType = nestedItems[j]!.listItem || parentListType; + const anchorType: "bullet" | "number" = + nestedItems[j]!.listItem || parentListType; const nestedGroup: PortableTextTextBlock[] = []; - while ( - j < nestedItems.length && - (nestedItems[j]!.listItem || parentListType) === nestedListType - ) { + 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, nestedListType)); + content.push(convertPTList(adjustedGroup, anchorType)); } } } diff --git a/packages/admin/tests/components/PortableTextEditor.list.test.ts b/packages/admin/tests/components/PortableTextEditor.list.test.ts index 9f144ec87..ccb7b78e9 100644 --- a/packages/admin/tests/components/PortableTextEditor.list.test.ts +++ b/packages/admin/tests/components/PortableTextEditor.list.test.ts @@ -309,6 +309,48 @@ describe("PortableText → ProseMirror: nested list level", () => { 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", () => { From fd247d5f757664685c0b2cd4db2ceaede8e9684e Mon Sep 17 00:00:00 2001 From: "emdashbot[bot]" Date: Mon, 25 May 2026 05:18:16 +0000 Subject: [PATCH 6/6] style: format --- packages/admin/src/components/PortableTextEditor.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/admin/src/components/PortableTextEditor.tsx b/packages/admin/src/components/PortableTextEditor.tsx index 65546ee6e..6284924db 100644 --- a/packages/admin/src/components/PortableTextEditor.tsx +++ b/packages/admin/src/components/PortableTextEditor.tsx @@ -810,8 +810,7 @@ function convertPTListItem( let j = 0; while (j < nestedItems.length) { - const anchorType: "bullet" | "number" = - nestedItems[j]!.listItem || parentListType; + const anchorType: "bullet" | "number" = nestedItems[j]!.listItem || parentListType; const nestedGroup: PortableTextTextBlock[] = []; do {