Skip to content

Commit 414c1fc

Browse files
os-salesclaude
andauthored
feat(spec)!: element:record_picker.filter converges onto the ViewFilterRule array form — the last record-form filter in ComponentPropsMap (objectui#6206 Option B) (#15456)
* wip: element:record_picker.filter converges onto the ViewFilterRule array form Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 * wip: rejection and census tests, regenerated docs and export-origins Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 * wip: regenerate api-surface for ElementRecordPickerPropsParsed Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 * fix(spec): state the filter-door census correctly in the shipped prose; migrate command listed by --from 17 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 * fix(spec): reword the picker filter description without an apostrophe; regenerate followers after merging main Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b548e43 commit 414c1fc

9 files changed

Lines changed: 331 additions & 8 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
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)
6+
7+
<!-- adr-0087: registered element-record-picker-filter-rule-array -->
8+
9+
**BREAKING** accept-set change on one props-map entry, shipped as `minor` under
10+
the repo's launch-window convention for breaking changes; the migration
11+
prescription is registered under protocol major 18.
12+
13+
One filter orthography platform-wide (maintainer batch adjudication 2026-08-25,
14+
verbatim 「同意」, Option B): after `element:number` converged (#12039 Key 2),
15+
`element:record_picker`'s `filter` was the one `filter` input in
16+
`ComponentPropsMap` still declared as the MongoDB-style record
17+
(`FilterConditionSchema`) while the three array-declared siblings
18+
(`record:related_list`, its nested Add-affordance picker, `element:number`)
19+
declared `z.array(ViewFilterRuleSchema)` — the four `object-*` doors declare
20+
`filter` as `z.unknown()`, #15449 — so the filter a list view stores and
21+
renders was refused by the picker beside it. The entry now declares the same
22+
array form those siblings do, and the `FilterConditionSchema` import that existed for this
23+
one site leaves the file with it.
24+
25+
Sequenced measurement-first, as that convergence had to be: the `record_picker`
26+
read path was measured at the objectui pin before the declaration moved. The
27+
renderer hands `filter` to `query.$filter` and calls `adapter.find()`, whose
28+
`convertQueryParams` lowers a rule array through `translateFilterArray` into
29+
filter AST tuples — the door every list view's stored rule array already takes
30+
— and nothing on that path parses `properties` against the installed spec.
31+
32+
**Migration** (`element-record-picker-filter-rule-array` — listed by
33+
`os migrate meta --from 17` once the protocol major is 18): a record-form `filter: { status: 'active' }` becomes
34+
`filter: [{ field: 'status', operator: 'equals', value: 'active' }]`; an operator
35+
object `{ amount: { $gt: 100 } }` becomes
36+
`[{ field: 'amount', operator: 'greater_than', value: 100 }]`; several keys
37+
become several rules (they AND). The record form is refused at `filter`
38+
(`invalid_type`, expected array). The binding-level `dataSource.filter` on the
39+
same node is a different key and is unchanged by this release.
40+
41+
`ElementRecordPickerPropsParsed` is declared (ADR-0122): the entry's parsed
42+
state now differs from its authored state on `filter` (`operator` normalizes on
43+
parse), so the bare alias is no longer isomorphic.

content/docs/references/ui/component.mdx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ View filter rule
212212
| **labelField** | `string` | optional | Field rendered as each row's text (default `name`) |
213213
| **valueField** | `string` | optional | Field whose value is written into the bound page variable (default `id`) |
214214
| **label** | `string \| Record<string, string>` | optional | Control label rendered above the select |
215-
| **filter** | `any` | optional | Filter criteria for available records |
215+
| **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 |
216216
| **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Row order — synonym of the component-level `dataSource.sort`, which takes precedence when both are set |
217217
| **limit** | `integer` | optional | Max records offered — synonym of the component-level `dataSource.limit`, which takes precedence when both are set (renderer default 50) |
218218
| **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: '<var>', type: 'record_id', source: '<this component id>' }]`. 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
223223
| **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. |
224224
| **aria** | `{ ariaLabel?: string \| Record<string, string>; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |
225225

226+
### Nested Shape: `ElementRecordPickerProps.filter[number]`
227+
228+
View filter rule
229+
230+
| Property | Type | Required | Description |
231+
| :--- | :--- | :--- | :--- |
232+
| **field** | `string` || Field name to filter on |
233+
| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` || Filter operator |
234+
| **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. |
235+
226236
### Nested Shape: `ElementRecordPickerProps.sort[number]`
227237

228238
Sort field and direction pair

packages/spec/api-surface/ui.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@
143143
"ElementNumberPropsParsed (type)",
144144
"ElementNumberPropsSchema (const)",
145145
"ElementRecordPickerProps (type)",
146+
"ElementRecordPickerPropsParsed (type)",
146147
"ElementRecordPickerPropsSchema (const)",
147148
"ElementTextInputPropsSchema (const)",
148149
"ElementTextPropsSchema (const)",

packages/spec/export-origins/ui.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@
143143
"ElementNumberPropsParsed": "src/ui/component.zod.ts#ElementNumberPropsParsed (type)",
144144
"ElementNumberPropsSchema": "src/ui/component.zod.ts#ElementNumberPropsSchema (const)",
145145
"ElementRecordPickerProps": "src/ui/component.zod.ts#ElementRecordPickerProps (type)",
146+
"ElementRecordPickerPropsParsed": "src/ui/component.zod.ts#ElementRecordPickerPropsParsed (type)",
146147
"ElementRecordPickerPropsSchema": "src/ui/component.zod.ts#ElementRecordPickerPropsSchema (const)",
147148
"ElementTextInputPropsSchema": "src/ui/component.zod.ts#ElementTextInputPropsSchema (const)",
148149
"ElementTextPropsSchema": "src/ui/component.zod.ts#ElementTextPropsSchema (const)",
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
export const entry: SemanticMigration = {
6+
id: 'element-record-picker-filter-rule-array',
7+
surface:
8+
"`element:record_picker` component props — `filter` (the FORM: the MongoDB-style "
9+
+ '`FilterConditionSchema` record vs the `ViewFilterRule` array)',
10+
replacement:
11+
'`z.array(ViewFilterRuleSchema)` — the rule array `[{ field, operator, value }, ...]` '
12+
+ "the map's array-declared `filter` doors already carry (`record:related_list`, its nested "
13+
+ 'Add-affordance picker, `element:number`; the four `object-*` blocks declare `filter` as '
14+
+ '`z.unknown()`, #15449). A record-form filter '
15+
+ "`{ status: 'active' }` becomes `[{ field: 'status', operator: 'equals', value: 'active' }]`; "
16+
+ "an operator object `{ amount: { $gt: 100 } }` becomes "
17+
+ "`[{ field: 'amount', operator: 'greater_than', value: 100 }]`; several keys become "
18+
+ 'several rules (they AND). Legacy operator shorthands (`eq`, `gt`, `notIn`, …) are '
19+
+ 'accepted and normalized on parse. The binding-level `dataSource.filter` on the same node '
20+
+ 'is a different key (`ElementDataSourceSchema`) and is not moved by this entry',
21+
reason:
22+
'One filter orthography platform-wide (objectui#6206, maintainer batch adjudication '
23+
+ "2026-08-25, verbatim 「同意」, Option B). `ComponentPropsMap['element:record_picker'].filter` "
24+
+ 'was the LAST `filter` input in the map still declared as the MongoDB-style record '
25+
+ '(`FilterConditionSchema`) after `element:number` converged (#12039 Key 2): the three '
26+
+ 'array-declared doors (`record:related_list`, its nested Add-affordance picker, '
27+
+ '`element:number`) carried the `ViewFilterRule` array and the four `object-*` doors '
28+
+ 'declare `z.unknown()` (#15449), so the filter a list view stores and renders was refused '
29+
+ 'by the picker beside them, and a lone holdout is the state where the next author copies '
30+
+ 'the wrong form. Sequenced measurement-first, as that convergence had to be (the 2026-08-25 '
31+
+ 'Option-A ordering ruling, #14406): at the objectui pin `00d3f09c` the renderer hands '
32+
+ '`filter` to `query.$filter` and calls `adapter.find()` '
33+
+ '(`components/src/renderers/basic/record-picker.tsx`); `ObjectStackAdapter.convertQueryParams` '
34+
+ 'lowers an ARRAY `$filter` through `translateFilterArray` into filter AST tuples '
35+
+ '(`data-objectstack/src/index.ts`), the same door every list view\'s stored rule array '
36+
+ 'already takes, and the engine lowers the tuples before the driver '
37+
+ '(`engine-filter-array-lowering.test.ts`); nothing on that path parses `properties` '
38+
+ 'against the installed spec. The pin and objectui `main` (`f7cf7e8`) are byte-identical on '
39+
+ 'every read-path file. The ruled migration check ran with the change: the sweep of '
40+
+ 'first-party corpora (examples/, skills/, content/docs/, docs/, packages/**, .changeset/) '
41+
+ 'found ONE `element:record_picker` author writing a record-form `filter` — a spec test '
42+
+ 'fixture, rewritten to the array form in the same change — and zero outside the spec '
43+
+ 'package; this entry carries the prescription for authors outside the repo.',
44+
acceptanceCriteria:
45+
"`ComponentPropsMap['element:record_picker'].safeParse({ object, filter: [{ field: "
46+
+ "'status', operator: 'equals', value: 'active' }] })` succeeds and the parsed `filter` is "
47+
+ "the same rule array; a record-form `filter: { status: 'active' }` is refused at the "
48+
+ '`filter` path (`invalid_type`, expected array). At runtime the picker offers exactly the '
49+
+ 'rows the array selects — the same filter a list view renders. Downstream (objectui, after '
50+
+ "a released spec version reaches the pin): the registry's `inputs.filter` entry for "
51+
+ "`element:record_picker` (`type: 'object'`, `record-picker.tsx`) flips to the array arm and "
52+
+ 'the `record-picker-inputs-spec-parity.test.ts` pins that assert the record form follow — '
53+
+ 'objectui#7663, filed from #14406 with a Blocked-by line.',
54+
};

packages/spec/src/migrations/registry.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6415,6 +6415,56 @@ const step18: MigrationStep = {
64156415
+ '(`registry-inputs-spec-parity.test.ts`) becomes deletable, which is what closes '
64166416
+ 'objectui#6206.',
64176417
},
6418+
{
6419+
id: 'element-record-picker-filter-rule-array',
6420+
surface:
6421+
"`element:record_picker` component props — `filter` (the FORM: the MongoDB-style "
6422+
+ '`FilterConditionSchema` record vs the `ViewFilterRule` array)',
6423+
replacement:
6424+
'`z.array(ViewFilterRuleSchema)` — the rule array `[{ field, operator, value }, ...]` '
6425+
+ "the map's array-declared `filter` doors already carry (`record:related_list`, its nested "
6426+
+ 'Add-affordance picker, `element:number`; the four `object-*` blocks declare `filter` as '
6427+
+ '`z.unknown()`, #15449). A record-form filter '
6428+
+ "`{ status: 'active' }` becomes `[{ field: 'status', operator: 'equals', value: 'active' }]`; "
6429+
+ "an operator object `{ amount: { $gt: 100 } }` becomes "
6430+
+ "`[{ field: 'amount', operator: 'greater_than', value: 100 }]`; several keys become "
6431+
+ 'several rules (they AND). Legacy operator shorthands (`eq`, `gt`, `notIn`, …) are '
6432+
+ 'accepted and normalized on parse. The binding-level `dataSource.filter` on the same node '
6433+
+ 'is a different key (`ElementDataSourceSchema`) and is not moved by this entry',
6434+
reason:
6435+
'One filter orthography platform-wide (objectui#6206, maintainer batch adjudication '
6436+
+ "2026-08-25, verbatim 「同意」, Option B). `ComponentPropsMap['element:record_picker'].filter` "
6437+
+ 'was the LAST `filter` input in the map still declared as the MongoDB-style record '
6438+
+ '(`FilterConditionSchema`) after `element:number` converged (#12039 Key 2): the three '
6439+
+ 'array-declared doors (`record:related_list`, its nested Add-affordance picker, '
6440+
+ '`element:number`) carried the `ViewFilterRule` array and the four `object-*` doors '
6441+
+ 'declare `z.unknown()` (#15449), so the filter a list view stores and renders was refused '
6442+
+ 'by the picker beside them, and a lone holdout is the state where the next author copies '
6443+
+ 'the wrong form. Sequenced measurement-first, as that convergence had to be (the 2026-08-25 '
6444+
+ 'Option-A ordering ruling, #14406): at the objectui pin `00d3f09c` the renderer hands '
6445+
+ '`filter` to `query.$filter` and calls `adapter.find()` '
6446+
+ '(`components/src/renderers/basic/record-picker.tsx`); `ObjectStackAdapter.convertQueryParams` '
6447+
+ 'lowers an ARRAY `$filter` through `translateFilterArray` into filter AST tuples '
6448+
+ '(`data-objectstack/src/index.ts`), the same door every list view\'s stored rule array '
6449+
+ 'already takes, and the engine lowers the tuples before the driver '
6450+
+ '(`engine-filter-array-lowering.test.ts`); nothing on that path parses `properties` '
6451+
+ 'against the installed spec. The pin and objectui `main` (`f7cf7e8`) are byte-identical on '
6452+
+ 'every read-path file. The ruled migration check ran with the change: the sweep of '
6453+
+ 'first-party corpora (examples/, skills/, content/docs/, docs/, packages/**, .changeset/) '
6454+
+ 'found ONE `element:record_picker` author writing a record-form `filter` — a spec test '
6455+
+ 'fixture, rewritten to the array form in the same change — and zero outside the spec '
6456+
+ 'package; this entry carries the prescription for authors outside the repo.',
6457+
acceptanceCriteria:
6458+
"`ComponentPropsMap['element:record_picker'].safeParse({ object, filter: [{ field: "
6459+
+ "'status', operator: 'equals', value: 'active' }] })` succeeds and the parsed `filter` is "
6460+
+ "the same rule array; a record-form `filter: { status: 'active' }` is refused at the "
6461+
+ '`filter` path (`invalid_type`, expected array). At runtime the picker offers exactly the '
6462+
+ 'rows the array selects — the same filter a list view renders. Downstream (objectui, after '
6463+
+ "a released spec version reaches the pin): the registry's `inputs.filter` entry for "
6464+
+ "`element:record_picker` (`type: 'object'`, `record-picker.tsx`) flips to the array arm and "
6465+
+ 'the `record-picker-inputs-spec-parity.test.ts` pins that assert the record form follow — '
6466+
+ 'objectui#7663, filed from #14406 with a Blocked-by line.',
6467+
},
64186468
{
64196469
id: 'engine-dotted-filter-refused',
64206470
surface:

packages/spec/src/type-alias-convention.pin.test.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,7 @@ import type * as M183 from './api/sortability.zod.js';
270270
import type * as M184 from './shared/value-domain.zod.js';
271271

272272
// ---------------------------------------------------------------------------
273-
// 826 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared.
273+
// 825 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared.
274274
//
275275
// That number is machine-checked, not hand-kept. The runtime companion at the
276276
// bottom of this file recomputes the pin count from the source and asserts that
@@ -1557,7 +1557,10 @@ export type Iso817 = Assert<Eq< z.input< typeof M155.ActionType >, z.infer< type
15571557
// own input ≠ infer (`operator` is normalized on parse — `ViewFilterRuleParsed`
15581558
// exists for exactly that reason), so `ElementNumberPropsParsed` is declared
15591559
// and the pin deleted.
1560-
export type Iso819 = Assert<Eq< z.input< typeof M170.ElementRecordPickerPropsSchema >, z.infer< typeof M170.ElementRecordPickerPropsSchema > >>;
1560+
// `ElementRecordPickerPropsSchema` (Iso819) left the family the same way on
1561+
// #14406 — the LAST record-form `filter` in `ComponentPropsMap`: its `filter`
1562+
// now carries `z.array(ViewFilterRuleSchema)` too, so `ElementRecordPickerPropsParsed`
1563+
// is declared and this pin deleted.
15611564
export type Iso820 = Assert<Eq< z.input< typeof M170.RecordHighlightsField >, z.infer< typeof M170.RecordHighlightsField > >>;
15621565
export type Iso821 = Assert<Eq< z.input< typeof M170.RecordPathProps >, z.infer< typeof M170.RecordPathProps > >>;
15631566
// `record:reference_rail` (#8691) — deliberately default-free on the same
@@ -1682,7 +1685,7 @@ describe('ADR-0122 type-alias convention', () => {
16821685
// this title and the section header above the pin list — are now asserted
16831686
// against the recomputed count below, so neither can go stale without a red
16841687
// test naming it.
1685-
it('still declares all 826 isomorphic pins', () => {
1688+
it('still declares all 825 isomorphic pins', () => {
16861689
// The truth of each pin is proved by tsc, not here — an `Assert<Eq<...>>`
16871690
// that stops holding is a compile error with the alias named. What tsc
16881691
// cannot notice is a pin that was DELETED: removing the assertion removes
@@ -2133,7 +2136,17 @@ describe('ADR-0122 type-alias convention', () => {
21332136
// `ErrorMappingRuleSchema` and `ConnectorErrorCategorySchema` left whole
21342137
// with the key (whole-def removal, `RETIRED_DEFS_BY_MAJOR[18]`), so the
21352138
// two pins that named them (`Iso381` / `Iso382`) leave with the schemas.
2136-
expect(pins).toHaveLength(826);
2139+
//
2140+
// 826 -> 825 is #14406's ui#6206-B convergence of the LAST record-form
2141+
// `filter` in `ComponentPropsMap`: `ElementRecordPickerPropsSchema.filter`
2142+
// now carries `z.array(ViewFilterRuleSchema)`, whose own input ≠ infer
2143+
// (`operator` is normalized on parse — `ViewFilterRuleParsed` exists for
2144+
// exactly that reason), so `element:record_picker` left the isomorphic
2145+
// family the way ADR-0122 prescribes and `element:number` did one entry
2146+
// earlier: `ElementRecordPickerPropsParsed` declared, the Iso819 pin
2147+
// deleted. -1 converted to an `XParsed` pair; the Iso number stays vacant
2148+
// (ids are claims about pins, not positions).
2149+
expect(pins).toHaveLength(825);
21372150

21382151
// The count is stated in PROSE twice as well — this case's title and the
21392152
// section header above the pin list — and until #6605 nothing read either

0 commit comments

Comments
 (0)