Skip to content

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

Open
OrangeManLi wants to merge 13 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 13 commits into
emdash-cms:mainfrom
OrangeManLi:fix/admin-nested-list-level

Conversation

@OrangeManLi
Copy link
Copy Markdown
Contributor

@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: e63fc7c

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
Contributor 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
Contributor 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

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.
@github-actions github-actions Bot added size/L and removed size/M labels May 25, 2026
@OrangeManLi
Copy link
Copy Markdown
Contributor Author

Pushed a follow-up addressing the inverse direction: convertPTList was also flattening nested lists (it ignored block.level and just emitted siblings), and the run-grouping in portableTextToProsemirror broke on listItem-type changes — so opening a saved nested list back in the editor would lose its hierarchy as soon as a different list type (e.g. an ordered child under a bullet) appeared.

Changes in e6ccb5b:

  • Rewrites convertPTList to recurse on level, mirroring @emdash-cms/core/portable-text-to-prosemirror.convertList.
  • Loosens the outer run-grouping in portableTextToProsemirror to fold level > 1 blocks into the current run regardless of their listItem type (the recursive call inside handles type switches per subtree).
  • Adds PT → PM tests and a PT → PM → PT round-trip test covering the regression case ([bullet L1, number L2, bullet L1] must stay as one bullet list with a numbered child, not three lists at the document root).

Translation Overview should also clear now — rebased onto main which picked up #1144's kysely workspace-catalog fix.

OrangeManLi and others added 4 commits May 25, 2026 13:17
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.
@github-actions github-actions Bot added the review/needs-rereview Author pushed changes since the last review label May 30, 2026
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Jun 2, 2026

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@1142

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@1142

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@1142

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@1142

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@1142

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@1142

emdash

npm i https://pkg.pr.new/emdash@1142

create-emdash

npm i https://pkg.pr.new/create-emdash@1142

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@1142

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@1142

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@1142

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@1142

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@1142

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@1142

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@1142

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@1142

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@1142

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@1142

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@1142

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@1142

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@1142

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@1142

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@1142

commit: e63fc7c

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

Labels

area/admin cla: signed review/needs-rereview Author pushed changes since the last review size/L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants