diff --git a/.changeset/filter-orthography-binding-and-object-blocks.md b/.changeset/filter-orthography-binding-and-object-blocks.md new file mode 100644 index 0000000000..a9f2068f7a --- /dev/null +++ b/.changeset/filter-orthography-binding-and-object-blocks.md @@ -0,0 +1,60 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec)!: the binding-level `dataSource.filter` and the four `object-*` `filter` doors converge onto the `ViewFilterRule` array form — one filter orthography platform-wide reaches the family (#15442, #15449; objectui#6206-B, decision batch #55 option A) + + + +**BREAKING** accept-set change at five doors — `ElementDataSourceSchema.filter` +(the `dataSource` binding every data-bound page component carries) and +`ComponentPropsMap['object-grid' | 'object-metric' | 'object-kanban' | +'object-calendar'].filter` — shipped as `minor` under the repo's launch-window +convention for breaking changes; the migration prescription is registered under +protocol major 18 as ONE entry for the family. + +One filter orthography platform-wide (maintainer batch adjudication 2026-08-25, +verbatim 「同意」; reached these two locations on 2026-09-06, decision batch #55, +verbatim 「同意」, option A: converge family-wide). Until this release the +binding alone declared the MongoDB-style record (`FilterConditionSchema`) — so it +refused the array the consumer's own pins author at that key, and +`element:record_picker` carried two orthographies at two keys resolved through +one `??` in the renderer — while the four `object-*` doors declared `z.unknown()` +and took the record, the ObjectQL AST tuple array and the rule array alike, +silently. All five now declare `z.array(ViewFilterRuleSchema)`, the form every +other `filter` door in the map already carried; the `FilterConditionSchema` +import that existed in `page.zod.ts` for this one site leaves with it. + +Sequenced measurement-first, as the family had to be: at the objectui pin +`a472b07` the `object-metric` aggregate path posted an array `where` that +`POST /analytics/query` refused (400 on every array form, #15828), so the +converge was parked behind the pin bump #16626. At the pin this repo builds +against (`53ded82b`, objectui#7754) the adapter lowers an authored array through +`translateFilterArray` and the spec's own `parseFilterAST` sink before the +wire; `ObjectGrid` lowers a rule array through `toFilterNode`; `ObjectKanban` / +`ObjectCalendar` hand it verbatim to `$filter`, where `convertQueryParams` +lowers it; the binding's composition seam AND-combines it with the named view's +rules through `mergeFilterNodes`. Nothing on those paths parses the value +against the installed spec. + +**Migration** (`element-data-source-and-object-block-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 `{ status: { $ne: 'done' } }` becomes +`[{ field: 'status', operator: 'not_equals', value: 'done' }]`; several keys +become several rules (they AND); an AST tuple array +`[['owner_id', '=', '{current_user_id}']]` becomes +`[{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }]` — +placeholders and date macros are unchanged. The record form is refused at +`filter` (`invalid_type`, expected array); the tuple array is refused at +`filter.0` (expected object). The dashboard widget `filter` +(`dashboard.zod.ts`) is a different family and is unchanged by this release +(#15829); `object-grid.defaultFilters` is a different key, not named by the +ruling, and is unchanged. + +In-repo authors migrated in the same change: four spec test fixtures at the +binding, five showcase authors (`my-work.page.ts`, `index.ts`) and three lint +fixtures. Type aliases: `ElementDataSourceParsed`, `ObjectMetricPropsParsed`, +`ObjectKanbanPropsParsed` and `ObjectCalendarPropsParsed` are declared (ADR-0122: +`operator` normalizes on parse, so input ≠ infer at these five schemas now). diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index eabf540fc4..07fcd74f14 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -311,13 +311,23 @@ Sort field and direction pair | **objectName** | `string` | optional | Object this calendar binds to. Optional because the component-level `dataSource` binding can supply the object instead | | **calendar** | `any` | optional | Calendar field config: `{ startDateField, endDateField?, titleField?, colorField?, allDayField? }` | | **defaultView** | `Enum<'month' \| 'week' \| 'day'>` | optional | Initial view mode | -| **filter** | `any` | optional | Base query filter | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **sort** | `any` | optional | Sort for the fetched events | | **data** | `any[]` | optional | Pre-fetched records — skips the internal fetch | | **staticData** | `any[]` | optional | Static inline records | | **locale** | `string` | optional | Locale override for the calendar chrome | | **loading** | `boolean` | optional | External loading state (honoured only alongside `data`) | +### Nested Shape: `ObjectCalendarProps.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. | + --- @@ -381,7 +391,7 @@ Sort field and direction pair | **title** | `string \| Record` | optional | Fallback for `label` (the renderer reads `label \|\| title`) | | **columns** | `any[]` | optional | Columns: field names or column definition objects | | **fields** | `any[]` | optional | Field list fallback used when `columns` is absent | -| **filter** | `any` | optional | Base query filter (ObjectQL filter array/AST) — lowered to the wire `$filter`. THE key, singular — not the plural misspelling | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. THE key, singular — not the plural misspelling. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **defaultFilters** | `any` | optional | Legacy base-filter fallback, read only when `filter` is absent. Prefer `filter` | | **sort** | `any` | optional | Initial sort (array of `{ field, order }`) | | **defaultSort** | `never` | optional | [REMOVED] `object-grid` property `defaultSort` was removed in @objectstack/spec 17 (ADR-0049) — it was the legacy second spelling of `sort`: a single `{ field, order }` pair read only when `sort` was absent, so one intent had two spellings and a grid authoring both silently ignored this one. Rename the key to `sort` and wrap the value in an array (`defaultSort: { field, order }` becomes `sort: [{ field, order }]`); the pair itself is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | @@ -414,6 +424,16 @@ Sort field and direction pair | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source binding (ViewDataSchema — discriminated on `provider`: object \| api \| value \| schema). Static inline rows live at `{ provider: 'value', items: [...] }`; the bare-array shortcut is refused — see migration `object-grid-data-view-data-converged` | | **staticData** | `any[]` | optional | Deprecated bare-array static-rows shortcut the renderer still reads. Prefer `data: { provider: 'value', items: [...] }` | +### Nested Shape: `ObjectGridProps.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: `ObjectGridProps.data[provider='object']` | Property | Type | Required | Description | @@ -456,7 +476,7 @@ Sort field and direction pair | **objectName** | `string` | optional | Object this board binds to. Optional because the component-level `dataSource` binding can supply the object instead | | **groupBy** | `string` | optional | Field whose values become the board columns | | **columns** | `any[]` | optional | Swimlane definitions (`{ id, title }` per `groupBy` value, or bare value strings) — NOT a field projection | -| **filter** | `any` | optional | Base query filter, handed to the wire `$filter` | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter, handed to the wire `$filter` — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **limit** | `integer` | optional | Maximum number of records loaded onto the board (row cap); lowered to the query's top-level `$top` (renderer default 100). The component-level `dataSource.limit` wins when both are set; a bound view's `pagination.pageSize` fills it only when unset | | **data** | `any[]` | optional | Static inline cards — bypasses the object query | | **cardTitle** | `string` | optional | Field rendered as each card title | @@ -468,6 +488,16 @@ Sort field and direction pair | **coverImageField** | `string` | optional | Image field rendered as the card cover | | **conditionalFormatting** | `any` | optional | Card conditional formatting rules | +### Nested Shape: `ObjectKanbanProps.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. | + --- @@ -508,7 +538,7 @@ Sort field and direction pair | **icon** | `string` | optional | Lucide icon name drawn in the metric tile header, inside the `colorVariant`-tinted square. Read on this component — `ObjectMetricWidget` forwards it to `MetricWidget`, which resolves it with `getLazyIcon` (the `LazyIcon` module: kebab-case or PascalCase, degrading to the `Database` glyph on an unknown name). | | **colorVariant** | `Enum<'default' \| 'blue' \| 'teal' \| 'orange' \| 'purple' \| 'success' \| 'warning' \| 'danger'>` | optional | Icon container color variant | | **aggregate** | `any` | optional | Aggregation config (`{ field, function, groupBy? }`) run against the object | -| **filter** | `any` | optional | Filter the aggregation is scoped by | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter the aggregation is scoped by — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **format** | `string` | optional | Number format pattern (e.g. '0,0', '$0,0', '0%') | | **currency** | `string` | optional | ISO currency code (e.g. 'USD') — enables currency formatting | | **prefix** | `string` | optional | Static prefix before the formatted value | @@ -520,6 +550,16 @@ Sort field and direction pair | **drillDown** | `any` | optional | Click-through drill config — opens the underlying records | | **compareTo** | `any` | optional | Period-over-period comparison (`{ kind: 'previousPeriod' \| 'previousYear' }`) | +### Nested Shape: `ObjectMetricProps.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. | + --- diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index 738a271786..73ef09ad6d 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -29,10 +29,20 @@ const result = ElementDataSourceSchema.parse(data); | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object to query | | **view** | `string` | optional | Named view to apply | -| **filter** | `any` | optional | Additional filter criteria | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Additional filter criteria — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in ComponentPropsMap shares; AND-combined with the filter of the named view. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Sort order | | **limit** | `integer` | optional | Max records to display | +### Nested Shape: `ElementDataSource.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: `ElementDataSource.sort[number]` Sort field and direction pair @@ -245,7 +255,7 @@ View filter rule | **responsiveStyles** | `{ large?: Record; medium?: Record; small?: Record; xsmall?: Record }` | optional | Per-breakpoint scoped style maps (ADR-0065) | | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — component rendered only when TRUE. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user` — one object, three spellings), and page state as `page.`. The shipping renderer additionally mounts `app`, `features`, `os.user` and binds `data` to the data-source ADAPTER here — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). ⚠️ `data` is surface-dependent: on a `page:tabs` item `visibleWhen` it is the record ROW instead. e.g. "page.selectedProjectId != ''" | | **visibility** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse. | -| **dataSource** | `{ object: string; view?: string; filter?: any; sort?: object[]; … }` | optional | Per-element data binding for multi-object pages | +| **dataSource** | `{ object: string; view?: string; filter?: object[]; sort?: object[]; … }` | optional | Per-element data binding for multi-object pages | | **responsive** | `never` | optional | [REMOVED] `page.components[].responsive` was removed in @objectstack/spec 17 (ADR-0049 D2) — no renderer ever read it, so per-breakpoint layout overrides (columns/order/visibility) parsed, validated, and then did nothing. Delete the key. For breakpoint behaviour that IS applied, use the sibling `responsiveStyles` (ADR-0065) — per-breakpoint CSS maps compiled to id-scoped CSS at render, e.g. `responsiveStyles: { xsmall: { display: 'none' } }` to hide a component on the narrowest screens. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | @@ -264,7 +274,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object to query | | **view** | `string` | optional | Named view to apply | -| **filter** | `any` | optional | Additional filter criteria | +| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Additional filter criteria — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in ComponentPropsMap shares; AND-combined with the filter of the named view. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Sort order | | **limit** | `integer` | optional | Max records to display | @@ -343,7 +353,7 @@ View filter rule | **responsiveStyles** | `{ large?: Record; medium?: Record; small?: Record; xsmall?: Record }` | optional | Per-breakpoint scoped style maps (ADR-0065) | | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — component rendered only when TRUE. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user` — one object, three spellings), and page state as `page.`. The shipping renderer additionally mounts `app`, `features`, `os.user` and binds `data` to the data-source ADAPTER here — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). ⚠️ `data` is surface-dependent: on a `page:tabs` item `visibleWhen` it is the record ROW instead. e.g. "page.selectedProjectId != ''" | | **visibility** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse. | -| **dataSource** | `{ object: string; view?: string; filter?: any; sort?: object[]; … }` | optional | Per-element data binding for multi-object pages | +| **dataSource** | `{ object: string; view?: string; filter?: object[]; sort?: object[]; … }` | optional | Per-element data binding for multi-object pages | | **responsive** | `never` | optional | [REMOVED] `page.components[].responsive` was removed in @objectstack/spec 17 (ADR-0049 D2) — no renderer ever read it, so per-breakpoint layout overrides (columns/order/visibility) parsed, validated, and then did nothing. Delete the key. For breakpoint behaviour that IS applied, use the sibling `responsiveStyles` (ADR-0065) — per-breakpoint CSS maps compiled to id-scoped CSS at render, e.g. `responsiveStyles: { xsmall: { display: 'none' } }` to hide a component on the narrowest screens. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | diff --git a/examples/app-showcase/src/ui/pages/index.ts b/examples/app-showcase/src/ui/pages/index.ts index 0d6a02e4c1..560c1c36ca 100644 --- a/examples/app-showcase/src/ui/pages/index.ts +++ b/examples/app-showcase/src/ui/pages/index.ts @@ -75,7 +75,7 @@ export const ComponentGalleryPage = definePage({ { type: 'object-metric', properties: { objectName: 'showcase_project', label: 'Projects', icon: 'folder-kanban', colorVariant: 'blue', description: 'active & planned', aggregate: { field: 'id', function: 'count' } } }, { type: 'object-metric', properties: { objectName: 'showcase_task', label: 'Tasks', icon: 'check-square', colorVariant: 'purple', description: 'all states', aggregate: { field: 'id', function: 'count' } } }, { type: 'object-metric', properties: { objectName: 'showcase_account', label: 'Accounts', icon: 'building', colorVariant: 'teal', description: 'customers', aggregate: { field: 'id', function: 'count' } } }, - { type: 'object-metric', properties: { objectName: 'showcase_task', label: 'Open Tasks', icon: 'list-checks', colorVariant: 'warning', description: 'not done', aggregate: { field: 'id', function: 'count' }, filter: { status: { $ne: 'done' } } } }, + { type: 'object-metric', properties: { objectName: 'showcase_task', label: 'Open Tasks', icon: 'list-checks', colorVariant: 'warning', description: 'not done', aggregate: { field: 'id', function: 'count' }, filter: [{ field: 'status', operator: 'not_equals', value: 'done' }] } }, ], }, }, diff --git a/examples/app-showcase/src/ui/pages/my-work.page.ts b/examples/app-showcase/src/ui/pages/my-work.page.ts index e6e1143b37..96db9507a9 100644 --- a/examples/app-showcase/src/ui/pages/my-work.page.ts +++ b/examples/app-showcase/src/ui/pages/my-work.page.ts @@ -36,9 +36,9 @@ export const MyWorkPage = definePage({ columns: 3, gap: 4, children: [ - { type: 'object-metric', properties: { objectName: 'showcase_task', label: 'Open Tasks', icon: 'list-checks', colorVariant: 'blue', description: 'not done', aggregate: { field: 'id', function: 'count' }, filter: { status: { $ne: 'done' } } } }, - { type: 'object-metric', properties: { objectName: 'showcase_task', label: 'In Review', icon: 'eye', colorVariant: 'warning', description: 'awaiting review', aggregate: { field: 'id', function: 'count' }, filter: { status: 'in_review' } } }, - { type: 'object-metric', properties: { objectName: 'showcase_project', label: 'At-Risk Projects', icon: 'alert-triangle', colorVariant: 'danger', description: 'health red', aggregate: { field: 'id', function: 'count' }, filter: { health: 'red' } } }, + { type: 'object-metric', properties: { objectName: 'showcase_task', label: 'Open Tasks', icon: 'list-checks', colorVariant: 'blue', description: 'not done', aggregate: { field: 'id', function: 'count' }, filter: [{ field: 'status', operator: 'not_equals', value: 'done' }] } }, + { type: 'object-metric', properties: { objectName: 'showcase_task', label: 'In Review', icon: 'eye', colorVariant: 'warning', description: 'awaiting review', aggregate: { field: 'id', function: 'count' }, filter: [{ field: 'status', operator: 'equals', value: 'in_review' }] } }, + { type: 'object-metric', properties: { objectName: 'showcase_project', label: 'At-Risk Projects', icon: 'alert-triangle', colorVariant: 'danger', description: 'health red', aggregate: { field: 'id', function: 'count' }, filter: [{ field: 'health', operator: 'equals', value: 'red' }] } }, ], }, }, @@ -51,12 +51,19 @@ export const MyWorkPage = definePage({ // this line used to carry has ZERO read points in the renderer, so it // was accepted and then dropped — the queue silently listed every row // (objectstack#7750). + // + // And the `ViewFilterRule` ARRAY form, `[{ field, operator, value }]`: + // the one filter orthography every `filter` door declares since + // #15442 / #15449 (ui#6206-B). The ObjectQL AST tuple this line used + // to carry (`[['owner_id', '=', '{current_user_id}']]`) is refused at + // `filter.0` now; `toFilterNode` lowers the rule to the same AST node + // before the wire, and `{current_user_id}` resolves exactly as before. { type: 'object-grid', properties: { objectName: 'showcase_task', columns: ['title', 'project', 'status', 'priority', 'due_date'], - filter: [['owner_id', '=', '{current_user_id}']], + filter: [{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }], }, }, ], diff --git a/examples/app-showcase/test/my-work-visibility.test.ts b/examples/app-showcase/test/my-work-visibility.test.ts index 558f2f80a6..030c9c74f2 100644 --- a/examples/app-showcase/test/my-work-visibility.test.ts +++ b/examples/app-showcase/test/my-work-visibility.test.ts @@ -186,8 +186,11 @@ describe('My Work — the personal queue declares its filter in the key object-g it('still scopes the queue to the signed-in user', () => { // The spelling is only half the fix; the predicate is what makes the page // personal at all. `{current_user_id}` is the page-level identity token. + // Authored in the `ViewFilterRule` array form — the one filter orthography + // `object-grid.filter` declares since #15449 (ui#6206-B); the AST tuple + // this pin used to carry is refused at `filter.0` by the spec now. expect(workQueueGrid()!.properties!.filter).toEqual([ - ['owner_id', '=', '{current_user_id}'], + { field: 'owner_id', operator: 'equals', value: '{current_user_id}' }, ]); }); }); diff --git a/packages/lint/src/validate-component-props.test.ts b/packages/lint/src/validate-component-props.test.ts index 74c046dbc6..776b8b641d 100644 --- a/packages/lint/src/validate-component-props.test.ts +++ b/packages/lint/src/validate-component-props.test.ts @@ -480,12 +480,16 @@ describe('validateComponentProps — object-* blocks are dispatched (#7751)', () properties: { objectName: 'showcase_task', columns: ['title', 'project', 'status', 'priority', 'due_date'], - filter: [['owner_id', '=', '{current_user_id}']], + // The rule-array form the four `object-*` doors declare since + // #15449 (ui#6206-B, one filter orthography); the AST tuple this + // pin used to carry is refused at `filter.0` now, so the corpus + // shape it mirrors (`my-work.page.ts`) moved with it. + filter: [{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }], }, }, { type: 'object-metric', - properties: { objectName: 'showcase_task', label: 'Open Tasks', icon: 'list-checks', colorVariant: 'blue', description: 'not done', aggregate: { field: 'id', function: 'count' }, filter: { status: { $ne: 'done' } } }, + properties: { objectName: 'showcase_task', label: 'Open Tasks', icon: 'list-checks', colorVariant: 'blue', description: 'not done', aggregate: { field: 'id', function: 'count' }, filter: [{ field: 'status', operator: 'not_equals', value: 'done' }] }, }, { type: 'object-metric', diff --git a/packages/lint/src/validate-empty-combinators.test.ts b/packages/lint/src/validate-empty-combinators.test.ts index 5382134a9f..0b9e482954 100644 --- a/packages/lint/src/validate-empty-combinators.test.ts +++ b/packages/lint/src/validate-empty-combinators.test.ts @@ -229,6 +229,17 @@ describe('validateEmptyCombinators — the surfaces it walks', () => { }); it('covers objects, views, reports, datasets, pages and apps', () => { + // The `pages` row authors the RETIRED record form on purpose: since + // #15442 / #15449 (ui#6206-B, one filter orthography) the `object-*` + // `filter` doors carry `z.array(ViewFilterRuleSchema)`, so the spec now + // refuses `{ $or: [] }` there. That is exactly what this row measures. + // This rule never parses a fixture against the spec schema — the walk is + // key-name based (`walkAuthoredFilters` visits any `FILTER_KEYS` key it + // reaches, whatever its shape) — so the off-spec author is still judged + // here, and gets this located error beside the schema refusal rather than + // nothing. It is also the only measurement of the page walk that `pages` + // in `EMPTY_COMBINATOR_SURFACES` declares: drop it and that surface is + // declared and unmeasured. const findings = validateEmptyCombinators({ objects: [{ name: 'lead', listViews: { mine: { filter: { $or: [] } } } }], views: [{ name: 'all', list: { filter: { $and: [] } } }], diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index 750cc34cd6..0507096117 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -138,6 +138,7 @@ "EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT (const)", "ElementButtonPropsSchema (const)", "ElementDataSource (type)", + "ElementDataSourceParsed (type)", "ElementDataSourceSchema (const)", "ElementFilterPropsSchema (const)", "ElementFormPropsSchema (const)", @@ -251,6 +252,7 @@ "NotificationType (type)", "NotificationTypeSchema (const)", "ObjectCalendarProps (type)", + "ObjectCalendarPropsParsed (type)", "ObjectCalendarPropsSchema (const)", "ObjectFormProps (type)", "ObjectFormPropsSchema (const)", @@ -258,11 +260,13 @@ "ObjectGridPropsParsed (type)", "ObjectGridPropsSchema (const)", "ObjectKanbanProps (type)", + "ObjectKanbanPropsParsed (type)", "ObjectKanbanPropsSchema (const)", "ObjectListViewSchema (const)", "ObjectMasterDetailFormProps (type)", "ObjectMasterDetailFormPropsSchema (const)", "ObjectMetricProps (type)", + "ObjectMetricPropsParsed (type)", "ObjectMetricPropsSchema (const)", "ObjectNavItem (type)", "ObjectNavItemParsed (type)", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index e0f1611736..17b87ed2e5 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -134,6 +134,7 @@ "EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT": "src/ui/expression-bindable-text-keys.zod.ts#EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT (const)", "ElementButtonPropsSchema": "src/ui/component.zod.ts#ElementButtonPropsSchema (const)", "ElementDataSource": "src/ui/page.zod.ts#ElementDataSource (type)", + "ElementDataSourceParsed": "src/ui/page.zod.ts#ElementDataSourceParsed (type)", "ElementDataSourceSchema": "src/ui/page.zod.ts#ElementDataSourceSchema (const)", "ElementFilterPropsSchema": "src/ui/component.zod.ts#ElementFilterPropsSchema (const)", "ElementFormPropsSchema": "src/ui/component.zod.ts#ElementFormPropsSchema (const)", @@ -247,6 +248,7 @@ "NotificationType": "src/ui/notification.zod.ts#NotificationType (type)", "NotificationTypeSchema": "src/ui/notification.zod.ts#NotificationTypeSchema (const)", "ObjectCalendarProps": "src/ui/component.zod.ts#ObjectCalendarProps (type)", + "ObjectCalendarPropsParsed": "src/ui/component.zod.ts#ObjectCalendarPropsParsed (type)", "ObjectCalendarPropsSchema": "src/ui/component.zod.ts#ObjectCalendarPropsSchema (const)", "ObjectFormProps": "src/ui/component.zod.ts#ObjectFormProps (type)", "ObjectFormPropsSchema": "src/ui/component.zod.ts#ObjectFormPropsSchema (const)", @@ -254,11 +256,13 @@ "ObjectGridPropsParsed": "src/ui/component.zod.ts#ObjectGridPropsParsed (type)", "ObjectGridPropsSchema": "src/ui/component.zod.ts#ObjectGridPropsSchema (const)", "ObjectKanbanProps": "src/ui/component.zod.ts#ObjectKanbanProps (type)", + "ObjectKanbanPropsParsed": "src/ui/component.zod.ts#ObjectKanbanPropsParsed (type)", "ObjectKanbanPropsSchema": "src/ui/component.zod.ts#ObjectKanbanPropsSchema (const)", "ObjectListViewSchema": "src/ui/view.zod.ts#ObjectListViewSchema (const)", "ObjectMasterDetailFormProps": "src/ui/component.zod.ts#ObjectMasterDetailFormProps (type)", "ObjectMasterDetailFormPropsSchema": "src/ui/component.zod.ts#ObjectMasterDetailFormPropsSchema (const)", "ObjectMetricProps": "src/ui/component.zod.ts#ObjectMetricProps (type)", + "ObjectMetricPropsParsed": "src/ui/component.zod.ts#ObjectMetricPropsParsed (type)", "ObjectMetricPropsSchema": "src/ui/component.zod.ts#ObjectMetricPropsSchema (const)", "ObjectNavItem": "src/ui/app.zod.ts#ObjectNavItem (type)", "ObjectNavItemParsed": "src/ui/app.zod.ts#ObjectNavItemParsed (type)", diff --git a/packages/spec/src/migrations/entries/semantic/18.element-data-source-and-object-block-filter-rule-array.ts b/packages/spec/src/migrations/entries/semantic/18.element-data-source-and-object-block-filter-rule-array.ts new file mode 100644 index 0000000000..06fb47f3e0 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.element-data-source-and-object-block-filter-rule-array.ts @@ -0,0 +1,84 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'element-data-source-and-object-block-filter-rule-array', + surface: + 'Page-component `dataSource.filter` (`ElementDataSourceSchema`, the binding every ' + + 'data-bound element carries) and the `filter` prop of the four `object-*` blocks in ' + + '`ComponentPropsMap` — `object-grid`, `object-metric`, `object-kanban`, `object-calendar` ' + + '(the FORM: the MongoDB-style `FilterConditionSchema` record at the binding, and the ' + + 'accept-anything `z.unknown()` at the four block doors, vs the `ViewFilterRule` array)', + replacement: + '`z.array(ViewFilterRuleSchema)` at all five doors — the rule array ' + + '`[{ field, operator, value }, ...]` every other `filter` door in the map already carries ' + + '(`record:related_list`, its Add-affordance picker, `element:number`, ' + + '`element:record_picker`). A record-form filter `{ status: \'active\' }` becomes ' + + '`[{ field: \'status\', operator: \'equals\', value: \'active\' }]`; an operator object ' + + '`{ status: { $ne: \'done\' } }` becomes ' + + '`[{ field: \'status\', operator: \'not_equals\', value: \'done\' }]`; several keys become ' + + 'several rules (they AND). An ObjectQL AST tuple array ' + + '`[[\'owner_id\', \'=\', \'{current_user_id}\']]` — which the `z.unknown()` block doors ' + + 'also took — becomes `[{ field: \'owner_id\', operator: \'equals\', value: ' + + '\'{current_user_id}\' }]`; the value placeholders and date macros are unchanged. Legacy ' + + 'operator shorthands (`eq`, `ne`, `gt`, `notIn`, …) are accepted and normalized on parse. ' + + 'The dashboard widget `filter` (`dashboard.zod.ts`) is a different family and is not moved ' + + 'by this entry (#15829); `object-grid.defaultFilters` is a different key and is not named ' + + 'by the ruling this entry records.', + reason: + 'One filter orthography platform-wide (objectui#6206, maintainer batch adjudication ' + + '2026-08-25, verbatim 「同意」, Option B) reached two more locations the ComponentPropsMap ' + + 'census could not see (#15442 anchor, #15449 member; decision batch #55, 2026-09-06, ' + + 'verbatim 「同意」, option A: converge family-wide, one entry). The binding-level ' + + '`dataSource.filter` alone still said `FilterConditionSchema`: it refused the array the ' + + 'consumer\'s own pins author at that key, and `element:record_picker` carried two ' + + 'orthographies at two keys (`properties.filter` the rule array, `dataSource.filter` the ' + + 'record) resolved through one `??` in the renderer — the shape in which a dropped or ' + + 'misread filter returns the wrong rows without an error. The four `object-*` doors said ' + + '`z.unknown()`: a read-point record derived from the renderers on 2026-08-13 (#7751), ' + + 'twelve days before the ruling, not an exception to it — so an author following the ' + + 'showcase wrote the record and an author following the manifest wrote an array, and each ' + + 'got a silent success receipt while the html tier already declared `array` for the grid ' + + 'and the metric. The record\'s `$and` / `$or` / `$not` keys were misread by every gate ' + + 'block anyway (objectui#6948), so the exception would have preserved a capability the ' + + 'consumer does not honour. Sequenced measurement-first, as the family had to be: at the ' + + 'objectui pin `a472b07` the `object-metric` aggregate path posted an array `where` that ' + + '`POST /analytics/query` refused with 400 on every array form (#15828), so the converge ' + + 'was parked behind the pin bump #16626; at the pin this repo builds against (`53ded82b`, ' + + 'objectui#7754) the adapter lowers an authored array through `translateFilterArray` and ' + + 'the spec\'s own `parseFilterAST` sink before the wire, `ObjectGrid.tsx` lowers a rule ' + + 'array through `toFilterNode`, `ObjectKanban.tsx` / `ObjectCalendar.tsx` hand it verbatim ' + + 'to `$filter` where `convertQueryParams` lowers it, and the binding\'s composition seam ' + + 'AND-combines it with the named view\'s rules through `mergeFilterNodes`. The ruled ' + + 'migration check ran with the change: the in-repo sweep found four spec test fixtures ' + + 'at the binding (`page.test.ts`, all record form), five showcase authors at the block ' + + 'doors (`my-work.page.ts`, `index.ts`: four records on `object-metric`, one AST tuple ' + + 'array on `object-grid`) and three lint fixtures — every one rewritten to the rule array ' + + 'in the same change, and zero outside those files; this entry carries the prescription ' + + 'for authors outside the repo. ' + + '⚠️ Metadata AT REST is deliberately NOT rewritten, and this disposition adds no D2 ' + + 'conversion — a SemanticMigration converts nothing by its own type, and ' + + '`os migrate meta --stored` (the pass over a deployment\'s `sys_metadata` rows) replays ' + + 'D2 conversions only, so it has nothing to rewrite for this shape. The read path ' + + 'does not re-validate stored rows ' + + '(`applyConversionsToStoredItem` replays the full chain without validating, by its own ' + + 'contract), so a stored page or block carrying the record form keeps loading unchanged ' + + 'and is still rendered by objectui at the pinned `.objectui-sha`; what changes is that ' + + 'RE-SAVING it is refused at the `filter` door, on its next save and not before.', + acceptanceCriteria: + '`ElementDataSourceSchema.safeParse({ object, filter: [{ field: \'status\', operator: ' + + '\'equals\', value: \'active\' }] })` succeeds and the parsed `filter` is the same rule ' + + 'array; `ComponentPropsMap[\'object-grid\' | \'object-metric\' | \'object-kanban\' | ' + + '\'object-calendar\'].safeParse({ filter: })` raises no issue at `filter`; ' + + 'a record-form `filter: { status: \'active\' }` is refused at the `filter` path of all ' + + 'five doors (`invalid_type`, expected array), and an AST tuple array is refused at ' + + '`filter.0` (expected object). No `filter` door in `ComponentPropsMap` accepts the ' + + 'record any more (the twin of the #14406 census pin). At runtime each block and the ' + + 'binding select exactly the rows the array selects — the same filter a list view ' + + 'renders — including the `object-metric` aggregate tile, whose analytics `where` is the ' + + 'lowered condition. Downstream (objectui, after a released spec version reaches the ' + + 'pin): the seventeen `dataSource.filter` test authors at the pin (fifteen tuple arrays, ' + + 'two records) become off-spec fixtures and `ElementDataSourceConfig.filter`\'s ' + + '"three shapes" note narrows — objectui cards filed by the seat, not blocked on here.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 811080f111..ab1cd97668 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -6893,6 +6893,86 @@ const step18: MigrationStep = { + 'and every other declared key — `url`, `authToken`, `encryptionKey`, `concurrency`, `syncUrl`, ' + '`sync`, `timeoutMs` — keeps its bound, default and optionality.', }, + { + id: 'element-data-source-and-object-block-filter-rule-array', + surface: + 'Page-component `dataSource.filter` (`ElementDataSourceSchema`, the binding every ' + + 'data-bound element carries) and the `filter` prop of the four `object-*` blocks in ' + + '`ComponentPropsMap` — `object-grid`, `object-metric`, `object-kanban`, `object-calendar` ' + + '(the FORM: the MongoDB-style `FilterConditionSchema` record at the binding, and the ' + + 'accept-anything `z.unknown()` at the four block doors, vs the `ViewFilterRule` array)', + replacement: + '`z.array(ViewFilterRuleSchema)` at all five doors — the rule array ' + + '`[{ field, operator, value }, ...]` every other `filter` door in the map already carries ' + + '(`record:related_list`, its Add-affordance picker, `element:number`, ' + + '`element:record_picker`). A record-form filter `{ status: \'active\' }` becomes ' + + '`[{ field: \'status\', operator: \'equals\', value: \'active\' }]`; an operator object ' + + '`{ status: { $ne: \'done\' } }` becomes ' + + '`[{ field: \'status\', operator: \'not_equals\', value: \'done\' }]`; several keys become ' + + 'several rules (they AND). An ObjectQL AST tuple array ' + + '`[[\'owner_id\', \'=\', \'{current_user_id}\']]` — which the `z.unknown()` block doors ' + + 'also took — becomes `[{ field: \'owner_id\', operator: \'equals\', value: ' + + '\'{current_user_id}\' }]`; the value placeholders and date macros are unchanged. Legacy ' + + 'operator shorthands (`eq`, `ne`, `gt`, `notIn`, …) are accepted and normalized on parse. ' + + 'The dashboard widget `filter` (`dashboard.zod.ts`) is a different family and is not moved ' + + 'by this entry (#15829); `object-grid.defaultFilters` is a different key and is not named ' + + 'by the ruling this entry records.', + reason: + 'One filter orthography platform-wide (objectui#6206, maintainer batch adjudication ' + + '2026-08-25, verbatim 「同意」, Option B) reached two more locations the ComponentPropsMap ' + + 'census could not see (#15442 anchor, #15449 member; decision batch #55, 2026-09-06, ' + + 'verbatim 「同意」, option A: converge family-wide, one entry). The binding-level ' + + '`dataSource.filter` alone still said `FilterConditionSchema`: it refused the array the ' + + 'consumer\'s own pins author at that key, and `element:record_picker` carried two ' + + 'orthographies at two keys (`properties.filter` the rule array, `dataSource.filter` the ' + + 'record) resolved through one `??` in the renderer — the shape in which a dropped or ' + + 'misread filter returns the wrong rows without an error. The four `object-*` doors said ' + + '`z.unknown()`: a read-point record derived from the renderers on 2026-08-13 (#7751), ' + + 'twelve days before the ruling, not an exception to it — so an author following the ' + + 'showcase wrote the record and an author following the manifest wrote an array, and each ' + + 'got a silent success receipt while the html tier already declared `array` for the grid ' + + 'and the metric. The record\'s `$and` / `$or` / `$not` keys were misread by every gate ' + + 'block anyway (objectui#6948), so the exception would have preserved a capability the ' + + 'consumer does not honour. Sequenced measurement-first, as the family had to be: at the ' + + 'objectui pin `a472b07` the `object-metric` aggregate path posted an array `where` that ' + + '`POST /analytics/query` refused with 400 on every array form (#15828), so the converge ' + + 'was parked behind the pin bump #16626; at the pin this repo builds against (`53ded82b`, ' + + 'objectui#7754) the adapter lowers an authored array through `translateFilterArray` and ' + + 'the spec\'s own `parseFilterAST` sink before the wire, `ObjectGrid.tsx` lowers a rule ' + + 'array through `toFilterNode`, `ObjectKanban.tsx` / `ObjectCalendar.tsx` hand it verbatim ' + + 'to `$filter` where `convertQueryParams` lowers it, and the binding\'s composition seam ' + + 'AND-combines it with the named view\'s rules through `mergeFilterNodes`. The ruled ' + + 'migration check ran with the change: the in-repo sweep found four spec test fixtures ' + + 'at the binding (`page.test.ts`, all record form), five showcase authors at the block ' + + 'doors (`my-work.page.ts`, `index.ts`: four records on `object-metric`, one AST tuple ' + + 'array on `object-grid`) and three lint fixtures — every one rewritten to the rule array ' + + 'in the same change, and zero outside those files; this entry carries the prescription ' + + 'for authors outside the repo. ' + + '⚠️ Metadata AT REST is deliberately NOT rewritten, and this disposition adds no D2 ' + + 'conversion — a SemanticMigration converts nothing by its own type, and ' + + '`os migrate meta --stored` (the pass over a deployment\'s `sys_metadata` rows) replays ' + + 'D2 conversions only, so it has nothing to rewrite for this shape. The read path ' + + 'does not re-validate stored rows ' + + '(`applyConversionsToStoredItem` replays the full chain without validating, by its own ' + + 'contract), so a stored page or block carrying the record form keeps loading unchanged ' + + 'and is still rendered by objectui at the pinned `.objectui-sha`; what changes is that ' + + 'RE-SAVING it is refused at the `filter` door, on its next save and not before.', + acceptanceCriteria: + '`ElementDataSourceSchema.safeParse({ object, filter: [{ field: \'status\', operator: ' + + '\'equals\', value: \'active\' }] })` succeeds and the parsed `filter` is the same rule ' + + 'array; `ComponentPropsMap[\'object-grid\' | \'object-metric\' | \'object-kanban\' | ' + + '\'object-calendar\'].safeParse({ filter: })` raises no issue at `filter`; ' + + 'a record-form `filter: { status: \'active\' }` is refused at the `filter` path of all ' + + 'five doors (`invalid_type`, expected array), and an AST tuple array is refused at ' + + '`filter.0` (expected object). No `filter` door in `ComponentPropsMap` accepts the ' + + 'record any more (the twin of the #14406 census pin). At runtime each block and the ' + + 'binding select exactly the rows the array selects — the same filter a list view ' + + 'renders — including the `object-metric` aggregate tile, whose analytics `where` is the ' + + 'lowered condition. Downstream (objectui, after a released spec version reaches the ' + + 'pin): the seventeen `dataSource.filter` test authors at the pin (fifteen tuple arrays, ' + + 'two records) become off-spec fixtures and `ElementDataSourceConfig.filter`\'s ' + + '"three shapes" note narrows — objectui cards filed by the seat, not blocked on here.', + }, { id: 'element-number-filter-rule-array', surface: diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 9a6b63e569..0f02049470 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -275,7 +275,7 @@ import type * as M184 from './shared/value-domain.zod.js'; import type * as M185 from './shared/epoch.zod.js'; // --------------------------------------------------------------------------- -// 815 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 811 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 @@ -1333,7 +1333,11 @@ export type Iso692 = Assert, z.infer< typeof M163.PageTypeSchema > >>; -export type Iso694 = Assert, z.infer< typeof M163.ElementDataSourceSchema > >>; +// `ElementDataSourceSchema` (Iso694) left the family on #15442 — the ui#6206-B +// filter orthography reaching the binding-level `dataSource.filter`: it now +// carries `z.array(ViewFilterRuleSchema)`, whose own input ≠ infer (`operator` +// is normalized on parse), so `ElementDataSourceParsed` is declared and this +// pin deleted. // ui/report.zod.ts export type Iso695 = Assert, z.infer< typeof M164.JoinedReportBlockSchema > >>; @@ -1587,9 +1591,11 @@ export type Iso849 = Assert, z.inf // `ObjectGridPropsSchema` (Iso839) left the family exactly that way on the // ui#6207 convergence: its `data` now carries `ViewDataSchema`, whose own // input ≠ infer, so `ObjectGridPropsParsed` is declared and the pin deleted. -export type Iso840 = Assert, z.infer< typeof M170.ObjectMetricPropsSchema > >>; -export type Iso841 = Assert, z.infer< typeof M170.ObjectKanbanPropsSchema > >>; -export type Iso842 = Assert, z.infer< typeof M170.ObjectCalendarPropsSchema > >>; +// `ObjectMetricPropsSchema` (Iso840), `ObjectKanbanPropsSchema` (Iso841) and +// `ObjectCalendarPropsSchema` (Iso842) left the same way on #15449 — the +// ui#6206-B filter orthography reaching the four `object-*` `filter` doors: +// each now carries `z.array(ViewFilterRuleSchema)` (input ≠ infer), so the +// three `XParsed` aliases are declared and the three pins deleted. export type Iso843 = Assert, z.infer< typeof M170.ObjectFormPropsSchema > >>; export type Iso844 = Assert, z.infer< typeof M170.ObjectMasterDetailFormPropsSchema > >>; @@ -1686,7 +1692,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 815 isomorphic pins', () => { + it('still declares all 811 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 @@ -2172,7 +2178,17 @@ describe('ADR-0122 type-alias convention', () => { // `AnalyticsDateRangeSchema` (its union with `z.array(z.string())`) — no // default, no transform on either arm, two new pins (`Iso869` / `Iso870`). // +2 added. - expect(pins).toHaveLength(815); + // + // 815 -> 811 is the ui#6206-B filter-orthography family convergence + // (#15442 + #15449, decision batch #55, option A): `ElementDataSourceSchema` + // (the binding-level `dataSource.filter`) and `ObjectMetricPropsSchema` / + // `ObjectKanbanPropsSchema` / `ObjectCalendarPropsSchema` (three of the four + // `object-*` `filter` doors; `object-grid` had already left on ui#6207) now + // carry `z.array(ViewFilterRuleSchema)`, whose own input ≠ infer, so each + // left the isomorphic family the way ADR-0122 prescribes: the `XParsed` + // alias declared, the pin deleted. -4 converted to `XParsed` pairs; the + // Iso numbers stay vacant (ids are claims about pins, not positions). + expect(pins).toHaveLength(811); // 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 49a20ae875..21e11472da 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -1901,10 +1901,11 @@ describe("element:record_picker `filter` — one filter orthography platform-wid // 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". + // name. The holdout shape is exactly "declares `filter`, refuses the + // array". A door declaring `z.unknown()` accepted both forms and was never + // a holdout of THIS census by construction — which is why the four + // `object-*` doors needed the complementary pin below (#15449): "every + // `filter` door refuses the record". type Door = { shape?: Record; safeParse: (v: unknown) => ParseResult }; const doors = (Object.entries(ComponentPropsMap) as Array<[string, unknown]>) .filter(([, schema]) => { @@ -1923,6 +1924,89 @@ describe("element:record_picker `filter` — one filter orthography platform-wid }); }); +// --------------------------------------------------------------------------- +// The four `object-*` `filter` doors — the ViewFilterRule ARRAY orthography +// (ui#6206-B reaching the object-* family: #15449, folded into #15442, +// decision batch #55, option A: family-wide, one ADR-0087 D3 entry) +// --------------------------------------------------------------------------- +describe('the four `object-*` `filter` doors — one filter orthography platform-wide (ui#6206-B, #15449)', () => { + const OBJECT_DOORS = ['object-grid', 'object-metric', 'object-kanban', 'object-calendar'] as const; + const RULES = [{ field: 'status', operator: 'not_equals', value: 'done' }]; + const RECORD_FORM = { status: { $ne: 'done' } }; + /** The showcase's `object-grid` used to author THIS — an ObjectQL AST tuple array. */ + const TUPLE_ARRAY = [['owner_id', '=', '{current_user_id}']]; + type ParseResult = { success: boolean; data?: { filter?: unknown }; error?: { issues: Array<{ path: PropertyKey[]; code: string }> } }; + type Door = { shape?: Record; safeParse: (v: unknown) => ParseResult }; + const door = (type: string) => ComponentPropsMap[type as keyof typeof ComponentPropsMap] as unknown as Door; + const issuesAtPath = (r: ParseResult, path: string) => + r.success ? [] : r.error!.issues.filter((i) => i.path.join('.') === path); + + it.each(OBJECT_DOORS)('%s accepts a ViewFilterRule[] filter and echoes it — the acceptance criterion', (type) => { + // Measured at the objectui pin `53ded82b` before the declarations moved: + // grid lowers the rule array through `toFilterNode`; kanban and calendar + // hand it verbatim to `$filter`, where `convertQueryParams` lowers it; the + // metric's aggregate path lowers it through `translateFilterArray` and + // `parseFilterAST` before `POST /analytics/query` (objectui#7754 — the + // door the family was sequenced behind, #15828 / #16626). + const r = door(type).safeParse({ objectName: 'showcase_task', filter: RULES }); + expect(r.success).toBe(true); + expect(r.data!.filter).toEqual(RULES); + }); + + it.each(OBJECT_DOORS)('%s carries the REAL ViewFilterRuleSchema: a legacy operator spelling normalizes on parse', (type) => { + // `z.unknown()` echoed `ne` back unchanged; the real rule schema lowers it. + const r = door(type).safeParse({ objectName: 'showcase_task', filter: [{ field: 'status', operator: 'ne', value: 'done' }] }); + expect(r.success).toBe(true); + expect((r.data!.filter as Array<{ operator: string }>)[0].operator).toBe('not_equals'); + }); + + it.each(OBJECT_DOORS)('%s REFUSES the MongoDB-style record at the `filter` path — what `z.unknown()` used to take silently', (type) => { + // Reverse verification on the issue envelope: located at `filter`, kind + // named. Before #15449 this exact value parsed with zero issues on every + // one of the four doors (measured, census report on #15442). Migration: + // `element-data-source-and-object-block-filter-rule-array`. + const r = door(type).safeParse({ objectName: 'showcase_task', filter: RECORD_FORM }); + expect(r.success).toBe(false); + const atFilter = issuesAtPath(r, 'filter'); + expect(atFilter).toHaveLength(1); + expect(atFilter[0].code).toBe('invalid_type'); + expect(atFilter[0]).toMatchObject({ expected: 'array' }); + const plain = door(type).safeParse({ objectName: 'showcase_task', filter: { status: 'done' } }); + expect(issuesAtPath(plain, 'filter').map((i) => i.code)).toEqual(['invalid_type']); + }); + + it.each(OBJECT_DOORS)('%s REFUSES the ObjectQL AST tuple array at `filter.0` — the other shape `z.unknown()` took', (type) => { + // The showcase's work-queue grid authored this until #15442 migrated it to + // the rule object; the container is right and the element is the wrong + // kind, so the refusal sits one hop deeper than the record's. + const r = door(type).safeParse({ objectName: 'showcase_task', filter: TUPLE_ARRAY }); + expect(r.success).toBe(false); + expect(issuesAtPath(r, 'filter')).toEqual([]); + expect(issuesAtPath(r, 'filter.0').map((i) => i.code)).toEqual(['invalid_type']); + }); + + it('every `filter` door in ComponentPropsMap refuses the record — the twin of the #14406 census pin', () => { + // The #14406 pin above asks "does any `filter` door refuse the ARRAY?" and + // cannot see an accept-anything door by construction. This is the other + // half of "one filter orthography": asked over the WHOLE map by shape, so + // a future entry declaring `filter` as `z.unknown()` or as the record is + // caught here by name. Guarded the same way — the doors pinned above must + // be found, or the shape read has gone wrong and the loop is vacuous. + 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); + expect(doors).toEqual(expect.arrayContaining([...OBJECT_DOORS, 'element:record_picker', 'element:number', 'record:related_list'])); + const recordTakers = doors.filter((type) => { + const r = door(type).safeParse({ filter: RECORD_FORM }); + return issuesAtPath(r, 'filter').length === 0; + }); + expect(recordTakers).toEqual([]); + }); +}); + // --------------------------------------------------------------------------- // Interactive Elements — element:text_input // --------------------------------------------------------------------------- @@ -2569,12 +2653,16 @@ describe('#7751 — object-* block props schemas', () => { }); it('the corrected #7750 node (my-work.page.ts, post-fix) parses GREEN and retains its filter', () => { + // The node as the showcase authors it since #15442 / #15449: the + // `ViewFilterRule` array (ui#6206-B). The AST tuple this pin carried + // before is refused at `filter.0` now — pinned in the object-* filter + // describe below. const parsed = ComponentPropsMap['object-grid'].parse({ objectName: 'showcase_task', columns: ['title', 'project', 'status', 'priority', 'due_date'], - filter: [['owner_id', '=', '{current_user_id}']], + filter: [{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }], }); - expect(parsed.filter).toEqual([['owner_id', '=', '{current_user_id}']]); + expect(parsed.filter).toEqual([{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }]); }); it("object-grid `data` takes the ViewDataSchema provider object — the ui#6207 convergence (Option A)", () => { @@ -2649,10 +2737,13 @@ describe('#7751 — object-* block props schemas', () => { it('every object-metric node of the showcase corpus parses GREEN (the clean-corpus control)', () => { // Copies of all three my-work.page.ts metrics + the command-center shape // (variant/format) — the exact nodes the lint must NOT start warning on. + // The three filters are the `ViewFilterRule` arrays the showcase authors + // since #15442 / #15449 (ui#6206-B); the records they replaced are refused + // at `filter` now (pinned in the object-* filter describe above). const nodes = [ - { objectName: 'showcase_task', label: 'Open Tasks', icon: 'list-checks', colorVariant: 'blue', description: 'not done', aggregate: { field: 'id', function: 'count' }, filter: { status: { $ne: 'done' } } }, - { objectName: 'showcase_task', label: 'In Review', icon: 'eye', colorVariant: 'warning', description: 'awaiting review', aggregate: { field: 'id', function: 'count' }, filter: { status: 'in_review' } }, - { objectName: 'showcase_project', label: 'At-Risk Projects', icon: 'alert-triangle', colorVariant: 'danger', description: 'health red', aggregate: { field: 'id', function: 'count' }, filter: { health: 'red' } }, + { objectName: 'showcase_task', label: 'Open Tasks', icon: 'list-checks', colorVariant: 'blue', description: 'not done', aggregate: { field: 'id', function: 'count' }, filter: [{ field: 'status', operator: 'not_equals', value: 'done' }] }, + { objectName: 'showcase_task', label: 'In Review', icon: 'eye', colorVariant: 'warning', description: 'awaiting review', aggregate: { field: 'id', function: 'count' }, filter: [{ field: 'status', operator: 'equals', value: 'in_review' }] }, + { objectName: 'showcase_project', label: 'At-Risk Projects', icon: 'alert-triangle', colorVariant: 'danger', description: 'health red', aggregate: { field: 'id', function: 'count' }, filter: [{ field: 'health', operator: 'equals', value: 'red' }] }, { objectName: 'showcase_task', label: 'Tasks', colorVariant: 'purple', variant: 'bare', aggregate: { field: 'id', function: 'count' }, format: '0,0' }, ]; for (const node of nodes) { diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index cf728dec07..4b888d0400 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -2463,8 +2463,26 @@ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ .describe('Columns: field names or column definition objects'), fields: z.array(z.unknown()).optional() .describe('Field list fallback used when `columns` is absent'), - filter: z.unknown().optional() - .describe('Base query filter (ObjectQL filter array/AST) — lowered to the wire `$filter`. THE key, singular — not the plural misspelling'), + /** + * Base query filter — the `ViewFilterRule` ARRAY form, + * `[{ field, operator, value }, ...]`, the one filter orthography every + * `filter` door in this map shares (ui#6206-B; reached the four `object-*` + * doors and the binding-level `dataSource.filter` on #15442 / #15449, + * decision batch #55, verbatim 「同意」, option A: family-wide). The + * `z.unknown()` this door carried was a read-point record written twelve + * days before that ruling (#7751), not an exception to it: it accepted the + * MongoDB-style record, the AST tuple array and the rule array alike, so + * an author following the showcase and an author following the manifest + * each got a silent success receipt for a different shape. Measured at the + * objectui pin `53ded82b` before the declaration moved: `ObjectGrid.tsx` + * lowers `schema.filter` through `toFilterNode`, whose rule-array arm maps + * each rule to an AST node before `$filter` — the door every saved view's + * stored rules already take. The record and tuple forms are refused at + * `filter`; the migration prescription is the + * `element-data-source-and-object-block-filter-rule-array` semantic entry. + */ + filter: z.array(ViewFilterRuleSchema).optional() + .describe('Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. THE key, singular — not the plural misspelling. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array`'), defaultFilters: z.unknown().optional() .describe('Legacy base-filter fallback, read only when `filter` is absent. Prefer `filter`'), sort: z.unknown().optional().describe('Initial sort (array of { field, order })'), @@ -2621,7 +2639,25 @@ export const ObjectMetricPropsSchema = lazySchema(() => strictObject({ .optional().describe('Icon container color variant'), aggregate: z.unknown().optional() .describe('Aggregation config ({ field, function, groupBy? }) run against the object'), - filter: z.unknown().optional().describe('Filter the aggregation is scoped by'), + /** + * Filter the aggregation is scoped by — the `ViewFilterRule` ARRAY form, + * the one filter orthography every `filter` door in this map shares (#15449, + * the family entry above on `object-grid` carries the ruling). This door was + * the one the family had to be sequenced behind: with `aggregate` the widget + * posts the filter as the `where` of `POST /analytics/query`, whose request + * schema takes only a `FilterCondition`, and at the pin `a472b07` the adapter + * posted an array verbatim — a 400 on every array form (#15828). At the pin + * this repo builds against (`53ded82b`, objectui#7754) the adapter lowers an + * authored array through `translateFilterArray` and the spec's own + * `parseFilterAST` sink before the wire (`lowerAnalyticsFilterForWire`), so + * the rule array reaches the analytics door as the condition it declares; + * `resolveFilterPlaceholders` walks arrays and objects alike, so the date + * macros and `{current_user_id}` still resolve. The record form is refused + * at `filter`; see migration + * `element-data-source-and-object-block-filter-rule-array`. + */ + filter: z.array(ViewFilterRuleSchema).optional() + .describe('Filter the aggregation is scoped by — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array`'), format: z.string().optional().describe("Number format pattern (e.g. '0,0', '$0,0', '0%')"), currency: z.string().optional().describe("ISO currency code (e.g. 'USD') — enables currency formatting"), prefix: z.string().optional().describe('Static prefix before the formatted value'), @@ -2636,6 +2672,14 @@ export const ObjectMetricPropsSchema = lazySchema(() => strictObject({ })); /** Author state (ADR-0122: the bare name is the author state). */ export type ObjectMetricProps = z.input; +/** + * ADR-0122: the parsed state differs from the authored state on exactly one + * key — `filter` carries `z.array(ViewFilterRuleSchema)` (the ui#6206-B family + * convergence, #15449), whose own input ≠ infer (`operator` is normalized on + * parse). So `object-metric` leaves the type-alias convention pin's default-free + * family the way `object-grid` did, taking the `ObjectGridPropsParsed` route. + */ +export type ObjectMetricPropsParsed = z.infer; /** * `object-kanban` (objectui `plugin-kanban/src/ObjectKanban.tsx` + @@ -2669,7 +2713,19 @@ export const ObjectKanbanPropsSchema = lazySchema(() => strictObject({ groupBy: z.string().optional().describe('Field whose values become the board columns'), columns: z.array(z.unknown()).optional() .describe('Swimlane definitions ({ id, title } per `groupBy` value, or bare value strings) — NOT a field projection'), - filter: z.unknown().optional().describe('Base query filter, handed to the wire `$filter`'), + /** + * Base query filter — the `ViewFilterRule` ARRAY form, the one filter + * orthography every `filter` door in this map shares (#15449; the family + * entry on `object-grid` carries the ruling). Measured at the objectui pin + * `53ded82b` before the declaration moved: `ObjectKanban.tsx` hands + * `schema.filter` verbatim to `$filter`, and `ObjectStackAdapter.convertQueryParams` + * lowers a rule array through `translateFilterArray` — the same door every + * list view's stored rule array takes. The record form is refused at + * `filter`; see migration + * `element-data-source-and-object-block-filter-rule-array`. + */ + filter: z.array(ViewFilterRuleSchema).optional() + .describe('Base query filter, handed to the wire `$filter` — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array`'), /** * Row cap (#16503 — the spec half of objectui#8172; decision batch #68, * 2026-09-07, option A: the contract declares the capability that already @@ -2718,6 +2774,14 @@ export const ObjectKanbanPropsSchema = lazySchema(() => strictObject({ })); /** Author state (ADR-0122: the bare name is the author state). */ export type ObjectKanbanProps = z.input; +/** + * ADR-0122: the parsed state differs from the authored state on exactly one + * key — `filter` carries `z.array(ViewFilterRuleSchema)` (the ui#6206-B family + * convergence, #15449), whose own input ≠ infer (`operator` is normalized on + * parse). So `object-kanban` leaves the type-alias convention pin's default-free + * family the way `object-grid` did, taking the `ObjectGridPropsParsed` route. + */ +export type ObjectKanbanPropsParsed = z.infer; /** * The flat per-field spellings `ObjectCalendar` keeps reading as a @@ -2760,7 +2824,18 @@ export const ObjectCalendarPropsSchema = lazySchema(() => strictObject({ calendar: z.unknown().optional() .describe('Calendar field config: { startDateField, endDateField?, titleField?, colorField?, allDayField? }'), defaultView: z.enum(['month', 'week', 'day']).optional().describe('Initial view mode'), - filter: z.unknown().optional().describe('Base query filter'), + /** + * Base query filter — the `ViewFilterRule` ARRAY form, the one filter + * orthography every `filter` door in this map shares (#15449; the family + * entry on `object-grid` carries the ruling). Measured at the objectui pin + * `53ded82b` before the declaration moved: `ObjectCalendar.tsx` hands + * `schema.filter` verbatim to `$filter` and the adapter lowers a rule array + * exactly as it does for the kanban. The record form is refused at + * `filter`; see migration + * `element-data-source-and-object-block-filter-rule-array`. + */ + filter: z.array(ViewFilterRuleSchema).optional() + .describe('Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array`'), sort: z.unknown().optional().describe('Sort for the fetched events'), data: z.array(z.unknown()).optional().describe('Pre-fetched records — skips the internal fetch'), staticData: z.array(z.unknown()).optional().describe('Static inline records'), @@ -2769,6 +2844,14 @@ export const ObjectCalendarPropsSchema = lazySchema(() => strictObject({ })); /** Author state (ADR-0122: the bare name is the author state). */ export type ObjectCalendarProps = z.input; +/** + * ADR-0122: the parsed state differs from the authored state on exactly one + * key — `filter` carries `z.array(ViewFilterRuleSchema)` (the ui#6206-B family + * convergence, #15449), whose own input ≠ infer (`operator` is normalized on + * parse). So `object-calendar` leaves the type-alias convention pin's default-free + * family the way `object-grid` did, taking the `ObjectGridPropsParsed` route. + */ +export type ObjectCalendarPropsParsed = z.infer; /** * `object-form` (objectui `plugin-form/src/ObjectForm.tsx` @ `eb7f586b`, plus diff --git a/packages/spec/src/ui/page.test.ts b/packages/spec/src/ui/page.test.ts index 68996732c9..9dd4c60086 100644 --- a/packages/spec/src/ui/page.test.ts +++ b/packages/spec/src/ui/page.test.ts @@ -15,6 +15,7 @@ import { type ElementDataSource, type InterfacePageConfig, } from './page.zod'; +import { ComponentPropsMap } from './component.zod'; describe('PageComponentSchema', () => { it('should accept valid minimal component', () => { @@ -630,7 +631,7 @@ describe('ElementDataSourceSchema', () => { const ds = ElementDataSourceSchema.parse({ object: 'invoice', view: 'pending_review', - filter: { status: 'pending' }, + filter: [{ field: 'status', operator: 'equals', value: 'pending' }], sort: [{ field: 'created_at', order: 'desc' }], limit: 50, }); @@ -653,6 +654,116 @@ describe('ElementDataSourceSchema', () => { }); }); +// --------------------------------------------------------------------------- +// ElementDataSourceSchema `filter` — the ViewFilterRule ARRAY orthography +// (ui#6206-B reaching the binding: #15442, decision batch #55, option A) +// --------------------------------------------------------------------------- +describe('ElementDataSourceSchema `filter` — one filter orthography platform-wide (ui#6206-B, #15442)', () => { + const RULES = [{ field: 'status', operator: 'equals', value: 'active' }]; + const RECORD_FORM = { status: 'active' }; + /** The objectui pin's own test authors write THIS at `dataSource.filter` — an AST tuple array. */ + const TUPLE_ARRAY = [['amount', '>', 100]]; + type ParseResult = { success: boolean; error?: { issues: Array<{ path: PropertyKey[]; code: string }> } }; + /** The issues a parse raised under `key` (top-level), whatever else it raised. */ + const issuesUnder = (r: ParseResult, key: string) => + r.success ? [] : r.error!.issues.filter((i) => i.path[0] === key); + /** The issues located EXACTLY at `path` (dotted) — `filter` is not `filter.0`. */ + const issuesAt = (r: ParseResult, path: string) => + r.success ? [] : r.error!.issues.filter((i) => i.path.join('.') === path); + + it('accepts a ViewFilterRule[] filter — the acceptance criterion', () => { + // Before #15442 this exact value was REFUSED here (`invalid_type`, expected + // record): the binding alone said `FilterConditionSchema` while every + // `filter` door in ComponentPropsMap took the array. Measured at the + // objectui pin `53ded82b` before the declaration moved: the composition + // seam AND-combines the binding filter with the named view's rules through + // `mergeFilterNodes`, whose `toFilterNode` lowers a rule array to AST nodes. + const r = ElementDataSourceSchema.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', () => { + // `ne` is a legacy spelling `normalizeFilterOperator` lowers to `not_equals` + // — a plain `z.array(z.object(...))` would have echoed it back unchanged. + const legacy = ElementDataSourceSchema.safeParse({ + object: 'account', + filter: [{ field: 'status', operator: 'ne', value: 'done' }], + }); + expect(legacy.success).toBe(true); + expect(legacy.data!.filter![0].operator).toBe('not_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 = ElementDataSourceSchema.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 key 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-data-source-and-object-block-filter-rule-array`. + const r = ElementDataSourceSchema.safeParse({ object: 'account', filter: RECORD_FORM }); + expect(r.success).toBe(false); + const atFilter = issuesUnder(r, 'filter'); + expect(atFilter).toHaveLength(1); + expect(atFilter[0].code).toBe('invalid_type'); + expect(atFilter[0]).toMatchObject({ expected: 'array', path: ['filter'] }); + // An operator-object record and a `$or` group are the same form and get + // the same verdict — no arm accepts any spelling of the record. + const opRecord = ElementDataSourceSchema.safeParse({ object: 'account', filter: { amount: { $gt: 100 } } }); + expect(issuesUnder(opRecord, 'filter').map((i) => i.code)).toEqual(['invalid_type']); + const group = ElementDataSourceSchema.safeParse({ object: 'account', filter: { $or: [{ status: 'active' }] } }); + expect(issuesUnder(group, 'filter').map((i) => i.code)).toEqual(['invalid_type']); + }); + + it('the ObjectQL AST tuple array is refused too — at `filter.0`, not at `filter`', () => { + // The consumer's pinned tests author `[['amount', '>', 100]]` at this key + // (objectui `record-picker-element-data-source.test.tsx`). That shape was + // refused before (`invalid_type` at `filter`, expected record) and stays + // refused now — one hop deeper, because the array is the right container + // and the element is the wrong kind. The prescription is the rule object + // `{ field: 'amount', operator: 'greater_than', value: 100 }`; the + // seventeen off-spec authors at the pin are the seat's objectui follow-up. + const r = ElementDataSourceSchema.safeParse({ object: 'account', filter: TUPLE_ARRAY }); + expect(r.success).toBe(false); + expect(issuesAt(r, 'filter')).toEqual([]); + expect(issuesAt(r, 'filter.0').map((i) => i.code)).toEqual(['invalid_type']); + expect(issuesAt(r, 'filter.0')[0]).toMatchObject({ expected: 'object' }); + }); + + it('shares the array orthography with the props-map `filter` doors — one value, two keys, the same verdicts', () => { + // `element:record_picker` was the node that carried two orthographies at + // two keys (`properties.filter` the array, `dataSource.filter` the record) + // resolved through one `??` in the renderer. Each key is asked at ITS + // door: the binding through the real `PageComponentSchema` (which parses + // `dataSource` and leaves `properties` a bag — the props-map dispatch is + // the lint's, warning tier), and the props key through the picker's own + // `ComponentPropsMap` entry. The same rule array raises no issue at either; + // the same record is refused at both with the same code. + const binding = PageComponentSchema.safeParse({ + type: 'element:record_picker', + properties: { object: 'account', filter: RULES }, + dataSource: { object: 'account', filter: RULES }, + }); + expect(binding.success).toBe(true); + const bindingRecord = PageComponentSchema.safeParse({ + type: 'element:record_picker', + properties: { object: 'account', filter: RULES }, + dataSource: { object: 'account', filter: RECORD_FORM }, + }); + expect(issuesAt(bindingRecord, 'dataSource.filter').map((i) => i.code)).toEqual(['invalid_type']); + const picker = ComponentPropsMap['element:record_picker']; + expect(issuesAt(picker.safeParse({ object: 'account', filter: RULES }), 'filter')).toEqual([]); + expect(issuesAt(picker.safeParse({ object: 'account', filter: RECORD_FORM }), 'filter').map((i) => i.code)) + .toEqual(issuesAt(bindingRecord, 'dataSource.filter').map((i) => i.code)); + }); +}); + // --------------------------------------------------------------------------- // PageComponent dataSource integration // --------------------------------------------------------------------------- @@ -663,7 +774,7 @@ describe('PageComponent dataSource integration', () => { properties: { object: 'order', aggregate: 'sum', field: 'total' }, dataSource: { object: 'order', - filter: { status: 'completed' }, + filter: [{ field: 'status', operator: 'equals', value: 'completed' }], limit: 100, }, }); @@ -759,12 +870,12 @@ describe('Page end-to-end', () => { { type: 'element:number', properties: { object: 'order', aggregate: 'count' }, - dataSource: { object: 'order', filter: { status: 'pending' } }, + dataSource: { object: 'order', filter: [{ field: 'status', operator: 'equals', value: 'pending' }] }, }, { type: 'element:number', properties: { object: 'order', aggregate: 'sum', field: 'total', format: 'currency', prefix: '$' }, - dataSource: { object: 'order', filter: { status: 'completed' } }, + dataSource: { object: 'order', filter: [{ field: 'status', operator: 'equals', value: 'completed' }] }, }, { type: 'element:divider', diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index 496bbb40a5..4f30ce88c0 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -6,7 +6,6 @@ import { ExpressionInputSchema } from '../shared/expression.zod'; import { normalizeVisibleWhen } from '../shared/visibility'; import { VISIBILITY_ONLY_STRICT_OPTIONS } from '../shared/editability-boundary'; import { SortItemSchema } from '../shared/enums.zod'; -import { FilterConditionSchema } from '../data/filter.zod'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; import { ResponsiveStylesSchema } from './responsive.zod'; import { retiredKey } from '../shared/retired-key'; @@ -166,7 +165,27 @@ export const ElementDataSourceSchema = lazySchema(() => strictObject({ }, { object: z.string().describe('Object to query'), view: z.string().optional().describe('Named view to apply'), - filter: FilterConditionSchema.optional().describe('Additional filter criteria'), + /** + * Additional filter — the `ViewFilterRule` ARRAY form, + * `[{ field, operator, value }, ...]`, the one filter orthography every + * `filter` door in `ComponentPropsMap` declares (`record:related_list`, its + * Add-affordance picker, `element:number`, `element:record_picker`, and the + * four `object-*` blocks). Until the ui#6206-B principle reached this key + * (#15442, decision batch #55, verbatim 「同意」, option A: family-wide) the + * binding alone said `FilterConditionSchema`, the MongoDB-style record — so + * `element:record_picker` carried two orthographies at two keys resolved by + * one `??` in the renderer, and the consumer's own pins authored an array + * here that this declaration refused. Measured at the objectui pin + * `53ded82b` before the declaration moved: the composition seam + * (`core/src/data-scope/element-data-source.ts`) types the key `unknown` + * and AND-combines it with the named view's own rule array through + * `mergeFilterNodes`, whose `toFilterNode` lowers a rule array to AST + * nodes — the same door every saved view's stored rules take. The record + * form is refused at `filter`; the migration prescription is the + * `element-data-source-and-object-block-filter-rule-array` semantic entry. + */ + filter: z.array(ViewFilterRuleSchema).optional() + .describe('Additional filter criteria — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in ComponentPropsMap shares; AND-combined with the filter of the named view. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array`'), sort: z.array(SortItemSchema).optional().describe('Sort order'), limit: z.number().int().positive().optional().describe('Max records to display'), })); @@ -812,6 +831,14 @@ export type PageVariable = z.input; /** Post-parse shape of {@link PageVariable} — defaults applied, transforms run (ADR-0122). */ export type PageVariableParsed = z.infer; export type ElementDataSource = z.input; +/** + * Post-parse shape of {@link ElementDataSource} — defaults applied, transforms + * run (ADR-0122). Declared on #15442: `filter` carries + * `z.array(ViewFilterRuleSchema)`, whose own input ≠ infer (`operator` is + * normalized on parse), so the binding left the type-alias convention pin's + * isomorphic family the way `element:number` and `element:record_picker` did. + */ +export type ElementDataSourceParsed = z.infer; export type InterfacePageConfig = z.input; /** Post-parse shape of {@link InterfacePageConfig} — defaults applied, transforms run (ADR-0122). */ export type InterfacePageConfigParsed = z.infer;