Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/whole-buses-repair.md
Original file line number Diff line number Diff line change
@@ -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`.
12 changes: 10 additions & 2 deletions packages/admin/src/components/PortableTextEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[] }>;

Expand All @@ -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));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correctness check: this matches the core converter's behavior exactly. Core does it through convertListItemconvertListItemNestedconvertListItem (one extra hop because convertListItemNested separately iterates the nested list's listItem children); collapsing those hops into a single recursive convertList here is fine because this function already iterates items expecting them to be the list's .content (i.e. a sequence of listItem nodes), which is what child.content is for a nested bulletList/orderedList. Same output, fewer functions. 👍

}
}
}
Expand Down
182 changes: 182 additions & 0 deletions packages/admin/tests/components/PortableTextEditor.list.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
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<string, unknown>)
);
}

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: [
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good case — the mixed bullet+number nest is the one that proves level and listItem are tracked independently (a bug where the nested recursion reused the parent's listItem type would still pass the all-bullet tests).

{
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"],
]);
});
});
Loading