Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
38 changes: 38 additions & 0 deletions .changeset/object-form-repeater-data-binding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
"@objectstack/spec": patch
---

fix(spec): `object.form.ts` repeater predicates bind through `data`, like every other metadata form (#6254)

The object metadata form's field-list repeater carried 16 `visibleWhen`
predicates written as **bare identifiers**:

- FROM: `visibleWhen: "type == 'formula'"`, `visibleWhen: "type in ['lookup','master_detail']"`, …
- TO: `visibleWhen: "data.type == 'formula'"`, `visibleWhen: "data.type in ['lookup','master_detail']"`, …

Metadata-editing forms (`*.form.ts`) bind the row under edit as `data` — which
is how the sibling `field.form.ts` has always written them (`data.type == 'text'`).
The bare spelling has no binding at all: `type` is an unbound identifier, the
predicate faults, and a faulted visibility predicate resolves to its fallback,
`true`. Every constrained sub-field — `maxLength`, `min`/`max`, `precision`,
`expression`, `returnType`, `reference`, `deleteBehavior`, `autonumberFormat`,
… — was therefore offered on **every** field row regardless of its type, which
is the exact opposite of what each rule asks for.

**The repeater does rebind `data`, and that is worth writing down.** A sub-field
of a `type: 'record'` repeater is evaluated against its own row
(`evaluatePredicate(spec.visibleOn, { data: row })` in the metadata form
renderer), so `data.type` reads *this row's* type — precisely what a per-entry
rule wants. What the repeater does **not** do is introduce an implicit row
scope: the root is spelled `data` at every depth. `FormField.visibleWhen`'s
JSDoc and `describe` now state both halves, so the next author does not have to
infer either one.

`field.form.ts` is unchanged — it was already correct.

Authoring-surface fix with no schema or validation change, hence patch. Note
that these predicates are not yet *observably* restored: a separate defect
outside this package (the metadata form renderer reads the deprecated
`visibleOn` key, while the parse emits only the canonical `visibleWhen`) keeps
every metadata-form predicate inert today. That is tracked separately; this
change is a prerequisite for it, and correct on its own terms either way.
2 changes: 1 addition & 1 deletion content/docs/references/ui/view.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ Column footer summary configuration
| **language** | `string` | optional | Code editor language (for type=code) |
| **keyField** | `{ field?: string; label?: string; placeholder?: string; helpText?: string; … }` | optional | Key column config for record-typed fields |
| **dependsOn** | `string` | optional | Parent field name for cascading |
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — field shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. No `current_user` at field level — it is unbound here and the predicate would fault open (per-option `visibleWhen` is the surface that binds it). e.g. P`record.priority == 'urgent'` |
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — field shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. No `current_user` at field level — it is unbound here and the predicate would fault open (per-option `visibleWhen` is the surface that binds it). Inside a repeater `data` is the ROW, but it is still spelled `data` — a bare identifier is unbound and faults open too. e.g. P`record.priority == 'urgent'` |
| **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse. |
| **disclosure** | `Enum<'inline' \| 'popover'>` | optional | Composite rendering: inline bordered box (default) or a summary line + gear popover (progressive disclosure). |
| **fields** | `[FormField](#formfield)[]` | optional | Sub-fields for composite/repeater/record types |
Expand Down
32 changes: 16 additions & 16 deletions packages/spec/src/data/object.form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,21 +117,21 @@ export const objectForm = defineForm({
// through `inlineHelpText` / `description`.

// Text constraints
{ field: 'maxLength', type: 'number', helpText: 'Max characters', visibleWhen: "type in ['text','textarea','email','url','phone','password','markdown','html','richtext']" },
{ field: 'minLength', type: 'number', helpText: 'Min characters', visibleWhen: "type in ['text','textarea','email','url','phone','password','markdown','html','richtext']" },
{ field: 'maxLength', type: 'number', helpText: 'Max characters', visibleWhen: "data.type in ['text','textarea','email','url','phone','password','markdown','html','richtext']" },
{ field: 'minLength', type: 'number', helpText: 'Min characters', visibleWhen: "data.type in ['text','textarea','email','url','phone','password','markdown','html','richtext']" },

// Numeric constraints
{ field: 'min', type: 'number', helpText: 'Minimum value', visibleWhen: "type in ['number','currency','percent','rating','slider','progress']" },
{ field: 'max', type: 'number', helpText: 'Maximum value', visibleWhen: "type in ['number','currency','percent','rating','slider','progress']" },
{ field: 'precision', type: 'number', helpText: 'Total digits', visibleWhen: "type in ['number','currency','percent']" },
{ field: 'scale', type: 'number', helpText: 'Decimal places', visibleWhen: "type in ['number','currency','percent']" },
{ field: 'min', type: 'number', helpText: 'Minimum value', visibleWhen: "data.type in ['number','currency','percent','rating','slider','progress']" },
{ field: 'max', type: 'number', helpText: 'Maximum value', visibleWhen: "data.type in ['number','currency','percent','rating','slider','progress']" },
{ field: 'precision', type: 'number', helpText: 'Total digits', visibleWhen: "data.type in ['number','currency','percent']" },
{ field: 'scale', type: 'number', helpText: 'Decimal places', visibleWhen: "data.type in ['number','currency','percent']" },

// Selection options
{
field: 'options',
type: 'repeater',
helpText: 'Available choices',
visibleWhen: "type in ['select','multiselect','radio','checkboxes']",
visibleWhen: "data.type in ['select','multiselect','radio','checkboxes']",
fields: [
{ field: 'label', type: 'text', required: true },
{ field: 'value', type: 'text', required: true },
Expand All @@ -142,24 +142,24 @@ export const objectForm = defineForm({
},

// Relational
{ field: 'reference', type: 'text', helpText: 'Target object name', visibleWhen: "type in ['lookup','master_detail','tree']" },
{ field: 'reference', type: 'text', helpText: 'Target object name', visibleWhen: "data.type in ['lookup','master_detail','tree']" },
// `lookupFilters`, not `referenceFilter`: an array of
// {field, operator, value} rules, not a CEL string.
{ field: 'lookupFilters', widget: 'json', helpText: 'Filter rules applied to the picker ({field, operator, value})', visibleWhen: "type in ['lookup','master_detail']" },
{ field: 'lookupFilters', widget: 'json', helpText: 'Filter rules applied to the picker ({field, operator, value})', visibleWhen: "data.type in ['lookup','master_detail']" },
// `deleteBehavior`, not a `cascadeDelete` boolean: the schema models
// three outcomes, and only one of them is "cascade".
{ field: 'deleteBehavior', type: 'select', helpText: 'What happens when the referenced record is deleted', visibleWhen: "type in ['lookup','master_detail']", options: [
{ field: 'deleteBehavior', type: 'select', helpText: 'What happens when the referenced record is deleted', visibleWhen: "data.type in ['lookup','master_detail']", options: [
{ label: 'Set null', value: 'set_null' },
{ label: 'Cascade (delete children)', value: 'cascade' },
{ label: 'Restrict (block the delete)', value: 'restrict' },
] },
{ field: 'multiple', type: 'boolean', helpText: 'Allow selecting multiple records', visibleWhen: "type in ['lookup']" },
{ field: 'multiple', type: 'boolean', helpText: 'Allow selecting multiple records', visibleWhen: "data.type in ['lookup']" },

// Formula / summary
// `expression`, not `formula` — the key is named for what it holds,
// not for the field type that uses it.
{ field: 'expression', type: 'code', language: 'expression', helpText: 'CEL formula expression', visibleWhen: "type == 'formula'" },
{ field: 'returnType', type: 'select', helpText: 'Result type for formulas', visibleWhen: "type == 'formula'", options: [
{ field: 'expression', type: 'code', language: 'expression', helpText: 'CEL formula expression', visibleWhen: "data.type == 'formula'" },
{ field: 'returnType', type: 'select', helpText: 'Result type for formulas', visibleWhen: "data.type == 'formula'", options: [
{ label: 'Text', value: 'text' }, { label: 'Number', value: 'number' }, { label: 'Boolean', value: 'boolean' },
{ label: 'Date', value: 'date' }, { label: 'Datetime', value: 'datetime' }, { label: 'Currency', value: 'currency' },
] },
Expand All @@ -170,7 +170,7 @@ export const objectForm = defineForm({
field: 'summaryOperations',
type: 'composite',
helpText: 'Roll-up: which child object, which field, which aggregation',
visibleWhen: "type == 'summary'",
visibleWhen: "data.type == 'summary'",
fields: [
{ field: 'object', type: 'text', required: true, helpText: 'Source child object name' },
{ field: 'field', type: 'text', required: true, helpText: 'Field on the child object to aggregate (ignored for count)' },
Expand All @@ -184,10 +184,10 @@ export const objectForm = defineForm({
// Autonumber — `autonumberFormat`, not `displayFormat`. There is no
// `startingNumber`: the counter resets per rendered prefix, which the
// format string itself determines (e.g. AD{YYYYMMDD}{0000} resets daily).
{ field: 'autonumberFormat', type: 'text', helpText: 'e.g. "INV-{0000}"; date tokens {YYYY}/{MM}/{DD} and {field_name} interpolation supported', visibleWhen: "type == 'autonumber'" },
{ field: 'autonumberFormat', type: 'text', helpText: 'e.g. "INV-{0000}"; date tokens {YYYY}/{MM}/{DD} and {field_name} interpolation supported', visibleWhen: "data.type == 'autonumber'" },

// Code language
{ field: 'language', type: 'text', helpText: 'Editor language (e.g. sql, javascript)', visibleWhen: "type == 'code'" },
{ field: 'language', type: 'text', helpText: 'Editor language (e.g. sql, javascript)', visibleWhen: "data.type == 'code'" },

// Governance. `validation` / `errorMessage` are not FieldSchema keys —
// a record-level predicate is a `validation` metadata item on the
Expand Down
14 changes: 13 additions & 1 deletion packages/spec/src/ui/view.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1424,8 +1424,20 @@ const FormFieldBaseSchema = lazySchema(() => z.object({
* bound for **per-option** `visibleWhen` (a different evaluator —
* `resolveCascadingOptions` against the host's predicate scope, ADR-0068 /
* objectui#2284); that is the only `*When` surface where it resolves.
*
* **Inside a repeater, `data` is the ROW, not the whole document** (#6254).
* A sub-field of a `type: 'record'` / repeater field is rendered with its own
* activation — the metadata form renderer evaluates each sub-field predicate
* as `evaluatePredicate(spec.visibleOn, { data: row })` — so `data.type`
* means *this row's* `type`, which is what a per-entry rule wants. The root
* is still spelled `data` at every depth: there is no implicit row scope, so
* a BARE identifier (`type == 'formula'`) is unbound and the predicate faults
* open, showing the field for every row — the same fail-open direction the
* `current_user` note above describes, arriving from the other end. Prefix
* every reference with `data.` whether the field sits at the top level or
* inside a repeater.
*/
visibleWhen: ExpressionInputSchema.optional().describe("Visibility predicate (CEL) — field shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. No `current_user` at field level — it is unbound here and the predicate would fault open (per-option `visibleWhen` is the surface that binds it). e.g. P`record.priority == 'urgent'`"),
visibleWhen: ExpressionInputSchema.optional().describe("Visibility predicate (CEL) — field shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. No `current_user` at field level — it is unbound here and the predicate would fault open (per-option `visibleWhen` is the surface that binds it). Inside a repeater `data` is the ROW, but it is still spelled `data` — a bare identifier is unbound and faults open too. e.g. P`record.priority == 'urgent'`"),
/** @deprecated ADR-0089 — use `visibleWhen`. Accepted and normalized to `visibleWhen` at parse. */
visibleOn: ExpressionInputSchema.optional().describe('[DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse.'),
disclosure: z.enum(['inline', 'popover']).optional().describe('Composite rendering: inline bordered box (default) or a summary line + gear popover (progressive disclosure).'),
Expand Down
Loading