Skip to content

Commit 643b7c7

Browse files
qq9340100claude
andauthored
fix(spec): object.form.ts repeater 谓词按 data 绑定书写 (#6254) (#6330)
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 不动 —— 它本来就是对的。 Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY Co-authored-by: Claude <noreply@anthropic.com>
1 parent ad878e7 commit 643b7c7

4 files changed

Lines changed: 68 additions & 18 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): `object.form.ts` repeater predicates bind through `data`, like every other metadata form (#6254)
6+
7+
The object metadata form's field-list repeater carried 16 `visibleWhen`
8+
predicates written as **bare identifiers**:
9+
10+
- FROM: `visibleWhen: "type == 'formula'"`, `visibleWhen: "type in ['lookup','master_detail']"`, …
11+
- TO: `visibleWhen: "data.type == 'formula'"`, `visibleWhen: "data.type in ['lookup','master_detail']"`, …
12+
13+
Metadata-editing forms (`*.form.ts`) bind the row under edit as `data` — which
14+
is how the sibling `field.form.ts` has always written them (`data.type == 'text'`).
15+
The bare spelling has no binding at all: `type` is an unbound identifier, the
16+
predicate faults, and a faulted visibility predicate resolves to its fallback,
17+
`true`. Every constrained sub-field — `maxLength`, `min`/`max`, `precision`,
18+
`expression`, `returnType`, `reference`, `deleteBehavior`, `autonumberFormat`,
19+
… — was therefore offered on **every** field row regardless of its type, which
20+
is the exact opposite of what each rule asks for.
21+
22+
**The repeater does rebind `data`, and that is worth writing down.** A sub-field
23+
of a `type: 'record'` repeater is evaluated against its own row
24+
(`evaluatePredicate(spec.visibleOn, { data: row })` in the metadata form
25+
renderer), so `data.type` reads *this row's* type — precisely what a per-entry
26+
rule wants. What the repeater does **not** do is introduce an implicit row
27+
scope: the root is spelled `data` at every depth. `FormField.visibleWhen`'s
28+
JSDoc and `describe` now state both halves, so the next author does not have to
29+
infer either one.
30+
31+
`field.form.ts` is unchanged — it was already correct.
32+
33+
Authoring-surface fix with no schema or validation change, hence patch. Note
34+
that these predicates are not yet *observably* restored: a separate defect
35+
outside this package (the metadata form renderer reads the deprecated
36+
`visibleOn` key, while the parse emits only the canonical `visibleWhen`) keeps
37+
every metadata-form predicate inert today. That is tracked separately; this
38+
change is a prerequisite for it, and correct on its own terms either way.

content/docs/references/ui/view.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ Column footer summary configuration
159159
| **language** | `string` | optional | Code editor language (for type=code) |
160160
| **keyField** | `{ field?: string; label?: string; placeholder?: string; helpText?: string; … }` | optional | Key column config for record-typed fields |
161161
| **dependsOn** | `string` | optional | Parent field name for cascading |
162-
| **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'` |
162+
| **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'` |
163163
| **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse. |
164164
| **disclosure** | `Enum<'inline' \| 'popover'>` | optional | Composite rendering: inline bordered box (default) or a summary line + gear popover (progressive disclosure). |
165165
| **fields** | `[FormField](#formfield)[]` | optional | Sub-fields for composite/repeater/record types |

packages/spec/src/data/object.form.ts

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -117,21 +117,21 @@ export const objectForm = defineForm({
117117
// through `inlineHelpText` / `description`.
118118

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

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

129129
// Selection options
130130
{
131131
field: 'options',
132132
type: 'repeater',
133133
helpText: 'Available choices',
134-
visibleWhen: "type in ['select','multiselect','radio','checkboxes']",
134+
visibleWhen: "data.type in ['select','multiselect','radio','checkboxes']",
135135
fields: [
136136
{ field: 'label', type: 'text', required: true },
137137
{ field: 'value', type: 'text', required: true },
@@ -142,24 +142,24 @@ export const objectForm = defineForm({
142142
},
143143

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

158158
// Formula / summary
159159
// `expression`, not `formula` — the key is named for what it holds,
160160
// not for the field type that uses it.
161-
{ field: 'expression', type: 'code', language: 'expression', helpText: 'CEL formula expression', visibleWhen: "type == 'formula'" },
162-
{ field: 'returnType', type: 'select', helpText: 'Result type for formulas', visibleWhen: "type == 'formula'", options: [
161+
{ field: 'expression', type: 'code', language: 'expression', helpText: 'CEL formula expression', visibleWhen: "data.type == 'formula'" },
162+
{ field: 'returnType', type: 'select', helpText: 'Result type for formulas', visibleWhen: "data.type == 'formula'", options: [
163163
{ label: 'Text', value: 'text' }, { label: 'Number', value: 'number' }, { label: 'Boolean', value: 'boolean' },
164164
{ label: 'Date', value: 'date' }, { label: 'Datetime', value: 'datetime' }, { label: 'Currency', value: 'currency' },
165165
] },
@@ -170,7 +170,7 @@ export const objectForm = defineForm({
170170
field: 'summaryOperations',
171171
type: 'composite',
172172
helpText: 'Roll-up: which child object, which field, which aggregation',
173-
visibleWhen: "type == 'summary'",
173+
visibleWhen: "data.type == 'summary'",
174174
fields: [
175175
{ field: 'object', type: 'text', required: true, helpText: 'Source child object name' },
176176
{ 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({
184184
// Autonumber — `autonumberFormat`, not `displayFormat`. There is no
185185
// `startingNumber`: the counter resets per rendered prefix, which the
186186
// format string itself determines (e.g. AD{YYYYMMDD}{0000} resets daily).
187-
{ field: 'autonumberFormat', type: 'text', helpText: 'e.g. "INV-{0000}"; date tokens {YYYY}/{MM}/{DD} and {field_name} interpolation supported', visibleWhen: "type == 'autonumber'" },
187+
{ field: 'autonumberFormat', type: 'text', helpText: 'e.g. "INV-{0000}"; date tokens {YYYY}/{MM}/{DD} and {field_name} interpolation supported', visibleWhen: "data.type == 'autonumber'" },
188188

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

192192
// Governance. `validation` / `errorMessage` are not FieldSchema keys —
193193
// a record-level predicate is a `validation` metadata item on the

packages/spec/src/ui/view.zod.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1424,8 +1424,20 @@ const FormFieldBaseSchema = lazySchema(() => z.object({
14241424
* bound for **per-option** `visibleWhen` (a different evaluator —
14251425
* `resolveCascadingOptions` against the host's predicate scope, ADR-0068 /
14261426
* objectui#2284); that is the only `*When` surface where it resolves.
1427+
*
1428+
* **Inside a repeater, `data` is the ROW, not the whole document** (#6254).
1429+
* A sub-field of a `type: 'record'` / repeater field is rendered with its own
1430+
* activation — the metadata form renderer evaluates each sub-field predicate
1431+
* as `evaluatePredicate(spec.visibleOn, { data: row })` — so `data.type`
1432+
* means *this row's* `type`, which is what a per-entry rule wants. The root
1433+
* is still spelled `data` at every depth: there is no implicit row scope, so
1434+
* a BARE identifier (`type == 'formula'`) is unbound and the predicate faults
1435+
* open, showing the field for every row — the same fail-open direction the
1436+
* `current_user` note above describes, arriving from the other end. Prefix
1437+
* every reference with `data.` whether the field sits at the top level or
1438+
* inside a repeater.
14271439
*/
1428-
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'`"),
1440+
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'`"),
14291441
/** @deprecated ADR-0089 — use `visibleWhen`. Accepted and normalized to `visibleWhen` at parse. */
14301442
visibleOn: ExpressionInputSchema.optional().describe('[DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse.'),
14311443
disclosure: z.enum(['inline', 'popover']).optional().describe('Composite rendering: inline bordered box (default) or a summary line + gear popover (progressive disclosure).'),

0 commit comments

Comments
 (0)