Skip to content

fix(admin): emit nested-list level in prosemirrorToPortableText#1142

Open
OrangeManLi wants to merge 6 commits into
emdash-cms:mainfrom
OrangeManLi:fix/admin-nested-list-level
Open

fix(admin): emit nested-list level in prosemirrorToPortableText#1142
OrangeManLi wants to merge 6 commits into
emdash-cms:mainfrom
OrangeManLi:fix/admin-nested-list-level

Conversation

@OrangeManLi
Copy link
Copy Markdown

@OrangeManLi OrangeManLi commented May 22, 2026

What does this PR do?

Fixes nested-list serialization in the admin's Portable Text editor. PortableTextEditor's local convertList (packages/admin/src/components/PortableTextEditor.tsx) walks each listItem's content but only handles paragraph children — it silently skips any sibling bulletList / orderedList, and emits every block with level: 1 regardless of nesting depth.

As a result, Tab-indenting a bullet in the editor (which produces a valid nested ProseMirror tree with bulletList → listItem → [paragraph, bulletList → listItem → paragraph]) saves portable-text where every list block has level: 1 and the nested child items are flattened — or, in the bullet-with-only-a-nested-list shape, dropped entirely.

packages/core/src/content/converters/prosemirror-to-portable-text.ts already has the correct recursive form (convertList → convertListItem → convertListItemNested) — this PR mirrors that behavior in the admin's local copy so the editor's onChange output matches.

Repro (before this fix)

ProseMirror doc:

bulletList
  listItem
    paragraph "Parent"
    bulletList
      listItem
        paragraph "Child"

Output from _prosemirrorToPortableText:

[
  { _type: 'block', listItem: 'bullet', level: 1, children: [{ text: 'Parent' }] },
  // ❌ "Child" missing entirely
]

After this fix

[
  { _type: 'block', listItem: 'bullet', level: 1, children: [{ text: 'Parent' }] },
  { _type: 'block', listItem: 'bullet', level: 2, children: [{ text: 'Child' }] },
]

Closes #

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run (auto-format workflow already pushed a style: format commit on this branch)
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable) — N/A, no user-visible strings touched
  • I have added a changeset (if this PR changes a published package) — .changeset/whole-buses-repair.md, patch bump for @emdash-cms/admin
  • New features link to an approved Discussion — N/A, this is a bug fix

The three pnpm typecheck / lint / test boxes are unchecked because the npm registry was returning intermittent fetch failures during PR prep and pnpm install couldn't complete locally. The new test in packages/admin/tests/components/PortableTextEditor.list.test.ts is small and self-contained — CI will exercise it.

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 4.7 (via Claude Code)

Screenshots / test output

The added test file (packages/admin/tests/components/PortableTextEditor.list.test.ts) covers:

  • single-level bullet list (sanity, both items level: 1)
  • 2-level nest (level: 1 then level: 2)
  • mixed bullet+number nesting (bullet level: 1 then number level: 2)
  • 3-level deep nesting (level: 1, 2, 3)

Each case builds a synthetic ProseMirror doc and asserts the level/listItem/text tuples returned by _prosemirrorToPortableText.

`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.
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 22, 2026

🦋 Changeset detected

Latest commit: b93005a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 14 packages
Name Type
@emdash-cms/admin Patch
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/sandbox-workerd Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
create-emdash Patch
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 22, 2026

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@OrangeManLi
Copy link
Copy Markdown
Author

I have read the CLA Document and I hereby sign the CLA

github-actions Bot added a commit that referenced this pull request May 22, 2026
@OrangeManLi
Copy link
Copy Markdown
Author

Heads-up on the Translation Overview check failure — I dug in and I don't think it's caused by this PR.

The job dies with:

```
[ERR_PNPM_LOCKFILE_MISSING_DEPENDENCY] Broken lockfile: no entry for 'kysely@0.27.6' in pnpm-lock.yaml
This issue is probably caused by a badly resolved merge conflict.
```

That constraint comes from packages/workerd/package.json ("kysely": "^0.27.0", plus a peer >=0.27.0), which was introduced in 02ed8ba (Node.js plugin isolation via workerd sandbox, #426). But pnpm-lock.yaml on main only has kysely@0.29.2, no 0.27.x entry — so any PR whose base includes that commit fails the pnpm install --frozen-lockfile step Translation Overview runs.

I checked the other currently-open PRs and the same Translation Overview failure is present on #1141 and #1143, which corroborates that the lockfile drift is base-side, not introduced here.

Happy to push a lockfile regeneration on this branch if maintainers prefer to fix it through here, but I figured it'd be cleaner to surface it for a main fix so the same failure doesn't keep landing on every PR. Just let me know.

@ascorbic
Copy link
Copy Markdown
Collaborator

/review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

The fix is correct and mirrors the core prosemirror-to-portable-text.ts converter behaviorally — I walked through both forms (core's 4-function convertList → convertListItem → convertListItemNested → convertListItem vs. this PR's collapsed single-function recursion) and they produce the same output for the documented cases.

Verified

  • Top-level call sites (lines 236, 239) still invoke convertList(content, type) with the default level = 1 — no caller migration needed. ✓
  • _prosemirrorToPortableText is exported (line 1805), so the test's import path resolves. ✓
  • Test file follows the existing PortableTextEditor.table.test.ts convention (.ts extension, same import style; the browser vitest env is fine since the function is pure JS with no DOM deps). ✓
  • Test coverage hits the right cases: flat baseline, 2-level nest, mixed bullet/number nesting (proves level and listItem are tracked independently — an aliasing bug would still pass the all-bullet tests), 3-level. ✓
  • Changeset correctly scoped to @emdash-cms/admin patch. ✓

Out of scope, worth a follow-up — the inverse direction (convertPTList around line 743) still ignores block.level and flattens nested PT lists back into a single-level PM list on reload. The grouping loop at line 555 also stops at listItem type changes, so the mixed-type case from the new test ([bullet level=1, number level=2]) would come back as two adjacent top-level lists instead of one nested under the other. That's a pre-existing bug, not introduced here, and not what this PR claims to fix — but it means a nested list saved correctly by this fix won't fully round-trip the next time the editor opens. Core's portable-text-to-prosemirror.ts already has the level-aware grouping (its convertList/convertListItem, lines 178–260) — same mirroring exercise as this PR, in the opposite direction. Filing as a note; happy to keep this PR scoped as-is.

Translation Overview check failure is unrelated as the author noted — pnpm-lock.yaml drift on main after the workerd sandbox commit. Worth fixing on main separately.

Nice fix; small, focused, well-tested.

} 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. 👍

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).

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 22, 2026

Review posted successfully. The PR is well-scoped: the fix correctly mirrors the core converter's behavior for nested-list serialization, has solid test coverage (single-level, 2-level, mixed bullet/number, 3-level), and includes a properly scoped changeset. I flagged one out-of-scope follow-up — the inverse convertPTList still ignores block.level, so round-tripping a nested list back into the editor will flatten it — but that's a pre-existing bug, not introduced by this PR.

github run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants