diff --git a/.changeset/record-picker-filter-rule-array.md b/.changeset/record-picker-filter-rule-array.md new file mode 100644 index 0000000000..67f9ef2f67 --- /dev/null +++ b/.changeset/record-picker-filter-rule-array.md @@ -0,0 +1,43 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec)!: `ComponentPropsMap['element:record_picker'].filter` converges onto the `ViewFilterRule` array form — the last record-form `filter` in the map (#14406, objectui#6206 Option B) + + + +**BREAKING** accept-set change on one props-map entry, shipped as `minor` under +the repo's launch-window convention for breaking changes; the migration +prescription is registered under protocol major 18. + +One filter orthography platform-wide (maintainer batch adjudication 2026-08-25, +verbatim 「同意」, Option B): after `element:number` converged (#12039 Key 2), +`element:record_picker`'s `filter` was the one `filter` input in +`ComponentPropsMap` still declared as the MongoDB-style record +(`FilterConditionSchema`) while the three array-declared siblings +(`record:related_list`, its nested Add-affordance picker, `element:number`) +declared `z.array(ViewFilterRuleSchema)` — the four `object-*` doors declare +`filter` as `z.unknown()`, #15449 — so the filter a list view stores and +renders was refused by the picker beside it. The entry now declares the same +array form those siblings do, and the `FilterConditionSchema` import that existed for this +one site leaves the file with it. + +Sequenced measurement-first, as that convergence had to be: the `record_picker` +read path was measured at the objectui pin before the declaration moved. The +renderer hands `filter` to `query.$filter` and calls `adapter.find()`, whose +`convertQueryParams` lowers a rule array through `translateFilterArray` into +filter AST tuples — the door every list view's stored rule array already takes +— and nothing on that path parses `properties` against the installed spec. + +**Migration** (`element-record-picker-filter-rule-array` — listed by +`os migrate meta --from 17` once the protocol major is 18): a record-form `filter: { status: 'active' }` becomes +`filter: [{ field: 'status', operator: 'equals', value: 'active' }]`; an operator +object `{ amount: { $gt: 100 } }` becomes +`[{ field: 'amount', operator: 'greater_than', value: 100 }]`; several keys +become several rules (they AND). The record form is refused at `filter` +(`invalid_type`, expected array). The binding-level `dataSource.filter` on the +same node is a different key and is unchanged by this release. + +`ElementRecordPickerPropsParsed` is declared (ADR-0122): the entry's parsed +state now differs from its authored state on `filter` (`operator` normalizes on +parse), so the bare alias is no longer isomorphic. diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 5712c73bf6..87f23995ee 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -212,7 +212,7 @@ View filter rule | **labelField** | `string` | optional | Field rendered as each row's text (default `name`) | | **valueField** | `string` | optional | Field whose value is written into the bound page variable (default `id`) | | **label** | `string \| Record` | optional | Control label rendered above the select | -| **filter** | `any` | optional | Filter criteria for available records | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter rules narrowing which records the picker offers — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography the array-declared `filter` doors of this map share. The MongoDB-style record form is refused — see migration `element-record-picker-filter-rule-array`. The binding-level `dataSource.filter` wins outright when both are set | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Row order — synonym of the component-level `dataSource.sort`, which takes precedence when both are set | | **limit** | `integer` | optional | Max records offered — synonym of the component-level `dataSource.limit`, which takes precedence when both are set (renderer default 50) | | **targetVariable** | `never` | optional | [REMOVED] `element:record_picker` property `targetVariable` was removed in @objectstack/spec 17 (ADR-0049) — it was a declarative hint no renderer ever read: the live binding runs the other direction, resolved from the page variable whose `source` names this component's `id`, so authoring only `targetVariable` bound nothing while reporting success. Delete the key; to bind the picked record id, declare it on the variable — `variables: [{ name: '', type: 'record_id', source: '' }]`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | @@ -223,6 +223,16 @@ View filter rule | **multiple** | `never` | optional | [REMOVED] `element:record_picker` property `multiple` was removed in @objectstack/spec 17.0.0 (ADR-0049) — the picker is a single-select `Select` and the bound page variable holds one record id, so `multiple: true` selected nothing extra and reported success. Delete the key; multi-record selection is not implemented on this element. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | +### Nested Shape: `ElementRecordPickerProps.filter[number]` + +View filter rule + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **field** | `string` | ✅ | Field name to filter on | +| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | + ### Nested Shape: `ElementRecordPickerProps.sort[number]` Sort field and direction pair diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index 4c1bcb7f30..f31a383e00 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -143,6 +143,7 @@ "ElementNumberPropsParsed (type)", "ElementNumberPropsSchema (const)", "ElementRecordPickerProps (type)", + "ElementRecordPickerPropsParsed (type)", "ElementRecordPickerPropsSchema (const)", "ElementTextInputPropsSchema (const)", "ElementTextPropsSchema (const)", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index 81015d2e31..bcc9a41f5e 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -143,6 +143,7 @@ "ElementNumberPropsParsed": "src/ui/component.zod.ts#ElementNumberPropsParsed (type)", "ElementNumberPropsSchema": "src/ui/component.zod.ts#ElementNumberPropsSchema (const)", "ElementRecordPickerProps": "src/ui/component.zod.ts#ElementRecordPickerProps (type)", + "ElementRecordPickerPropsParsed": "src/ui/component.zod.ts#ElementRecordPickerPropsParsed (type)", "ElementRecordPickerPropsSchema": "src/ui/component.zod.ts#ElementRecordPickerPropsSchema (const)", "ElementTextInputPropsSchema": "src/ui/component.zod.ts#ElementTextInputPropsSchema (const)", "ElementTextPropsSchema": "src/ui/component.zod.ts#ElementTextPropsSchema (const)", diff --git a/packages/spec/src/migrations/entries/semantic/18.element-record-picker-filter-rule-array.ts b/packages/spec/src/migrations/entries/semantic/18.element-record-picker-filter-rule-array.ts new file mode 100644 index 0000000000..210ba6590b --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.element-record-picker-filter-rule-array.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'element-record-picker-filter-rule-array', + surface: + "`element:record_picker` component props — `filter` (the FORM: the MongoDB-style " + + '`FilterConditionSchema` record vs the `ViewFilterRule` array)', + replacement: + '`z.array(ViewFilterRuleSchema)` — the rule array `[{ field, operator, value }, ...]` ' + + "the map's array-declared `filter` doors already carry (`record:related_list`, its nested " + + 'Add-affordance picker, `element:number`; the four `object-*` blocks declare `filter` as ' + + '`z.unknown()`, #15449). A record-form filter ' + + "`{ status: 'active' }` becomes `[{ field: 'status', operator: 'equals', value: 'active' }]`; " + + "an operator object `{ amount: { $gt: 100 } }` becomes " + + "`[{ field: 'amount', operator: 'greater_than', value: 100 }]`; several keys become " + + 'several rules (they AND). Legacy operator shorthands (`eq`, `gt`, `notIn`, …) are ' + + 'accepted and normalized on parse. The binding-level `dataSource.filter` on the same node ' + + 'is a different key (`ElementDataSourceSchema`) and is not moved by this entry', + reason: + 'One filter orthography platform-wide (objectui#6206, maintainer batch adjudication ' + + "2026-08-25, verbatim 「同意」, Option B). `ComponentPropsMap['element:record_picker'].filter` " + + 'was the LAST `filter` input in the map still declared as the MongoDB-style record ' + + '(`FilterConditionSchema`) after `element:number` converged (#12039 Key 2): the three ' + + 'array-declared doors (`record:related_list`, its nested Add-affordance picker, ' + + '`element:number`) carried the `ViewFilterRule` array and the four `object-*` doors ' + + 'declare `z.unknown()` (#15449), so the filter a list view stores and renders was refused ' + + 'by the picker beside them, and a lone holdout is the state where the next author copies ' + + 'the wrong form. Sequenced measurement-first, as that convergence had to be (the 2026-08-25 ' + + 'Option-A ordering ruling, #14406): at the objectui pin `00d3f09c` the renderer hands ' + + '`filter` to `query.$filter` and calls `adapter.find()` ' + + '(`components/src/renderers/basic/record-picker.tsx`); `ObjectStackAdapter.convertQueryParams` ' + + 'lowers an ARRAY `$filter` through `translateFilterArray` into filter AST tuples ' + + '(`data-objectstack/src/index.ts`), the same door every list view\'s stored rule array ' + + 'already takes, and the engine lowers the tuples before the driver ' + + '(`engine-filter-array-lowering.test.ts`); nothing on that path parses `properties` ' + + 'against the installed spec. The pin and objectui `main` (`f7cf7e8`) are byte-identical on ' + + 'every read-path file. The ruled migration check ran with the change: the sweep of ' + + 'first-party corpora (examples/, skills/, content/docs/, docs/, packages/**, .changeset/) ' + + 'found ONE `element:record_picker` author writing a record-form `filter` — a spec test ' + + 'fixture, rewritten to the array form in the same change — and zero outside the spec ' + + 'package; this entry carries the prescription for authors outside the repo.', + acceptanceCriteria: + "`ComponentPropsMap['element:record_picker'].safeParse({ object, filter: [{ field: " + + "'status', operator: 'equals', value: 'active' }] })` succeeds and the parsed `filter` is " + + "the same rule array; a record-form `filter: { status: 'active' }` is refused at the " + + '`filter` path (`invalid_type`, expected array). At runtime the picker offers exactly the ' + + 'rows the array selects — the same filter a list view renders. Downstream (objectui, after ' + + "a released spec version reaches the pin): the registry's `inputs.filter` entry for " + + "`element:record_picker` (`type: 'object'`, `record-picker.tsx`) flips to the array arm and " + + 'the `record-picker-inputs-spec-parity.test.ts` pins that assert the record form follow — ' + + 'objectui#7663, filed from #14406 with a Blocked-by line.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 4a18956f43..84751b0d9a 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -6415,6 +6415,56 @@ const step18: MigrationStep = { + '(`registry-inputs-spec-parity.test.ts`) becomes deletable, which is what closes ' + 'objectui#6206.', }, + { + id: 'element-record-picker-filter-rule-array', + surface: + "`element:record_picker` component props — `filter` (the FORM: the MongoDB-style " + + '`FilterConditionSchema` record vs the `ViewFilterRule` array)', + replacement: + '`z.array(ViewFilterRuleSchema)` — the rule array `[{ field, operator, value }, ...]` ' + + "the map's array-declared `filter` doors already carry (`record:related_list`, its nested " + + 'Add-affordance picker, `element:number`; the four `object-*` blocks declare `filter` as ' + + '`z.unknown()`, #15449). A record-form filter ' + + "`{ status: 'active' }` becomes `[{ field: 'status', operator: 'equals', value: 'active' }]`; " + + "an operator object `{ amount: { $gt: 100 } }` becomes " + + "`[{ field: 'amount', operator: 'greater_than', value: 100 }]`; several keys become " + + 'several rules (they AND). Legacy operator shorthands (`eq`, `gt`, `notIn`, …) are ' + + 'accepted and normalized on parse. The binding-level `dataSource.filter` on the same node ' + + 'is a different key (`ElementDataSourceSchema`) and is not moved by this entry', + reason: + 'One filter orthography platform-wide (objectui#6206, maintainer batch adjudication ' + + "2026-08-25, verbatim 「同意」, Option B). `ComponentPropsMap['element:record_picker'].filter` " + + 'was the LAST `filter` input in the map still declared as the MongoDB-style record ' + + '(`FilterConditionSchema`) after `element:number` converged (#12039 Key 2): the three ' + + 'array-declared doors (`record:related_list`, its nested Add-affordance picker, ' + + '`element:number`) carried the `ViewFilterRule` array and the four `object-*` doors ' + + 'declare `z.unknown()` (#15449), so the filter a list view stores and renders was refused ' + + 'by the picker beside them, and a lone holdout is the state where the next author copies ' + + 'the wrong form. Sequenced measurement-first, as that convergence had to be (the 2026-08-25 ' + + 'Option-A ordering ruling, #14406): at the objectui pin `00d3f09c` the renderer hands ' + + '`filter` to `query.$filter` and calls `adapter.find()` ' + + '(`components/src/renderers/basic/record-picker.tsx`); `ObjectStackAdapter.convertQueryParams` ' + + 'lowers an ARRAY `$filter` through `translateFilterArray` into filter AST tuples ' + + '(`data-objectstack/src/index.ts`), the same door every list view\'s stored rule array ' + + 'already takes, and the engine lowers the tuples before the driver ' + + '(`engine-filter-array-lowering.test.ts`); nothing on that path parses `properties` ' + + 'against the installed spec. The pin and objectui `main` (`f7cf7e8`) are byte-identical on ' + + 'every read-path file. The ruled migration check ran with the change: the sweep of ' + + 'first-party corpora (examples/, skills/, content/docs/, docs/, packages/**, .changeset/) ' + + 'found ONE `element:record_picker` author writing a record-form `filter` — a spec test ' + + 'fixture, rewritten to the array form in the same change — and zero outside the spec ' + + 'package; this entry carries the prescription for authors outside the repo.', + acceptanceCriteria: + "`ComponentPropsMap['element:record_picker'].safeParse({ object, filter: [{ field: " + + "'status', operator: 'equals', value: 'active' }] })` succeeds and the parsed `filter` is " + + "the same rule array; a record-form `filter: { status: 'active' }` is refused at the " + + '`filter` path (`invalid_type`, expected array). At runtime the picker offers exactly the ' + + 'rows the array selects — the same filter a list view renders. Downstream (objectui, after ' + + "a released spec version reaches the pin): the registry's `inputs.filter` entry for " + + "`element:record_picker` (`type: 'object'`, `record-picker.tsx`) flips to the array arm and " + + 'the `record-picker-inputs-spec-parity.test.ts` pins that assert the record form follow — ' + + 'objectui#7663, filed from #14406 with a Blocked-by line.', + }, { id: 'engine-dotted-filter-refused', surface: diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 855e5d60e7..f8c8df2da3 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -270,7 +270,7 @@ import type * as M183 from './api/sortability.zod.js'; import type * as M184 from './shared/value-domain.zod.js'; // --------------------------------------------------------------------------- -// 826 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 825 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -1557,7 +1557,10 @@ export type Iso817 = Assert, z.infer< type // own input ≠ infer (`operator` is normalized on parse — `ViewFilterRuleParsed` // exists for exactly that reason), so `ElementNumberPropsParsed` is declared // and the pin deleted. -export type Iso819 = Assert, z.infer< typeof M170.ElementRecordPickerPropsSchema > >>; +// `ElementRecordPickerPropsSchema` (Iso819) left the family the same way on +// #14406 — the LAST record-form `filter` in `ComponentPropsMap`: its `filter` +// now carries `z.array(ViewFilterRuleSchema)` too, so `ElementRecordPickerPropsParsed` +// is declared and this pin deleted. export type Iso820 = Assert, z.infer< typeof M170.RecordHighlightsField > >>; export type Iso821 = Assert, z.infer< typeof M170.RecordPathProps > >>; // `record:reference_rail` (#8691) — deliberately default-free on the same @@ -1682,7 +1685,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 826 isomorphic pins', () => { + it('still declares all 825 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -2133,7 +2136,17 @@ describe('ADR-0122 type-alias convention', () => { // `ErrorMappingRuleSchema` and `ConnectorErrorCategorySchema` left whole // with the key (whole-def removal, `RETIRED_DEFS_BY_MAJOR[18]`), so the // two pins that named them (`Iso381` / `Iso382`) leave with the schemas. - expect(pins).toHaveLength(826); + // + // 826 -> 825 is #14406's ui#6206-B convergence of the LAST record-form + // `filter` in `ComponentPropsMap`: `ElementRecordPickerPropsSchema.filter` + // now carries `z.array(ViewFilterRuleSchema)`, whose own input ≠ infer + // (`operator` is normalized on parse — `ViewFilterRuleParsed` exists for + // exactly that reason), so `element:record_picker` left the isomorphic + // family the way ADR-0122 prescribes and `element:number` did one entry + // earlier: `ElementRecordPickerPropsParsed` declared, the Iso819 pin + // deleted. -1 converted to an `XParsed` pair; the Iso number stays vacant + // (ids are claims about pins, not positions). + expect(pins).toHaveLength(825); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until #6605 nothing read either diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index 96f8e2a39c..c0fb9893c2 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -1667,13 +1667,17 @@ describe('Interactive Elements — element:record_picker', () => { labelField: 'name', valueField: 'id', label: 'Account', - filter: { status: 'active' }, + // The ViewFilterRule array form (ui#6206-B, #14406) — this fixture + // authored the record form `{ status: 'active' }` while the entry alone + // accepted it. + filter: [{ field: 'status', operator: 'equals', value: 'active' }], placeholder: 'Search accounts...', emptyText: 'No accounts', }); expect(props.labelField).toBe('name'); expect(props.valueField).toBe('id'); expect(props.label).toBe('Account'); + expect(props.filter).toEqual([{ field: 'status', operator: 'equals', value: 'active' }]); expect(props.emptyText).toBe('No accounts'); }); @@ -1809,6 +1813,113 @@ describe('Interactive Elements — element:record_picker', () => { }); }); +// --------------------------------------------------------------------------- +// element:record_picker `filter` — the ViewFilterRule ARRAY orthography (ui#6206-B, #14406) +// --------------------------------------------------------------------------- +describe("element:record_picker `filter` — one filter orthography platform-wide (ui#6206 Option B, #14406)", () => { + const picker = ComponentPropsMap['element:record_picker']; + const number = ComponentPropsMap['element:number']; + const relatedList = ComponentPropsMap['record:related_list']; + const RULES = [{ field: 'status', operator: 'equals', value: 'active' }]; + const RECORD_FORM = { status: 'active' }; + type ParseResult = { success: boolean; error?: { issues: Array<{ path: PropertyKey[]; code: string }> } }; + /** The issues a parse raised AT `key` (top-level), whatever else it raised. */ + const issuesAt = (r: ParseResult, key: string) => + r.success ? [] : r.error!.issues.filter((i) => i.path[0] === key); + + it('accepts a ViewFilterRule[] filter — the acceptance criterion', () => { + // Before #14406 this exact value was REFUSED here — the entry said + // `FilterConditionSchema`, the MongoDB-style record, the LAST one in the + // map — while every sibling `filter` input accepted it. Measured at the + // objectui pin before the declaration moved: the renderer hands the value + // to `query.$filter`, and `adapter.find()` lowers a rule array through + // `translateFilterArray`, so the array reaches the query. + const r = picker.safeParse({ object: 'account', filter: RULES }); + expect(r.success).toBe(true); + expect(r.data!.filter).toEqual(RULES); + }); + + it('the array carries the REAL ViewFilterRuleSchema, not a lookalike: operators normalize, value shapes are checked', () => { + // `eq` is a legacy spelling `normalizeFilterOperator` lowers to `equals` — a + // plain `z.array(z.object(...))` would have echoed it back unchanged. + const legacy = picker.safeParse({ + object: 'account', + filter: [{ field: 'status', operator: 'eq', value: 'active' }], + }); + expect(legacy.success).toBe(true); + expect(legacy.data!.filter![0].operator).toBe('equals'); + // `in` takes an array; a scalar is refused at `filter.0.value` by the rule's + // own superRefine — the value-shape check rides in with the schema. + const scalarIn = picker.safeParse({ + object: 'account', + filter: [{ field: 'status', operator: 'in', value: 'active' }], + }); + expect(scalarIn.success).toBe(false); + expect(scalarIn.error!.issues.map((i) => i.path.join('.'))).toContain('filter.0.value'); + }); + + it('the MongoDB-style record form — what this entry alone used to accept — is REFUSED at the `filter` path', () => { + // Reverse verification of the convergence, asserted on the issue envelope + // rather than on a bare `success === false`: the refusal is located at + // `filter` and names the expected kind. Migration: + // `element-record-picker-filter-rule-array`. + const r = picker.safeParse({ object: 'account', filter: RECORD_FORM }); + expect(r.success).toBe(false); + const atFilter = issuesAt(r, 'filter'); + expect(atFilter).toHaveLength(1); + expect(atFilter[0].code).toBe('invalid_type'); + expect(atFilter[0]).toMatchObject({ expected: 'array' }); + // An operator-object record and a `$and` group are the same form and get + // the same verdict — no arm accepts any spelling of the record. + const opRecord = picker.safeParse({ object: 'account', filter: { amount: { $gt: 100 } } }); + expect(issuesAt(opRecord, 'filter').map((i) => i.code)).toEqual(['invalid_type']); + const group = picker.safeParse({ object: 'account', filter: { $and: [{ status: 'active' }] } }); + expect(issuesAt(group, 'filter').map((i) => i.code)).toEqual(['invalid_type']); + }); + + it('shares the array orthography with the sibling `filter` inputs — one value, three doors, the same verdicts', () => { + // The ruling is "one filter orthography platform-wide" and this entry was + // the last holdout, so the pin is cross-entry: the same rule array raises + // no issue at `filter` on any of the three declared doors, and the same + // record form is refused at `filter` with the same issue code on all + // three. Each door is asked only about ITS `filter`. + expect(issuesAt(picker.safeParse({ object: 'account', filter: RULES }), 'filter')).toEqual([]); + expect(issuesAt(number.safeParse({ object: 'account', aggregate: 'count', filter: RULES }), 'filter')).toEqual([]); + expect(issuesAt(relatedList.safeParse({ filter: RULES }), 'filter')).toEqual([]); + const pickerRefusal = issuesAt(picker.safeParse({ object: 'account', filter: RECORD_FORM }), 'filter').map((i) => i.code); + expect(pickerRefusal).toEqual(['invalid_type']); + expect(issuesAt(number.safeParse({ object: 'account', aggregate: 'count', filter: RECORD_FORM }), 'filter').map((i) => i.code)) + .toEqual(pickerRefusal); + expect(issuesAt(relatedList.safeParse({ filter: RECORD_FORM }), 'filter').map((i) => i.code)).toEqual(pickerRefusal); + }); + + it('no top-level `filter` door in ComponentPropsMap refuses the rule array any more — the census the card closes', () => { + // The card's claim is "the last record-form `filter` in `ComponentPropsMap`". + // Asserted over the WHOLE map by shape rather than over the entries named + // above, so a future entry declaring `FilterConditionSchema` at `filter` + // (which refuses an array outright, `invalid_type`) is caught here by + // name. Doors that declare `filter` as `z.unknown()` (the `object-*` + // blocks hand it to the wire verbatim) accept both forms and are not + // holdouts of this census; the holdout shape is exactly "declares + // `filter`, refuses the array". + type Door = { shape?: Record; safeParse: (v: unknown) => ParseResult }; + const doors = (Object.entries(ComponentPropsMap) as Array<[string, unknown]>) + .filter(([, schema]) => { + const shape = (schema as Door).shape; + return !!shape && 'filter' in shape; + }) + .map(([type]) => type); + // Guard the probe: the three doors pinned above must be found, or the + // shape read has gone wrong and the loop below is vacuous. + expect(doors).toEqual(expect.arrayContaining(['element:record_picker', 'element:number', 'record:related_list'])); + const holdouts = doors.filter((type) => { + const r = (ComponentPropsMap[type as keyof typeof ComponentPropsMap] as unknown as Door).safeParse({ filter: RULES }); + return issuesAt(r, 'filter').length > 0; + }); + expect(holdouts).toEqual([]); + }); +}); + // --------------------------------------------------------------------------- // Interactive Elements — element:text_input // --------------------------------------------------------------------------- diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 0fb17a6c19..6a8ede4699 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -1,7 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { z } from 'zod'; -import { FilterConditionSchema } from '../data/filter.zod'; import { ViewFilterRuleSchema, ViewDataSchema } from './view.zod'; import { InlineActionSchema, ActionLocationSchema } from './action.zod'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; @@ -2181,7 +2180,39 @@ export const ElementRecordPickerPropsSchema = lazySchema(() => strictObject({ valueField: z.string().optional().describe('Field whose value is written into the bound page variable (default `id`)'), /** Control label rendered above the select. */ label: I18nLabelSchema.optional().describe('Control label rendered above the select'), - filter: FilterConditionSchema.optional().describe('Filter criteria for available records'), + /** + * Filter rules narrowing which records the picker offers — the + * `ViewFilterRule` ARRAY form, `[{ field, operator, value }, ...]`, the one + * filter orthography the map's array-declared `filter` doors share + * (`record:related_list`, its nested Add-affordance picker, and — since + * #12039 Key 2 — `element:number`; the four `object-*` blocks declare + * `filter` as `z.unknown()`, no orthography at all — #15449). Until #14406 + * this entry alone still said `FilterConditionSchema`, the MongoDB-style + * record form: the last record-form `filter` in `ComponentPropsMap` after + * the ui#6206 ruling (2026-08-25, Option B, verbatim 「同意」: one filter + * orthography platform-wide). + * + * Sequenced measurement-first, as the `element:number` convergence had to + * be (the 2026-08-25 Option-A ordering ruling): the read path was measured + * at the objectui pin (`00d3f09c`) before this declaration moved. The + * renderer hands the value to `query.$filter` and calls `adapter.find()` + * (`components/src/renderers/basic/record-picker.tsx`); + * `ObjectStackAdapter.convertQueryParams` lowers an ARRAY `$filter` through + * `translateFilterArray` — `[{ field, operator, value }]` → filter AST + * tuples (`data-objectstack/src/index.ts`) — the same door every list view's + * stored rule array already takes, and the engine lowers the tuples before + * the driver (`objectql/src/engine-filter-array-lowering.test.ts`). Nothing + * on that path parses `properties` against the installed spec, so no + * refusal stands between an authored array and the query. The record form + * is refused at `filter`; the migration prescription is the + * `element-record-picker-filter-rule-array` semantic entry. + * + * The binding-level `dataSource.filter` this shorthand yields to + * (`ds.filter ?? props.filter`) is `ElementDataSourceSchema`'s key, not this + * entry's subject. + */ + filter: z.array(ViewFilterRuleSchema).optional() + .describe('Filter rules narrowing which records the picker offers — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography the array-declared `filter` doors of this map share. The MongoDB-style record form is refused — see migration `element-record-picker-filter-rule-array`. The binding-level `dataSource.filter` wins outright when both are set'), /** * Row order (#6276). The flat shorthand for `dataSource.sort`, and the same * shape — `SortItemSchema[]`, the pairs the renderer forwards to the query as @@ -2257,6 +2288,15 @@ export const ElementRecordPickerPropsSchema = lazySchema(() => strictObject({ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), })); export type ElementRecordPickerProps = z.input; +/** + * ADR-0122: the parsed state differs from the authored state on exactly one + * key — `filter` carries `ViewFilterRuleSchema` (the ui#6206 convergence, + * #14406), whose own input ≠ infer (`operator` is normalized on parse, which + * is why `ViewFilterRuleParsed` exists). So `element:record_picker` leaves + * the type-alias convention pin's isomorphic family (the Iso819 line deleted + * with this alias), the route `element:number` took one entry earlier. + */ +export type ElementRecordPickerPropsParsed = z.infer; /** * A single-line free-text input — the data-entry half of an SDUI page (Airtable