From 42e8e005e8ef03d7c6c1c20269411591d0240306 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 13:44:27 +0000 Subject: [PATCH] =?UTF-8?q?fix(spec):=20object.form.ts=20repeater=20?= =?UTF-8?q?=E8=B0=93=E8=AF=8D=E6=8C=89=20data=20=E7=BB=91=E5=AE=9A?= =?UTF-8?q?=E4=B9=A6=E5=86=99=20(#6254)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit object 元数据表单的字段列表 repeater 里有 16 处 `visibleWhen` 写成**裸标识符**: FROM `visibleWhen: "type == 'formula'"` / `"type in ['lookup','master_detail']"` … TO `visibleWhen: "data.type == 'formula'"` / `"data.type in [...]"` … 元数据编辑表单(*.form.ts)把在编行绑为 `data` —— 兄弟文件 field.form.ts 一直就是这么写的(`data.type == 'text'`)。裸写法根本没有绑定:`type` 是未绑定 标识符,谓词 fault,而 fault 的可见性谓词落到 fallback `true`。于是 maxLength / min / max / precision / expression / returnType / reference / deleteBehavior / autonumberFormat 等每一个受约束子字段,都会在**任意**类型的 字段行上照常出现 —— 恰是每条规则所求的反面。 **repeater 确实会重绑 `data`,这一点值得写进契约。** `type: 'record'` repeater 的子字段是针对自己那一行求值的(元数据表单渲染器里的 `evaluatePredicate(spec.visibleOn, { data: row })`),所以 `data.type` 读到的是 *本行* 的 type —— 正是按行规则想要的。repeater **不**做的是引入隐式行级作用域: 根在任何深度都拼作 `data`。FormField.visibleWhen 的 JSDoc 与 describe 现在把 两半都写明,免得下一个作者去猜其中任何一边。 field.form.ts 不动 —— 它本来就是对的。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY --- .../object-form-repeater-data-binding.md | 38 +++++++++++++++++++ content/docs/references/ui/view.mdx | 2 +- packages/spec/src/data/object.form.ts | 32 ++++++++-------- packages/spec/src/ui/view.zod.ts | 12 +++++- 4 files changed, 66 insertions(+), 18 deletions(-) create mode 100644 .changeset/object-form-repeater-data-binding.md diff --git a/.changeset/object-form-repeater-data-binding.md b/.changeset/object-form-repeater-data-binding.md new file mode 100644 index 0000000000..482a5cb9db --- /dev/null +++ b/.changeset/object-form-repeater-data-binding.md @@ -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. diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 103ebb5534..31deb1de54 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -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`+`current_user` (runtime forms) or `data` (metadata forms). 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`+`current_user` (runtime forms) or `data` (metadata forms). Inside a repeater `data` is the ROW, but it is still spelled `data` — a bare identifier is unbound and faults open. 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 | diff --git a/packages/spec/src/data/object.form.ts b/packages/spec/src/data/object.form.ts index 94ad63cfd7..4d4f8f934b 100644 --- a/packages/spec/src/data/object.form.ts +++ b/packages/spec/src/data/object.form.ts @@ -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 }, @@ -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' }, ] }, @@ -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)' }, @@ -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 diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 7c625e4865..a474ea4ebb 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -1412,8 +1412,18 @@ const FormFieldBaseSchema = lazySchema(() => z.object({ * (ADR-0089, canonical `*When` name). Binding root depends on the surface: * runtime record forms bind `record` + `current_user`; metadata-editing forms * (`*.form.ts`) bind the row under edit as `data`. + * + * **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. 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`+`current_user` (runtime forms) or `data` (metadata forms). e.g. P`record.priority == 'urgent'`"), + visibleWhen: ExpressionInputSchema.optional().describe("Visibility predicate (CEL) — field shown only when TRUE. Root: `record`+`current_user` (runtime forms) or `data` (metadata forms). Inside a repeater `data` is the ROW, but it is still spelled `data` — a bare identifier is unbound and faults open. 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).'),