diff --git a/.changeset/19085-metadata-form-declared-rows.md b/.changeset/19085-metadata-form-declared-rows.md new file mode 100644 index 00000000000..edba671e8aa --- /dev/null +++ b/.changeset/19085-metadata-form-declared-rows.md @@ -0,0 +1,17 @@ +--- +"@objectstack/spec": minor +"@objectstack/platform-objects": patch +--- + +`field.relatedListFilter` and `object.validations` are authorable in the metadata form. Both keys were **declared** by the served schema and offered by **no** form in `METADATA_FORM_REGISTRY`, so the generic metadata form never rendered a row for either and an author's only door was the Source tab — free-text JSON, where a mis-spelled sibling key is written, stored, and refused by the runtime later. + +Measured on the tree before the change: zero rows for either key across every `*.form.ts` in `packages/spec/src`, with a lit control (`maskingRule`, offered twice) and a dark control (a name no form carries) in the same read — so the zero is a reading, not a dead probe. + +**The face each row gets is a measurement, not a preference.** Both keys serve as JSON-Schema **pointer rows**, which is the shape a generic renderer cannot be assumed to resolve: + +- **`field.relatedListFilter` → `widget: 'filter-condition'`.** The served node is `{ $ref: '#/$defs/…' }` onto the recursive Query-DSL `FilterCondition`, whose derivation is `allOf: [open record, { $and/$or/$not }]` with **no top-level `type`** — there is nothing for the generic renderer to derive a control from. `filter-condition` names the FilterCondition wire, and this file already uses it one section down for `summaryOperations.filter`, the sibling `FilterConditionSchema` key. What the hint renders as **today**, measured at the pinned `.objectui-sha`, is the announced **raw-JSON editor carrying the hint** — not a criteria builder: the renderer that consumes this registry is the metadata-admin `SchemaForm`, whose own `WIDGETS` map registers no `filter-condition` (the `FilterConditionField` of that name lives in `@object-ui/fields`, on the ComponentRegistry path `ObjectForm` uses), and with the pointer unresolved neither structural fallback applies, so `resolveFieldFace` lands on `{ kind: 'raw-json', hint }` — the same face `summaryOperations.filter` gets. That editor hands `JSON.parse` output through verbatim and the save door judges it, so the wire is exact either way; the hint is the forward-looking half. ⛔ Deliberately **not** `filter-builder`: that widget consumes a rule **ARRAY** (what `view.filter`, `dataset.filter` and `page.filterBy` store), so routing this key there would write metadata the runtime refuses — the authoring trap this row exists to close, re-created one layer up. `visibleWhen` mirrors the key's own contract text (`lookup` / `master_detail`), a meaningfulness gate rather than a parse gate: `FieldSchema` accepts the key on every type, but the related-list derivation only ever reads it on the child-side FK. +- **`object.validations` → `widget: 'json'`.** The served node is an array whose items are a **double-hop** pointer (`items.$ref` → `$defs/__schema1` → `$defs/__schema2`) landing on a `oneOf` over the six `ValidationRule` members. A repeater would have to resolve both hops **and** pick a union branch before it could render a row; neither half is measured for this node, and a repeater that resolves neither renders an empty row whose values never land — the offer-vs-door defect the reconciliation gate beside it exists to catch. The Zod parse still refuses a malformed rule loudly at publish. Precisely: `json` is in that renderer's passthrough set, but the set is consulted **after** the structural fallbacks, not instead of them — so this row reaches the raw-JSON editor because the unresolved double-hop pointer derives nothing, not because the hint suppresses derivation. Once the pin moves past objectui's pointer resolution the same hint derives an `object-rows` repeater over the first `oneOf` branch; that is the renderer's precedence, not this repo's contract. Same treatment as the sibling structured-array rows `permission.rowLevelSecurity` and `email_template.variables`. Upgrading it to a structured control is a form-face addition, ⛔ not a reconciliation. + +A new pin (`metadata-form-declared-rows.pin.test.ts`) keeps both rows and both faces, and adds a registry-wide assertion — every row of every form, at every depth — that **no** form routes a `FilterCondition`-typed key to the rule-array builder, with a lit control proving the walk reaches both keys before it reports an empty misrouted set. + +⛔ **No wire byte moves and no export changes.** `check:api-surface` and `check:api-surface-declarations` are green with no regeneration: `METADATA_FORM_REGISTRY` is declared as an opaque `Readonly>`, so the row contents were never part of the declared surface. What changes is the **form payload** `getMetaTypes()` serves and the translation keys `os i18n extract` walks — hence the regenerated `platform-objects` metadata-form bundles (44 additive lines; the new `en` entries are source text, the translated locales still need translating). diff --git a/packages/lint/src/validate-predicate-path-refs.test.ts b/packages/lint/src/validate-predicate-path-refs.test.ts index 0156ee8305f..8263ff053f9 100644 --- a/packages/lint/src/validate-predicate-path-refs.test.ts +++ b/packages/lint/src/validate-predicate-path-refs.test.ts @@ -556,7 +556,22 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => { // `data.type == 'page'` — went with the key it configured. A form input for // an unwritable key is the false-compliant UI half of a retirement, so the // census falls by exactly one. - expect(predicates, 'the shipped metadata forms carry no predicates at all').toBe(52); + // It is 53 today, and this one is an ADDITION: #19085 gave + // `field.relatedListFilter` the form row its declaration always implied — + // the served schema declared the key while no form offered it, so an + // author's only door was the Source tab's free-text JSON. The row is gated + // `data.type in ['lookup','master_detail']`, mirroring the key's own + // contract text ("meaningful on a child's `master_detail`/`lookup` field") + // and the `reference` row beside it in the same form; `FieldSchema` accepts + // the key on every type, so that gate is a meaningfulness gate, not a parse + // gate — the same shape as the `valueDomain` entry above. + // Measured rather than inferred from the delta: the corpus was enumerated + // on this tree and on the merge base `eeaa882459`, and differenced by + // `
::::`. Exactly one entry was added — + // `field :: relatedListFilter :: data.type in ['lookup','master_detail']` + // — and NONE was removed. The same card's second row, `object.validations`, + // carries no predicate at all, so it does not enter this census. + expect(predicates, 'the shipped metadata forms carry no predicates at all').toBe(53); const findings = validatePredicatePathRefs(corrupted); expect(findings).toHaveLength(predicates); diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index b2447223e84..c61a9c8da36 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -249,6 +249,10 @@ export const enMetadataForms: NonNullable = { "enable.clone": { label: "Clone" }, + validations: { + label: "Validations", + helpText: "Object-level validation rules — an array of rule objects, e.g. [{ \"type\": \"script\", \"name\": \"amount_positive\", \"condition\": \"amount > 0\", \"message\": \"Amount must be positive\" }]. State-machine transition tables are declared here too (ADR-0020)" + }, datasource: { label: "Datasource", helpText: "Target datasource ID (default: \"default\")" @@ -424,6 +428,10 @@ export const enMetadataForms: NonNullable = { label: "Delete Behavior", helpText: "What happens when referenced record is deleted" }, + relatedListFilter: { + label: "Related List Filter", + helpText: "Default filter for this relationship's related list on the parent's detail page — AND-composed with the parent-record match, and the tab badge counts the same set" + }, expression: { label: "Expression", helpText: "CEL expression to calculate this field (makes it read-only)" diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 0f49d4a86ac..c7f2314cd0e 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -249,6 +249,10 @@ export const esESMetadataForms: NonNullable = "enable.clone": { label: "Clone" }, + validations: { + label: "Validations", + helpText: "Object-level validation rules — an array of rule objects, e.g. [{ \"type\": \"script\", \"name\": \"amount_positive\", \"condition\": \"amount > 0\", \"message\": \"Amount must be positive\" }]. State-machine transition tables are declared here too (ADR-0020)" + }, datasource: { label: "Fuente de datos", helpText: "ID de fuente de datos de destino (valor predeterminado: \"default\")" @@ -424,6 +428,10 @@ export const esESMetadataForms: NonNullable = label: "Comportamiento al eliminar", helpText: "Qué ocurre cuando se elimina el registro referenciado" }, + relatedListFilter: { + label: "Related List Filter", + helpText: "Default filter for this relationship's related list on the parent's detail page — AND-composed with the parent-record match, and the tab badge counts the same set" + }, expression: { label: "Expresión", helpText: "Expresión CEL para calcular este campo (lo hace de solo lectura)" diff --git a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts index 476ae94ab87..e363a4f12aa 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts @@ -66,6 +66,8 @@ export const esESGeneratedSourceHashes: Readonly> = { "metadataForms.email_template.fields.variables.helpText": "27197774c2cda633", "metadataForms.field.fields.placeholder.helpText": "07cecac0844860be", "metadataForms.field.fields.placeholder.label": "44d62b55b63fe718", + "metadataForms.field.fields.relatedListFilter.helpText": "4d4f65b4886840a7", + "metadataForms.field.fields.relatedListFilter.label": "0ba1fca22ec231f1", "metadataForms.field.fields.rows.helpText": "66755b9447e67a15", "metadataForms.field.fields.rows.label": "319af74cc41ea823", "metadataForms.field.fields.summaryOperations.field.helpText": "b6897e7341b31c09", @@ -161,6 +163,8 @@ export const esESGeneratedSourceHashes: Readonly> = { "metadataForms.object.fields.lifecycle.ttl.field.label": "e21c314685cd95ca", "metadataForms.object.fields.lifecycle.ttl.helpText": "9b125bc1584a941f", "metadataForms.object.fields.lifecycle.ttl.label": "e37616f70b0d157a", + "metadataForms.object.fields.validations.helpText": "1f6e59e364995ce8", + "metadataForms.object.fields.validations.label": "06fc6dd288348779", "metadataForms.page.fields.interfaceConfig.addRecord.helpText": "3eb7b86c3a630db9", "metadataForms.page.fields.interfaceConfig.addRecord.label": "01068706d8b5418a", "metadataForms.page.fields.interfaceConfig.allowPrinting.helpText": "b8fa84e13cd1432c", diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index 02a47bb95fd..5cfe6544361 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -249,6 +249,10 @@ export const jaJPMetadataForms: NonNullable = "enable.clone": { label: "Clone" }, + validations: { + label: "Validations", + helpText: "Object-level validation rules — an array of rule objects, e.g. [{ \"type\": \"script\", \"name\": \"amount_positive\", \"condition\": \"amount > 0\", \"message\": \"Amount must be positive\" }]. State-machine transition tables are declared here too (ADR-0020)" + }, datasource: { label: "データソース", helpText: "対象データソース ID(既定: \"default\")" @@ -424,6 +428,10 @@ export const jaJPMetadataForms: NonNullable = label: "削除動作", helpText: "参照先レコード削除時の動作" }, + relatedListFilter: { + label: "Related List Filter", + helpText: "Default filter for this relationship's related list on the parent's detail page — AND-composed with the parent-record match, and the tab badge counts the same set" + }, expression: { label: "式", helpText: "このフィールドを計算する CEL 式(読み取り専用化)" diff --git a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts index 6145060b5af..2962b7a4356 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts @@ -66,6 +66,8 @@ export const jaJPGeneratedSourceHashes: Readonly> = { "metadataForms.email_template.fields.variables.helpText": "27197774c2cda633", "metadataForms.field.fields.placeholder.helpText": "07cecac0844860be", "metadataForms.field.fields.placeholder.label": "44d62b55b63fe718", + "metadataForms.field.fields.relatedListFilter.helpText": "4d4f65b4886840a7", + "metadataForms.field.fields.relatedListFilter.label": "0ba1fca22ec231f1", "metadataForms.field.fields.rows.helpText": "66755b9447e67a15", "metadataForms.field.fields.rows.label": "319af74cc41ea823", "metadataForms.field.fields.summaryOperations.field.helpText": "b6897e7341b31c09", @@ -161,6 +163,8 @@ export const jaJPGeneratedSourceHashes: Readonly> = { "metadataForms.object.fields.lifecycle.ttl.field.label": "e21c314685cd95ca", "metadataForms.object.fields.lifecycle.ttl.helpText": "9b125bc1584a941f", "metadataForms.object.fields.lifecycle.ttl.label": "e37616f70b0d157a", + "metadataForms.object.fields.validations.helpText": "1f6e59e364995ce8", + "metadataForms.object.fields.validations.label": "06fc6dd288348779", "metadataForms.page.fields.interfaceConfig.addRecord.helpText": "3eb7b86c3a630db9", "metadataForms.page.fields.interfaceConfig.addRecord.label": "01068706d8b5418a", "metadataForms.page.fields.interfaceConfig.allowPrinting.helpText": "b8fa84e13cd1432c", diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index 8c67614f7bd..8b5914e9605 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -249,6 +249,10 @@ export const zhCNMetadataForms: NonNullable = "enable.clone": { label: "Clone" }, + validations: { + label: "Validations", + helpText: "Object-level validation rules — an array of rule objects, e.g. [{ \"type\": \"script\", \"name\": \"amount_positive\", \"condition\": \"amount > 0\", \"message\": \"Amount must be positive\" }]. State-machine transition tables are declared here too (ADR-0020)" + }, datasource: { label: "数据源", helpText: "目标数据源 ID(默认:\"default\")" @@ -424,6 +428,10 @@ export const zhCNMetadataForms: NonNullable = label: "删除行为", helpText: "被引用记录删除时的处理方式" }, + relatedListFilter: { + label: "Related List Filter", + helpText: "Default filter for this relationship's related list on the parent's detail page — AND-composed with the parent-record match, and the tab badge counts the same set" + }, expression: { label: "表达式", helpText: "用 CEL 表达式计算此字段的值(自动设为只读)" diff --git a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts index f9988444b81..625e5efd321 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts @@ -65,6 +65,8 @@ export const zhCNGeneratedSourceHashes: Readonly> = { "metadataForms.doc.label": "3f35cf5088b999ad", "metadataForms.field.fields.placeholder.helpText": "07cecac0844860be", "metadataForms.field.fields.placeholder.label": "44d62b55b63fe718", + "metadataForms.field.fields.relatedListFilter.helpText": "4d4f65b4886840a7", + "metadataForms.field.fields.relatedListFilter.label": "0ba1fca22ec231f1", "metadataForms.field.fields.rows.helpText": "66755b9447e67a15", "metadataForms.field.fields.rows.label": "319af74cc41ea823", "metadataForms.field.fields.summaryOperations.field.helpText": "b6897e7341b31c09", @@ -128,6 +130,8 @@ export const zhCNGeneratedSourceHashes: Readonly> = { "metadataForms.object.fields.fields.valueDomain.label": "18ebeb7e56e792a1", "metadataForms.object.fields.fields.visibleWhen.helpText": "46de2d3ff57b6667", "metadataForms.object.fields.fields.visibleWhen.label": "c852d4249db93285", + "metadataForms.object.fields.validations.helpText": "1f6e59e364995ce8", + "metadataForms.object.fields.validations.label": "06fc6dd288348779", "metadataForms.page.fields.interfaceConfig.addRecord.helpText": "3eb7b86c3a630db9", "metadataForms.page.fields.interfaceConfig.addRecord.label": "01068706d8b5418a", "metadataForms.page.fields.interfaceConfig.allowPrinting.helpText": "b8fa84e13cd1432c", diff --git a/packages/spec/src/data/field.form.ts b/packages/spec/src/data/field.form.ts index 29d30033c5a..9c3e123dc9e 100644 --- a/packages/spec/src/data/field.form.ts +++ b/packages/spec/src/data/field.form.ts @@ -102,6 +102,49 @@ export const fieldForm = defineForm({ { label: 'Cascade (delete children)', value: 'cascade' }, { label: 'Restrict (block the delete)', value: 'restrict' }, ] }, + // #19085 — `relatedListFilter` gets the row its declaration always + // implied. The served schema DECLARED the key and no form offered it, + // so an author's only door was the Source tab's free-text JSON, where + // nothing validates the sibling key they invent until the runtime + // refuses it. + // + // The face is `filter-condition`, ⛔ NOT `filter-builder`: the two + // widgets speak different wires. `filter-builder` consumes a rule + // ARRAY (what `view.filter`, `dataset.filter` and `page.filterBy` + // store); this key is a canonical Query-DSL `FilterCondition` — an + // object keyed by field, with `$and`/`$or`/`$not`. Routing it to the + // array widget would write metadata the runtime refuses, which is the + // authoring trap this row exists to close, re-created one layer up. + // `filter-condition` names the FilterCondition wire, and this file + // already uses it one section down for `summaryOperations.filter`, the + // sibling FilterConditionSchema key. + // + // ⚠ What the hint renders as TODAY, measured at the pinned + // `.objectui-sha`, is the announced raw-JSON editor carrying the hint — + // ⛔ NOT a criteria builder. The renderer that consumes this registry is + // the metadata-admin `SchemaForm`, whose own `WIDGETS` map registers no + // `filter-condition` (the `FilterConditionField` of that name lives in + // `@object-ui/fields`, on the ComponentRegistry path `ObjectForm` uses, + // not this one), and its `resolveFieldFace` falls past the registry, + // past both structural fallbacks — the served node is + // `{ $ref: '#/$defs/…' }` onto the recursive FilterCondition, an + // `allOf: [open record, { $and/$or/$not }]` with NO top-level `type`, + // so neither an object form nor an array-of-objects can be derived + // (objectui#9912 measured the same for 12 of the 14 served pointer + // rows) — and lands on `{ kind: 'raw-json', hint }`. That is the same + // face `summaryOperations.filter` gets, it hands `JSON.parse` output + // through verbatim, and the save door judges it, so the wire is exact + // either way. The hint is the forward-looking half: it is what a + // renderer resolves when it can, and it is ⛔ never `filter-builder`. + // + // `visibleWhen` mirrors the key's own contract text — "it is + // meaningful on a child's `master_detail`/`lookup` field" — and the + // `reference` row above. The schema accepts the key on every type, so + // this is a MEANINGFULNESS gate, not a parse gate: on a non-reference + // field the related-list derivation never reads it, and offering a + // knob the runtime does not deliver is what Prime Directive #10 + // forbids. + { field: 'relatedListFilter', widget: 'filter-condition', visibleWhen: "data.type in ['lookup','master_detail']", helpText: "Default filter for this relationship's related list on the parent's detail page — AND-composed with the parent-record match, and the tab badge counts the same set" }, ], }, { diff --git a/packages/spec/src/data/object.form.ts b/packages/spec/src/data/object.form.ts index df90385d8ed..338d795fff5 100644 --- a/packages/spec/src/data/object.form.ts +++ b/packages/spec/src/data/object.form.ts @@ -357,6 +357,44 @@ export const objectForm = defineForm({ collapsible: true, collapsed: true, fields: [ + // #19085 — `validations` gets the row its declaration always implied. + // The served schema DECLARED the key and no form offered it, so an + // author's only door was the Source tab's free-text JSON. This section + // already advertises "State machines" in its description, and a state + // machine IS a `validations` member (ADR-0020) — the row was missing, + // not the section. + // + // The face is the `json` control rather than a schema-derived + // repeater, and that is a measurement, not a preference. The served + // node is an array whose items are a DOUBLE-HOP pointer + // (`items.$ref` → `$defs/__schema1` → `$defs/__schema2`) landing on a + // `oneOf` over the six ValidationRule members. A repeater would have + // to resolve both hops AND pick a union branch before it could render + // a row; neither half is measured for this node, and a repeater that + // resolves neither renders an empty row whose values never land — the + // offer-vs-door defect the reconciliation gate beside this file + // exists to catch. The Zod parse still refuses a malformed rule loudly + // at publish. Same treatment as the sibling structured-array rows + // `permission.rowLevelSecurity` and `email_template.variables`. + // + // ⚠ Precisely: `json` is in the metadata-admin renderer's passthrough + // set, but that set is consulted AFTER the structural fallbacks, ⛔ not + // instead of them — `resolveFieldFace` tries the widget registry, then + // an object form, then an array-of-objects, and only then the + // passthrough check. So this row reaches the raw-JSON editor today + // because the unresolved double-hop pointer derives nothing, ⛔ not + // because the hint suppresses derivation. Once the pin moves past + // objectui's pointer resolution the same hint on this node derives an + // `object-rows` repeater over the FIRST `oneOf` branch (`script`) — + // that is the renderer's precedence, not this repo's contract, and + // whoever bumps the pin owns re-measuring this row and its two + // shape-siblings named above. + // + // Upgrading this to a structured control is a form-face addition — the + // same boundary the reconciliation ledger draws for the + // `lifecycle.*.onlyWhen` rows — ⛔ not a reconciliation, and not this + // row's price of admission. + { field: 'validations', widget: 'json', helpText: 'Object-level validation rules — an array of rule objects, e.g. [{ "type": "script", "name": "amount_positive", "condition": "amount > 0", "message": "Amount must be positive" }]. State-machine transition tables are declared here too (ADR-0020)' }, { field: 'datasource', type: 'text', helpText: 'Target datasource ID (default: "default")' }, { field: 'lifecycle', diff --git a/packages/spec/src/system/metadata-form-declared-rows.pin.test.ts b/packages/spec/src/system/metadata-form-declared-rows.pin.test.ts new file mode 100644 index 00000000000..b13bbc3ffe9 --- /dev/null +++ b/packages/spec/src/system/metadata-form-declared-rows.pin.test.ts @@ -0,0 +1,170 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * **Declared keys with no form row** (#19085). + * + * `field.relatedListFilter` and `object.validations` were DECLARED by the + * served schema and offered by no form in {@link METADATA_FORM_REGISTRY}, so + * the generic metadata form never rendered them and an author's only door was + * the Source tab — free-text JSON, where a mis-spelled sibling key is written, + * stored, and refused later by the runtime. The two rows landed with this + * file; this pin keeps them, and keeps the FACE each one was given. + * + * ## Why a pin, when a reconciliation gate already exists + * + * `metadata-form-zod-reconciliation.test.ts` reconciles the two directions + * asymmetrically, and only ONE of them at the top level: + * + * | direction | top level | nested lists | + * |---|---|---| + * | form-only (offered, not declared) | checked | checked | + * | retired (offered, tombstoned) | checked | checked | + * | zod-only (declared, not offered) | **unchecked** | checked (ledgerable) | + * + * So the class this card is about — a top-level key the Zod accepts and the + * form does not offer — is exactly the one the gate does not read, which is + * why both keys sat missing with every gate green. Closing that direction + * generally is not a pin: at the commit that landed this file the top-level + * zod-only set was **276 keys across the 17 forms**, each needing an offer or + * a ledgered reason. That census is its own card; this file pins the two keys + * measured on #19085 so they cannot silently regress in the meantime. + * + * ## The faces are load-bearing, not cosmetic + * + * `relatedListFilter` is pinned to `filter-condition` AND against + * `filter-builder`. The two widgets speak different wires — `filter-builder` + * consumes a rule ARRAY, while this key is a Query-DSL `FilterCondition`, an + * object keyed by field with `$and`/`$or`/`$not` — so "upgrading" the row to + * the visual builder would write metadata the runtime refuses, re-creating the + * very trap the row closes. The negative half is therefore an assertion, not a + * comment. + */ + +import { describe, it, expect } from 'vitest'; + +import { METADATA_FORM_REGISTRY } from './metadata-form-registry'; + +type FormEntry = { + field?: string; + widget?: string; + type?: string; + helpText?: string; + visibleWhen?: string | { dialect?: string; source?: string }; + fields?: FormEntry[]; +}; + +/** + * Keys whose value is a canonical Query-DSL `FilterCondition` — an object + * keyed by field with `$and`/`$or`/`$not`, NOT the rule array the visual + * `filter-builder` writes. Named by key rather than resolved from the Zod so + * the assertion below stays readable; both entries are pinned against their + * schema declaration in `field.test.ts`. + */ +const FILTER_CONDITION_KEYS = new Set(['relatedListFilter', 'summaryOperations.filter']); + +/** Every row of every registered form, at any depth, keyed by dotted path. */ +function allRows(): Array<{ type: string; path: string; row: FormEntry }> { + const out: Array<{ type: string; path: string; row: FormEntry }> = []; + const walk = (type: string, entries: FormEntry[], prefix: string) => { + for (const row of entries) { + if (!row?.field) continue; + const path = prefix ? `${prefix}.${row.field}` : row.field; + out.push({ type, path, row }); + if (Array.isArray(row.fields)) walk(type, row.fields, path); + } + }; + for (const [type, form] of Object.entries(METADATA_FORM_REGISTRY)) { + for (const section of ((form as any)?.sections ?? []) as Array<{ fields?: FormEntry[] }>) { + walk(type, section.fields ?? [], ''); + } + } + return out; +} + +/** Every top-level row of a form, across its sections. */ +function topLevelRows(form: unknown): FormEntry[] { + const rows: FormEntry[] = []; + for (const section of ((form as any)?.sections ?? []) as Array<{ fields?: FormEntry[] }>) { + for (const entry of section.fields ?? []) if (entry?.field) rows.push(entry); + } + return rows; +} + +const rowsFor = (type: string) => topLevelRows(METADATA_FORM_REGISTRY[type]); +const rowFor = (type: string, key: string) => rowsFor(type).filter((r) => r.field === key); + +describe('declared keys that now have a form row (#19085)', () => { + // The probe is only a reading if it can also report a zero. Both controls + // run against the SAME helper the assertions use, so a helper that stopped + // finding rows would fail here rather than passing everything vacuously. + it('CONTROLS: the row probe finds a known row and reports a known absence', () => { + expect(rowsFor('field').length, 'the field form has top-level rows at all').toBeGreaterThan(10); + expect(rowFor('field', 'maxLength'), 'lit control: a row that has always been offered').toHaveLength(1); + expect(rowFor('field', 'noSuchKeyAtAll'), 'dark control: a key no form offers').toHaveLength(0); + }); + + it('field.relatedListFilter is offered, and documented', () => { + const [row, ...extra] = rowFor('field', 'relatedListFilter'); + expect(row, '`relatedListFilter` is declared by FieldSchema; the field form must offer it').toBeDefined(); + expect(extra, 'one row only — a split offer needs disjoint visibleWhen and its own reason').toEqual([]); + // "Loud" is the point: a row with no help text sends the author back to + // guessing the wire, which is the Source tab with extra steps. + expect(row.helpText?.length ?? 0).toBeGreaterThan(20); + }); + + it('field.relatedListFilter is routed to the FilterCondition widget', () => { + const [row] = rowFor('field', 'relatedListFilter'); + expect(row.widget, 'the key is a Query-DSL FilterCondition object').toBe('filter-condition'); + }); + + it('NO registered form routes a FilterCondition-typed key to the rule-array builder', () => { + // The negative half, written so it can actually fail: it reads every row + // of every form at every depth, not just the one this card added. A + // future author "upgrading" either key to the visual builder — the same + // widget `view.filter` and `dataset.filter` legitimately use — would be + // writing an array where the runtime reads an object. + const rows = allRows(); + expect(rows.length, 'the walk reaches rows at all').toBeGreaterThan(100); + + const matched = rows.filter((r) => FILTER_CONDITION_KEYS.has(r.path)); + // Lit control: both keys are actually reachable by this walk, so an empty + // `misrouted` below is a measured zero rather than a walk that found + // nothing to judge. + expect(matched.map((r) => `${r.type}.${r.path}`).sort()).toEqual([ + 'field.relatedListFilter', + 'field.summaryOperations.filter', + ]); + + expect( + matched.filter((r) => r.row.widget === 'filter-builder').map((r) => `${r.type}.${r.path}`), + '`filter-builder` consumes a rule ARRAY — routing a FilterCondition key there writes metadata the runtime refuses', + ).toEqual([]); + // And each one still names the widget that speaks its wire. + expect(matched.every((r) => r.row.widget === 'filter-condition')).toBe(true); + }); + + it('field.relatedListFilter is gated to the field types its contract calls meaningful', () => { + const [row] = rowFor('field', 'relatedListFilter'); + // FieldSchema accepts the key on every type — this is a meaningfulness + // gate, not a parse gate. The related-list derivation only ever reads it + // on the child-side FK, so offering it elsewhere would advertise a knob + // the runtime does not deliver. + // `defineForm` normalises a predicate into a `{ dialect, source }` + // envelope, so the assertion reads the envelope rather than the string it + // was authored as. + expect(row.visibleWhen).toEqual({ dialect: 'cel', source: "data.type in ['lookup','master_detail']" }); + }); + + it('object.validations is offered, and documented with a worked example', () => { + const [row, ...extra] = rowFor('object', 'validations'); + expect(row, '`validations` is declared by ObjectSchema; the object form must offer it').toBeDefined(); + expect(extra).toEqual([]); + expect(row.widget, 'a double-hop $ref onto a 6-member oneOf — the passthrough control, not a derived repeater').toBe( + 'json', + ); + // The helpText carries the shape, because the control does not: a JSON + // door with no example is the Source tab wearing a label. + expect(row.helpText).toContain('"type"'); + expect(row.helpText).toContain('"name"'); + }); +});