Repeater field
Repeater.make(name).schema([...]) lets the end user add, remove,
reorder, and clone rows of an inner schema. Storage on the parent
record is a plain array of objects: [{ field1, field2 }, …].
Use it for line items on an order, FAQ entries on a CMS page, environment variables on a deployment, social-media links on a profile — anywhere the natural shape is "0 or more of the same little form."
#Quick example
import {
Form, Repeater, TextField, NumberField, ToggleField,
} from '@pilotiq/pilotiq'
Form.make()
.formId('orders-edit')
.schema([
Repeater.make('lineItems')
.label('Line items')
.columns(2)
.defaultItems(1)
.minItems(1)
.maxItems(50)
.reorderable()
.cloneable()
.collapsible()
.itemLabel(row => row['product'] || 'New line item')
.addActionLabel('Add line item')
.schema([
TextField.make('product').required(),
NumberField.make('quantity').default(1).required(),
NumberField.make('unitPrice').prefix('$').required(),
ToggleField.make('discounted'),
]),
])The submitted body has shape:
{
"lineItems": [
{ "product": "Widget", "quantity": 2, "unitPrice": 9.99, "discounted": false },
{ "product": "Gear", "quantity": 1, "unitPrice": 49, "discounted": true }
]
}#API
| Method | Effect |
|---|---|
.schema([...]) |
Inner schema — every Field type works inside; layout containers (Section, Group, Card, Grid) work too |
.columns(n) |
Render the inner schema in n grid columns |
.defaultItems(n) |
Initial empty rows on a fresh form (default 1) |
.minItems(n) |
Server-side validator + client-side disable on Remove |
.maxItems(n) |
Server-side validator + client-side disable on Add / Clone |
.reorderable() |
Drag-and-drop via the grip handle on each row, plus ↑ / ↓ buttons as keyboard fallback |
.cloneable() |
Show duplicate-row button |
.collapsible() |
Per-row collapse chevron — body kept mounted (so values survive collapse) |
.collapsed() |
Default-collapsed when collapsible (typically combined with itemLabel) |
.accordion() |
One-row-open-at-a-time mode. Picking a row collapses every other row. Auto-arms collapsible(). Pair with .collapsed() to start with everything closed (default opens the first visible row). Open-row id persists per-form to localStorage. |
.grid(n | { default?, sm?, md?, lg?, xl?, '2xl'? }) |
Lay the rows themselves in an n-column grid. Different from .columns(n), which grids the inner schema inside a row. Scalar form (grid(2)) is a fixed N columns at every width. Responsive form (grid({ default: 1, md: 2, xl: 3 })) keys column counts off the standard Tailwind breakpoint widths (sm 640px / md 768px / lg 1024px / xl 1280px / 2xl 1536px) but resolves them with CSS container queries against the Repeater's parent, not the viewport — a 3-column Repeater dropped into a narrow Split aside automatically folds to 1 column once the aside is narrower than md, even on a wide screen. The renderer marks the outer wrapper as a CQ container (container-type: inline-size) and emits a scoped <style> block of @container rules. n < 2 (or all-empty object) is the off sentinel. The drag-drop indicator is suppressed in grid mode (it reads wrong across cells); ↑ / ↓ buttons + DnD itself still work. |
.table([{ label, alignment?, width?, required? }, …]) |
Render rows as a compact HTML table — one <tr> per row, one <td> per inner field. Columns map 1:1 to schema() fields in declaration order. Inner-field labels render sr-only; clone / delete / extraItemActions land in a final actions cell. Pass [] to turn off. Mutually exclusive with .simple() and .grid(). |
.itemLabel(row => string) |
Header text for the collapsed row; falls back to Item N |
.itemHidden(rule) |
Per-row visibility — boolean or (ctx) => bool | Promise<bool>. Hidden rows render with display:none so values still round-trip on submit |
.itemCanDelete(rule) |
Per-row gate for the trash button — boolean or (ctx) => bool | Promise<bool>. Resolving falsy hides the trash on that row only |
.itemCanClone(rule) |
Per-row gate for the clone button. No-op when cloneable() is off |
.itemCanReorder(rule) |
Per-row gate for the drag grip + Up/Down arrows. No-op when reorderable() is off |
.addActionLabel(text) |
Label for the Add button (default 'Add') — shorthand for .addAction(RowButton.make().label(text)) |
.addAction(b) / .cloneAction(b) / .deleteAction(b) / .moveUpAction(b) / .moveDownAction(b) / .reorderAction(b) / .collapseAction(b) / .expandAction(b) |
Customize the chrome of the per-row built-in buttons (label / icon / color / tooltip). See Row-button customizers below. |
.expandAllAction(b?) / .collapseAllAction(b?) |
Mount field-header bulk chips that open / close every row. Opt-in — calling enables the button. Auto-arms collapsible(). |
Inherited from Field: .label() / .required() / .helperText() / .dehydrated() / .live() / .visible() / .hidden() / .disabled().
#Validation
Per-row inner-field errors land at flat keys: lineItems.0.product,
lineItems.0.quantity, etc. The renderer uses the dotted key to surface
the message inline on the matching row.
minItems / maxItems violations land under the bare key
(lineItems) so they render as a row-level message.
#Reactive interop (Plan #5)
Inner fields support live() and afterStateUpdated:
Repeater.make('lineItems').schema([
NumberField.make('quantity').live().afterStateUpdated((value, { $get, $set, row }) => {
const unit = Number($get('unitPrice') ?? 0)
$set('subtotal', Number(value) * unit) // row-scoped — writes the same row
console.log('row', row?.index, 'updated')
}),
NumberField.make('unitPrice'),
NumberField.make('subtotal').readonly(),
])$get(name)/$set(name, value)are row-scoped by default — reading a bare name reads/writes the current row's siblings.- Dotted paths reach across rows:
$get('lineItems.0.quantity'),$set('lineItems.0.subtotal', 0). row.indexexposes the current row's position;row.$get/row.$setare explicit aliases for the row-scoped helpers.
Live re-resolves still POST whole-form values, including the Repeater array.
#Layout visibility (Plan #8)
Section.visible(({ values }) => …) (and any layout visible(…) rule)
inside an inner schema sees ctx.values scoped to the row:
Repeater.make('faqs').schema([
TextField.make('question'),
TextField.make('answer'),
SelectField.make('category').options([...]),
Section.make('Internal notes')
.schema([TextField.make('internalNotes')])
.visible(({ values }) => values?.['category'] === 'technical'),
])Each row evaluates the visibility predicate independently against its own row values.
#Row-level visibility — itemHidden (Plan #14 v1.2)
Repeater.itemHidden(rule) accepts a boolean or a callback receiving
a row-scoped LayoutContext:
Repeater.make('contacts')
.itemHidden(({ values }) => values?.['archived'] === true)
.schema([
TextField.make('name'),
TextField.make('email'),
ToggleField.make('archived'),
])Hidden rows render with display: none — chrome (drag handle, action
buttons, label) doesn't render but inputs (and the __id) stay mounted
so values round-trip through FormData on submit. Visibility is purely
UX: hidden rows still count toward min/maxItems.
The predicate context carries:
values— row-scoped values$get/$set— row-scoped (dotted paths reach across rows)row.index— current row's absolute positionrecord/user— parent form's render context
Returning a Promise<boolean> is supported. A throwing predicate
fails-closed-as-visible (the row stays shown + console.warn) —
the inverse of layout visible()'s posture, because a misbehaving rule
should never silently hide data the user is editing.
itemHiddenre-evaluates on every server resolve. Pair the row's trigger field withlive()(or calllive()on a sibling top-level field that the rule reads) to get instant hide/show as the user types. Withoutlive(), the gate is still re-evaluated on initial SSR and after submit — just not between keystrokes.
Reorder skips hidden rows: pressing ↑ on the row below a hidden row hops the visible row over the hidden one. Drag-and-drop drops only between visible rows (hidden rows have no DOM box to target).
#Per-row capability gates
Three setters narrow the built-in row-button strip on a per-row basis:
Repeater.make('contacts')
.reorderable()
.cloneable()
.itemCanDelete(({ values }) => values?.['archived'] !== true) // can't delete archived
.itemCanClone(({ row }) => row?.index !== 0) // first row can't be cloned
.itemCanReorder(({ values }) => values?.['pinned'] !== true) // pinned rows stay put
.schema([
TextField.make('name'),
ToggleField.make('archived'),
ToggleField.make('pinned'),
])Each rule is boolean | (ctx: LayoutContext) => boolean | Promise<boolean>,
same shape as itemHidden. Returning truthy keeps the matching button
mounted (the default); returning falsy hides it on that row only:
itemCanDelete(false-ish)— trash button hiddenitemCanClone(false-ish)— clone button hidden (no-op whencloneable()is off)itemCanReorder(false-ish)— drag grip + Up/Down arrows hidden (no-op whenreorderable()is off)
The predicate context is the same as itemHidden: values (row-scoped),
$get / $set (row-local; dotted paths reach across rows), row.index,
record / user from the parent form.
Pinning a single row with itemCanReorder(false) doesn't lock its
neighbours — other rows can still drop above or below it. Pin both
sides if you need a pair to stay adjacent.
These gates are presentation, not authorization. A tampered POST body
that deletes a gated row will still go through. Gate the parent form's
lifecycle hooks (mutateDataBeforeUpdate, beforeSave, etc.) for real
authorization.
A throwing predicate fails-open (capability stays enabled + warn) —
mirroring itemHidden's posture, since a misbehaving rule shouldn't
silently lock the user out of editing data.
itemCanDelete / itemCanClone / itemCanReorder re-evaluate on every
server resolve, same as itemHidden. Mark the rule's trigger field
live() and the matching button on each row appears / disappears as
the user types. The renderer syncs the four flags by row id so locally
added rows + uncontrolled-input typed values survive each round-trip.
#Nested Repeaters
Repeaters compose. Coercion, validation, and resolve all recurse with the right scoping:
Repeater.make('products').schema([
TextField.make('name'),
Repeater.make('modifiers').schema([
TextField.make('label'),
NumberField.make('priceDelta'),
]),
])Submitted body:
{
"products": [
{ "name": "Burger", "modifiers": [
{ "label": "Cheese", "priceDelta": 1.5 },
{ "label": "Bacon", "priceDelta": 2 }
]
}
]
}Inner-row validation errors: products.0.modifiers.1.label.
#Soft / two-sided body shapes
Both Content-Type: application/json and
application/x-www-form-urlencoded bodies are supported on the server.
- JSON — the SPA
fetch+JSONpath (default since Plan #3 /feedback_action_dispatch_fetch_vs_303.md). ThelineItemskey is already an array of objects. - Flat-key form-encoded — the form-post 303 fallback path. Keys
arrive as
lineItems.0.product=Widget&lineItems.0.quantity=2. The server'scoerceFormValuesre-groups them into an array.
Trailing rows where every value is undefined / null / "" (only the
round-tripped __id is present) are trimmed before validators run, so
an "Add" + don't-type-anything sequence doesn't persist a blank row.
Rows with 0 or false survive — they're real values.
#Row identity
Each rendered row carries a stable id (server-generated on first
render; persisted client-side and round-tripped through a hidden
__id input). Stable ids enable:
- React key stability across reorder / clone / remove (so uncontrolled inputs preserve their typed values).
- Per-row collapsed-state localStorage keying:
pilotiq.repeater.<formId>.<fieldName>.<rowId>.
The id is a render-time identifier — it's not persisted on the
saved record. If you want stable row identity across reloads, add an
IdField to the inner schema.
#Reactive inner fields
Field.live() works inside a Repeater row. The client delegates
onChange / onBlur events at the Repeater container level: when a
dotted-path field name is detected (e.g. items.0.quantity), the
provider snapshots the form's full DOM state via FormData and POSTs
to the partial-resolve endpoint with the just-typed value layered on
top. The server's afterStateUpdated hook gets a row-scoped ctx.row
with $get / $set for same-row reads/writes, plus a top-level $get / $set that accepts dotted paths for cross-row reads.
Example — a row-scoped subtotal computed live from quantity × unit price:
NumberField.make('quantity')
.live({ debounce: 300 })
.afterStateUpdated((value, ctx) => {
const qty = Number(value ?? 0)
const price = Number(ctx.row?.$get('unitPrice') ?? 0)
ctx.row?.$set('subtotal', qty * price)
})Limitation: Switch / Slider and other React-controlled primitives
that update via callbacks (not native input events) won't bubble
through the delegated handler, so their inner live() won't fire.
Native inputs — text, number, email, textarea, select, range, date,
checkbox, radio — all work.
#Per-row action buttons — extraItemActions
Repeater.extraItemActions([...]) and Builder.extraItemActions([...])
register handler-style action buttons that render in each row's header
alongside the built-in clone/delete strip. Useful for "Send test",
"Mark featured", etc.
Repeater.make('subscribers')
.schema([TextField.make('email').required()])
.extraItemActions([
Action.make('sendTest')
.label('Send test')
.icon('send')
.visible(({ values }) => Boolean(values?.email))
.handler((ctx) => {
const email = ctx.row?.values?.email
// ctx.row = { index, id, values, fieldName, blockType? }
return { notify: Notification.make(`Test queued for ${email}`).success() }
}),
])The handler context carries:
| Field | Description |
|---|---|
ctx.record |
The parent record (edit page) — same as page-level handlers see. |
ctx.user |
The resolved panel user. |
ctx.row.index |
0-based row position. |
ctx.row.id |
Stable row id (the row's __id). |
ctx.row.values |
The row's submitted fields (Builder rows are unwrapped from data). |
ctx.row.fieldName |
Parent Repeater/Builder field name. |
ctx.row.blockType |
(Builder only) Matched block name. |
Visibility predicates (.visible() / .hidden() / .disabled()) receive
ActionVisibilityContext with values set to the row's submitted
fields, plus the parent record and user.
v1 limitations:
- Handler-style only.
.href(…)/.method(…)/ modal-form actions aren't supported per-row in v1. They render as no-op buttons. Use a handler that returns{ redirect }for navigation. - Top-level fields only. Nested Repeater/Builder rows hit the
2-segment
_rowPathcap. The action will render but dispatch fails-quiet. - Builder per-block actions (
Block.extraItemActions(...)) are deferred. Use field-levelextraItemActionsand branch in your handler onctx.row.blockType.
#Cross-row uniqueness — distinct()
Field.distinct() rejects duplicate values across the rows of a
Repeater (or rows of the same block type inside a Builder). The first
occurrence is always allowed; second + subsequent rows fail
validation.
Repeater.make('inventory')
.schema([
TextField.make('sku')
.required()
.distinct({ caseInsensitive: true }),
NumberField.make('stock'),
])Options:
| Option | Default | Description |
|---|---|---|
caseInsensitive |
false |
Case-fold strings before comparing. Non-string values are compared as-is. |
ignoreNulls |
true |
Treat null / undefined / '' as "not yet set" — two empty rows aren't a conflict. Set false to forbid duplicate empties too. |
message |
'Must be unique' |
Override the rejection text. |
Bare distinct() is the common case. Pass false to clear a
previously-set rule (field.distinct(opts).distinct(false)).
Behavior in Builder. The check is scoped to rows of the same
block type — two heading blocks with the same text conflict, but
a heading.text = "X" never conflicts with a paragraph.text = "X"
(different block schemas, different intent).
Interaction with unique(). The two are orthogonal: distinct()
is in-form cross-row; unique({ model }) is across-records DB probe.
Pair them when a Repeater value must be unique both within the row
set and against persisted records:
TextField.make('email')
.required()
.distinct({ caseInsensitive: true })
.validate(unique({ model: Subscriber, caseInsensitive: true }))Limitations. Outside a Repeater/Builder, distinct() is a no-op
(there's nothing to compare against). Inside a Repeater, the check
ignores nested-Repeater children — the inner array's distinct()
runs against its own rows only.
#Row-button customizers
The built-in row chrome buttons can be re-skinned without owning
the button markup. RowButton.make() is a tiny fluent builder — set
any subset of label / icon / color / tooltip and pass it to the
matching slot setter:
import { Repeater, RowButton } from '@pilotiq/pilotiq'
Repeater.make('lineItems')
.schema([…])
.reorderable()
.cloneable()
.collapsible()
// Every slot accepts a RowButton; absent slots keep their defaults.
.addAction(RowButton.make().label('Add line item').icon('plus-circle'))
.deleteAction(RowButton.make().tooltip('Remove this line').color('destructive'))
.cloneAction(RowButton.make().icon('files'))
.moveUpAction(RowButton.make().tooltip('Move earlier'))
.moveDownAction(RowButton.make().tooltip('Move later'))
.reorderAction(RowButton.make().tooltip('Hold and drag'))
.collapseAction(RowButton.make().icon('chevron-down').tooltip('Hide details'))
.expandAction(RowButton.make().icon('chevron-right').tooltip('Show details'))
.expandAllAction() // mounts the bulk "Expand all" header chip
.collapseAllAction() // mounts the bulk "Collapse all" header chip| Slot | Default icon | What it controls |
|---|---|---|
addAction |
+ |
Bottom Add button — also reads the customizer label / icon / tooltip on Builder's picker shortcut. Color is intentionally ignored on Add to keep the outline-button visual. |
cloneAction |
copy | Per-row Duplicate button. |
deleteAction |
trash | Per-row Remove button (default color: destructive on hover). |
moveUpAction |
arrow up | Keyboard-fallback Up arrow. |
moveDownAction |
arrow down | Keyboard-fallback Down arrow. |
reorderAction |
grip | Drag handle (a <span>, not a button — label becomes the aria-label, tooltip the title). |
collapseAction |
chevron-down (open) / chevron-right (collapsed) | Per-row chevron. By default the override applies to BOTH states; pair with expandAction for state-specific chrome. |
expandAction |
chevron-right | Sibling of collapseAction for the collapsed state only. Sets the icon / label / tooltip / color used when the row is currently collapsed; the open-state glyph still routes through collapseAction. |
expandAllAction |
chevrons-down | Field-header chip that opens every collapsed row. Opt-in — calling enables the button (with or without a RowButton override). Auto-arms collapsible(). In accordion() mode it opens the first visible row. |
collapseAllAction |
chevrons-up | Field-header chip that collapses every open row. Opt-in (same posture). Auto-arms collapsible(). In accordion() mode it closes the currently-open row. |
Icons are string-only — resolved through the registerIcons({ … })
runtime registry, the same way Block.icon() and Section.icon()
work. Unknown keys fall back to the slot's default Lucide glyph.
Color tokens: 'foreground' | 'destructive' | 'primary' | 'success' | 'warning' | 'info' | 'muted'. They map to text-…/hover:text-…
Tailwind class pairs, mirroring Action.color().
addActionLabel(text) is a shorthand for
addAction(RowButton.make().label(text)) — both setters can coexist;
the customizer wins when both are set.
#Compact table layout — table([{ label, … }, …])
For uniform rows (think team members, address book entries, line items),
table mode is denser than the default card layout — one <tr> per row,
one <td> per inner field, with the column headers carrying the labels:
Repeater.make('teamMembers')
.table([
{ label: 'Name' },
{ label: 'Email' },
{ label: 'Role', alignment: 'right' },
{ label: 'Active', alignment: 'center', width: '6rem' },
])
.reorderable()
.cloneable()
.schema([
TextField.make('name').required(),
TextField.make('email').required(),
SelectField.make('role').options([…]),
ToggleField.make('active'),
])Columns map 1:1 to schema() fields in declaration order — columns[0]
is the header for schema[0], and so on. Each column accepts:
| Key | Effect |
|---|---|
label |
Header text (required) |
alignment |
'left' | 'center' | 'right' — aligns header + cell |
width |
Raw CSS width string ('30%', '6rem', '200px') |
required |
Adds a red asterisk to the header (purely visual) |
Inner-field labels render sr-only since the column header carries the
labelling. Reorder grip + ↑/↓ buttons, clone, delete, and any
extraItemActions land in a trailing actions cell.
Pass an empty array (.table([])) to turn off table mode — handy for
toggling via a config value.
Mutually exclusive with .simple() and .grid(). The field setters
arbitrate (whichever was set last wins). .collapsible() and
.accordion() are silently ignored in table mode — <tr> rows have no
chrome to collapse.
#Single-field flat-array repeater — simple(field)
When the row is a single field, the [{ field: value }] storage shape
adds noise. Repeater.simple(field) flattens it to [value, value, …]:
Repeater.make('keywords')
.simple(
TextField.make('keyword').placeholder('Enter a keyword'),
)
.reorderable()
.defaultItems(2)The persisted record holds keywords: ['react', 'typescript', …] — a
plain string array. The form pipeline (resolve, coerce, validate) keeps
using the wrapped [{ keyword: v }] shape internally so per-field
validators (required, unique, distinct, custom validators) work
exactly the same as in a regular Repeater. The flattening happens
once, after coerce, before your save() handler runs.
The chrome flattens too:
- No row header — single-field rows don't need a label.
- No clone — there's no row identity to duplicate; users can just pick a value again.
- No collapse — pointless for a single input.
- Reorder + delete still work — drag handle (when
reorderable())- trash icon stay on each row.
min/maxItems, defaultItems, addActionLabel, and extraItemActions
all carry over.
Loading edit-mode records. When the Repeater is simple(), the
loaded record value ['a', 'b'] is wrapped on the way into resolution.
Already-wrapped values (e.g. when a coerce or state-update has already
produced [{name: v}, …]) pass through — the wrap is idempotent.
simple() replaces any prior schema() call. The single field
passed to simple() becomes the entire inner schema.
Heterogeneous rows belong in Builder, not simple. If your row
holds more than one field, use the regular schema([…]) form;
simple is purely for the one-input case.
#Disable options taken in sibling rows — disableOptionsWhenSelectedInSiblingRepeaterItems()
The client-side companion to distinct() for option-bearing fields.
Greys out option choices that any sibling row has already picked, so
users can't pick the same value twice.
Available on SelectField, RadioField, CheckboxListField, and
ToggleButtonsField:
Repeater.make('picks').schema([
SelectField.make('colour')
.options([
{ value: 'red', label: 'Red' },
{ value: 'green', label: 'Green' },
{ value: 'blue', label: 'Blue' },
])
.disableOptionsWhenSelectedInSiblingRepeaterItems(),
])Calling this method auto-arms two related flags:
distinct()— server-side last-line guarantee. The client UI prevents the conflict from happening, but a tampered request (curl, or a stale tab) is rejected at validation time.live()— picking a value in one row immediately re-resolves the form so the disabled state on the OTHER rows updates without a page refresh.
Behavior in Builder. Same per-block-type scoping as distinct() —
a Select inside a hero block isn't shadowed by a pick in a
paragraph block (different schemas, different fields).
CheckboxList (multi-select) treats each entry of every sibling's
string[] as a taken value. The user can still uncheck their own
row's pick (the disabled flag never blocks releasing a held value),
but other rows can't pick it.
Static disabled per option is preserved alongside the taken
state — set { value, label, disabled: true } on the static option
list to mark a choice as permanently unavailable, and the runtime
disabling stacks on top.
Pass false to clear (.disableOptionsWhenSelectedInSiblingRepeaterItems(false)).
This does not also clear distinct() / live() — call those
explicitly if you need them off too.
#Relationship-backed rows — relationship(name)
By default a Repeater stores its rows as a JSON array on a column of
the parent record (order.lineItems = [{ ... }, ...]). For tightly
coupled, parent-only data that's perfect — one column, one round-trip.
But the moment the rows need to be queried independently, soft-deleted
on their own, referenced by other models, or sorted with cursors, the
JSON shape gets in the way. relationship(name) flips a Repeater to
back its rows with a real HasMany relation: each row becomes a real
child record, persisted via the child model.
PostResource.form(form) {
return form.schema([
TextField.make('title').required(),
Repeater.make('attachments')
.relationship('attachments') // matches Post.relations.attachments
.schema([
TextField.make('label').required(),
TextField.make('url').required(),
])
.reorderable()
.orderColumn('sort'), // optional — writes 0-based index per row
])
}The parent declares the relation in the rudder ORM convention:
class Post extends Model {
static override relations = {
attachments: { type: 'hasMany' as const, model: () => Attachment, foreignKey: 'postId' },
}
}That declaration is everything. The pilotiq pipeline does the rest:
- Load — on the edit page, rows are fetched from
parent.related('attachments')instead of read off the parent record. Each row's primary key is stamped onto__idso the renderer can round-trip identity through a hidden input. The PK and FK columns are stripped from the rendered row so the inner schema doesn't accidentally surface them as form values. - Save — submitted rows are diffed against the existing related
rows by
__id. New rows (no__idmatching an existing PK) →Attachment.create({ ...row, postId: parentId }). Matching rows →Attachment.update(__id, row). Existing rows missing from the submitted set →Attachment.delete(pk). The FK is not overwritten on update (the existing row's FK is already correct, and exposing it would let a tampered client re-link a child to a different parent). - Order — when
orderColumn('sort')is set, every create / update payload stamps the row's 0-based index into that column. Reordering via drag-and-drop simply rewrites the column on save.
#Object form
Pass an object instead of a string for explicit overrides — useful when the parent model doesn't follow the rudder convention or when you want to retarget the child model:
Repeater.make('attachments')
.relationship({
name: 'attachments',
model: Attachment,
foreignKey: 'postId',
orderColumn: 'sort',
})Each field defaults to the value discovered on the parent's static relations map; explicit settings win. The model and foreignKey
keys are server-only — they never cross the wire.
#Many-to-many relations
relationship() also supports the M2M family — belongsToMany,
morphToMany, and morphedByMany. The semantic is "embed inline-edited
related records":
class Article extends Model {
static override relations = {
tags: { type: 'belongsToMany' as const, model: () => Tag, pivotTable: 'article_tag' },
}
tags() { return Model.belongsToMany(this, 'tags') }
}
ArticleResource.form(form) {
return form.schema([
TextField.make('title'),
Repeater.make('tags')
.relationship('tags')
.schema([
TextField.make('name').required(),
TextField.make('slug'),
]),
])
}What changes from the hasMany case:
- Create row —
Tag.create(payload)runs first, thenarticle.tags().attach([newTag.id])writes the pivot row. The pivot is the link; the related model has no FK column. - Update row —
Tag.update(__id, payload)runs as before; the pivot is left alone (the link is already correct). - Delete row —
article.tags().detach([__id])removes the pivot link only. The related Tag is NOT deleted — it may be attached to other articles. AcascadeDeleteopt-in is a follow-up; for v1 detach-only is the safe default.
morphToMany and morphedByMany work the same way — the rudder ORM
accessor handles polymorphic stamping (<morphName>Type) on the pivot
row transparently.
Pivot-extras editing
When the pivot table carries its own columns (e.g. users_roles.role
where each link assigns a different role), declare them with
pivotColumns([…]) and inline-edit them like any other field:
Repeater.make('users')
.relationship('users')
.pivotColumns(['role'])
.schema([
TextField.make('name'),
SelectField.make('role').options({
owner: 'Owner',
editor: 'Editor',
viewer: 'Viewer',
}),
])Field names listed in pivotColumns must match a name in the inner
schema(). The persist pipeline:
- Load — calls the rudder ORM's
withPivot(...cols)projection on the deferred query so each loaded row carriesrow.pivot = { role: 'owner', … }. The values flatten onto the row's form data — the innerrolefield pre-populates with'owner'. - Save (existing row) — splits the submitted payload by name. Pivot
columns route through
accessor.updatePivot(__id, { role: 'editor' }); child columns route throughmodel.update(__id, { name: 'New' })as before. Either side may be empty (pivot-only or child-only edits). - Save (new row) — child fields create the related record; pivot
fields ship through the per-id-pivot map shape on
attach({ [newId]: { role: 'owner' } }). - Delete — pivot row goes away with the
detach()link removal; the related child is left alone (same as the M2M default).
Requires the rudder ORM feat(orm): pivot-extras read/update + per-id sync (PR #251) — the withPivot projection on the query and
updatePivot on the M2M accessor. Pilotiq throws a clear error at save
time when the accessor doesn't expose updatePivot and a pivot column
changed.
The belongsToMany, morphToMany, and morphedByMany siblings all
share this surface — morphToMany / morphedByMany carry the
discriminator column transparently on every pivot operation.
M2M caveats
orderColumn()is rejected under M2M. Pivot-side ordering needs ORMorderByPivotwhich v1 doesn't expose; throwing here beats silently writing into a non-existent column on the related model. Re-order manually for now.- Builder.relationship doesn't support M2M. Builder rows are
heterogeneous
{ type, data }envelopes — the pivot semantics don't compose cleanly with that shape. UseRepeater.relationshipif your rows are homogeneous, or open an issue if you have a use case for per-block-type pivot dispatch.
#Per-row hooks — afterCreate / afterUpdate / afterDelete
Wire side effects to the per-row create / update / delete events that fire during the persist diff:
RepeaterField.make('attachments')
.relationship('attachments')
.schema([
TextField.make('filename'),
FileUpload.make('file'),
])
.afterCreate(async (record, ctx) => {
await Audit.log('attachment.created', { recordId: record.id, parentId: ctx.parentId })
})
.afterUpdate(async (record, ctx) => {
await Audit.log('attachment.updated', { recordId: record.id, parentId: ctx.parentId })
})
.afterDelete(async (removed, ctx) => {
if (ctx.mode === 'hasMany' || ctx.mode === 'morphMany') {
await S3.delete(removed.s3Key) // child record was physically deleted
}
// For M2M only the pivot was detached — the child may still exist.
})The handler receives the persisted child record and a RepeaterRowContext
carrying:
parent— post-save parent record (the same shape the surrounding form'safterSavewould see).parentId— convenience forparent[primaryKey].field— the Repeater field name (so a single handler can serve multiple fields).index— 0-based row index in the submitted set;-1forafterDelete(deleted rows aren't in the submitted set).mode—'hasMany' | 'morphMany' | 'belongsToMany' | 'morphToMany' | 'morphedByMany'. Branch on this inafterDeletewhen your cleanup depends on physical deletion vs pivot detach.
Hooks throw to abort: a throwing handler propagates and stops the rest of the persist diff. Earlier rows have already saved (v1 isn't transactional), so use sparingly for state that you can recover from or for precondition checks that should fail loud.
Each hook is opt-in per field; calling them on a Repeater that hasn't
declared relationship(...) throws at config time.
#Limitations and trade-offs
- Five relation types supported.
hasMany,morphMany,morphOne, and the M2M family (belongsToMany,morphToMany,morphedByMany).belongsTo/hasOneare deferred — they imply a single child, which the Repeater doesn't model naturally. - Mutually exclusive with
simple()anddehydrated(false). Flat[v, v, ...]storage can't round-trip through named child columns; adehydrated(false)field never persists, so combining it withrelationship()would silently drop every row. - No transaction wrapper in v1. If the parent saves but a child
create fails partway through the diff, the parent edit is committed
and the failure surfaces as a 500. A transactional wrapper is a
follow-up once the ORM lands a
transaction(fn)primitive. - Builder ships its own
relationship(...)sibling. Seedocs/guide/builder.md— Builder rows persist as child records carrying atypediscriminator + a JSONdatapayload (column names configurable). Same hasMany-only / no-transaction posture as Repeater. mutateDataBeforeCreatedoesn't see relation rows. They've been extracted before any user-side mutator runs. Mutate the parent data; the child rows go through the inner schema's own mutators on the child model side.
#Limitations
itemHiddenanditemCan*don't re-evaluate on live updates. Currently evaluated only at full form-render. Reactive hide/show / hide/enable is a future revision.- Per-row gates are presentation, not authorization.
itemCanDelete(and its siblings) hide buttons but don't reject tampered POST bodies. Gate the parent form's lifecycle hooks for real authorization. - Forms inside a Repeater row aren't dispatched in v1 (no row context on the handler). Keep forms at the page level.
#See also
docs/plans/repeater-field.md— design doc + step-by-step status.- Live demo:
playground→/new-admin/repeater-demo.