Skip to content

Block Kit: a repeater sub-field cannot itself be a repeater, so a plugin block cannot model a list whose items each own a list #2521

Description

@virafb

Summary

RepeaterElement is the only Block Kit element that captures more than one
value, and its sub-fields are restricted to four scalar element types. A
content shape as ordinary as "a list of cases, each with a title and three
labelled rows" therefore has no typed representation: the block either flattens
to a fixed number of scalar sub-fields, or the whole region stays an opaque HTML
atom that the editor cannot touch.

Verified against @emdash-cms/blocks@0.32.0, and re-checked unchanged in
0.33.0 (current latest on npm).

Where

The union, in @emdash-cms/blocks src/types.ts:

/**
 * Sub-field types allowed inside a RepeaterElement. Limited to the scalar
 * inputs the admin widget currently renders inline.
 */
type RepeaterSubField = TextInputElement | NumberInputElement | SelectElement | ToggleElement;

It is enforced at runtime too, not only by types, in src/validation.ts:

const REPEATER_SUB_FIELD_TYPES = new Set(["text_input", "number_input", "select", "toggle"]);
message: `Repeater sub-field type '${sub.type}' is not allowed. Expected one of: …`

Note that media_picker is a valid top-level element and is also excluded from
that set, so a repeater of images is out of reach by the same rule. (#1424
added media support to the content-type repeater field; the Block Kit
repeater is a separate surface and did not change.)

Reproducing

elements.repeater("cases", "Cases", [
  elements.textInput("title", "Title"),
  // Rejected by the type, and by validateBlocks at runtime:
  elements.repeater("rows", "Rows", [
    elements.textInput("label", "Label"),
    elements.textInput("value", "Value"),
  ]),
])

TypeScript rejects the nested element, and forcing it past the types produces
Repeater sub-field type 'repeater' is not allowed.

The interesting part: the renderer may already be closer than the types

In @emdash-cms/admin src/components/PortableTextEditor.tsx, a repeater row
renders each of its sub-fields through BlockKitField — the same component
used at the top level of the modal, whose switch already has a
case "repeater" that delegates to BlockKitRepeater. So the row renderer
would dispatch a nested repeater today.

Two things in the same file would need attention before that worked:

  • handleAdd seeds a new row from the sub-field type, with false for
    toggle, undefined for number_input, and "" for everything else. A
    nested repeater would be seeded with a string rather than [].
  • stripKeys removes _key at one level only, so nested rows would keep the
    synthetic keys the widget adds.

That is a reading of the source, not a test result — I did not force a nested
repeater through to see what the widget does with it.

Why it matters

The alternative shapes are worse in ways that show up in the editor:

  • Flattening to row1_label, row1_value, row2_label … fixes the count
    at declaration time and produces a modal of eight or more unrelated inputs
    with no visual grouping. It also silently caps the content.
  • Leaving it as HTML keeps the region editable only by a developer, which is
    the outcome the block API exists to avoid.

The shape is common: FAQ groups, comparison cases, timelines with sub-steps,
spec sheets. One level of nesting covers all of them.

Suggested fix

  1. Allow RepeaterElement in RepeaterSubField, bounded to one level of
    nesting so the shape stays predictable and the widget stays simple. Add
    "repeater" to REPEATER_SUB_FIELD_TYPES, seed nested rows as [] in
    handleAdd, and recurse in stripKeys.
  2. If nesting is deliberately out of scope, say so in the RepeaterSubField
    docstring. The current wording ("Limited to the scalar inputs the admin
    widget currently renders inline") reads as a temporary implementation limit,
    so plugin authors reasonably try it and hit the runtime error.

Happy to send a PR for (1) if you would like a particular shape.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions