From 139551771e7a86407d0362850470b7f5a9550a6f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 03:24:46 +0000 Subject: [PATCH 1/2] feat(spec): converge ComponentPropsMap['element:number'].filter onto the ViewFilterRule array form (ui#6206 Option B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accept-set change on one props-map entry: `filter` was `FilterConditionSchema` (the MongoDB-style record), now `z.array(ViewFilterRuleSchema)` — the one filter orthography every sibling `filter` input in the map already declares. Sequenced consumer-first per the 2026-08-25 Option-A ordering ruling: the objectui pin (d8ec8d6d) carries objectui#6828, re-measured before this move. ADR-0087 semantic entry `element-number-filter-rule-array` carries the prescription; `ElementNumberPropsParsed` declared and the Iso818 pin deleted with its receipt (ADR-0122); orthography tests added. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GDA48PuRFrHyRfdkBz8m21 --- .../element-number-filter-rule-array.md | 45 ++++++++++ .../18.element-number-filter-rule-array.ts | 45 ++++++++++ packages/spec/src/migrations/registry.ts | 41 +++++++++ .../src/type-alias-convention.pin.test.ts | 20 ++++- packages/spec/src/ui/component.test.ts | 85 +++++++++++++++++++ packages/spec/src/ui/component.zod.ts | 29 ++++++- 6 files changed, 260 insertions(+), 5 deletions(-) create mode 100644 .changeset/element-number-filter-rule-array.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.element-number-filter-rule-array.ts diff --git a/.changeset/element-number-filter-rule-array.md b/.changeset/element-number-filter-rule-array.md new file mode 100644 index 0000000000..60a2a78dea --- /dev/null +++ b/.changeset/element-number-filter-rule-array.md @@ -0,0 +1,45 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): `ComponentPropsMap['element:number'].filter` converges onto the `ViewFilterRule` array form (#12039, objectui#6206 Option B) + + + +**BREAKING** accept-set change on one props-map entry, shipped as `minor` under +the repo's launch-window convention for breaking changes; the migration +prescription is registered under protocol major 18. + +One filter orthography platform-wide (maintainer batch adjudication +2026-08-25, verbatim 「同意」, Option B): `element:number`'s `filter` was the +one `filter` input in `ComponentPropsMap` declared as the MongoDB-style record +(`FilterConditionSchema`) while every sibling (`record:related_list` and its +Add-affordance picker) declared `z.array(ViewFilterRuleSchema)` — so the +filter a list view stores and renders was refused by the KPI element beside +it. The entry now declares the same array form its siblings do. + +Sequenced consumer-first (the 2026-08-25 Option-A ordering ruling): +objectui#6828 made `ObjectStackAdapter.aggregate()` lower a rule array through +the same `translateFilterArray` its `find()` path runs before the analytics +wire; the objectui pin carrying it (`d8ec8d6d`) was re-measured before this +declaration moved, and the end-to-end chain is authored array → adapter +lowering → filter AST → accepted by `lowerAnalyticsWhere` (which still refuses +a RAW rule-object array — by design, and unchanged here). + +Migration — FROM → TO, one rule per key (several keys AND): + +```ts +// before (refused now — `invalid_type` at `filter`, expected array) +filter: { status: 'won' } +filter: { amount: { $gt: 100 } } +// after +filter: [{ field: 'status', operator: 'equals', value: 'won' }] +filter: [{ field: 'amount', operator: 'greater_than', value: 100 }] +``` + +The ruled migration check ran with the change: the sweep of first-party +corpora (examples/, skills/, create-objectstack, content/docs/, packages/apps/, +spec fixtures) found zero `element:number` authors writing a record-form +`filter`, so no rewrite ships. `ElementNumberPropsParsed` is declared +(ADR-0122: the parsed state now differs on `filter`, whose `operator` is +normalized on parse). `element:record_picker.filter` is not changed here. diff --git a/packages/spec/src/migrations/entries/semantic/18.element-number-filter-rule-array.ts b/packages/spec/src/migrations/entries/semantic/18.element-number-filter-rule-array.ts new file mode 100644 index 0000000000..771693e19f --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.element-number-filter-rule-array.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'element-number-filter-rule-array', + surface: + "`element:number` component props — `filter` (the FORM: the MongoDB-style " + + '`FilterConditionSchema` record vs the `ViewFilterRule` array)', + replacement: + '`z.array(ViewFilterRuleSchema)` — the rule array `[{ field, operator, value }, ...]` ' + + 'every other `filter` input in `ComponentPropsMap` already declares ' + + '(`record:related_list` and its Add-affordance picker). A record-form filter ' + + "`{ status: 'won' }` becomes `[{ field: 'status', operator: 'equals', value: 'won' }]`; " + + "an operator object `{ amount: { $gt: 100 } }` becomes " + + "`[{ field: 'amount', operator: 'greater_than', value: 100 }]`; several keys become " + + 'several rules (they AND). Legacy operator shorthands (`eq`, `gt`, `notIn`, …) are ' + + 'accepted and normalized on parse', + reason: + 'One filter orthography platform-wide (objectui#6206, maintainer batch adjudication ' + + "2026-08-25, verbatim 「同意」, Option B). `ComponentPropsMap['element:number'].filter` " + + 'was the one `filter` input in the map declared as the MongoDB-style record ' + + '(`FilterConditionSchema`) while its siblings declared the `ViewFilterRule` array, so ' + + 'the filter a list view stores and renders was refused by the KPI element beside it, ' + + 'and the objectui parity gate had to carry a reasoned exemption to look away. The ' + + 'convergence was sequenced consumer-first (ruling recorded 2026-08-25, Option A): ' + + 'objectui#6828 made `ObjectStackAdapter.aggregate()` lower a rule array through the ' + + 'same `translateFilterArray` its `find()` path runs before the analytics wire, and the ' + + 'objectui pin carrying it was re-measured before this entry moved — authored array → ' + + 'adapter lowering → filter AST → accepted by `lowerAnalyticsWhere`, which still refuses ' + + 'a RAW rule-object array by design. The ruled migration check ran with the change: the ' + + 'sweep of first-party corpora (examples/, skills/, create-objectstack, content/docs/, ' + + 'packages/apps/, spec fixtures) found ZERO `element:number` authors writing a ' + + 'record-form `filter`, so no rewrite ships — this entry carries the prescription for ' + + 'authors outside the repo.', + acceptanceCriteria: + "`ComponentPropsMap['element:number'].safeParse({ object, aggregate, filter: [{ field: " + + "'status', operator: 'equals', value: 'won' }] })` succeeds and the parsed `filter` is " + + "the same rule array; a record-form `filter: { status: 'won' }` is refused at the " + + '`filter` path (`invalid_type`, expected array). At runtime the element renders its ' + + 'aggregate on an analytics-capable deployment with the array filter applied — the same ' + + 'filter a list view renders. Downstream (objectui, after a released spec version reaches ' + + 'the pin): the registry declaration for this input and the spec entry agree with no ' + + 'parity exemption, which is what closes objectui#6206.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index a92f299b91..ff741500f9 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -6194,6 +6194,47 @@ const step18: MigrationStep = { + 'correct but whose other columns belong to a different record, since no error was ever ' + 'raised for those writes.', }, + { + id: 'element-number-filter-rule-array', + surface: + "`element:number` component props — `filter` (the FORM: the MongoDB-style " + + '`FilterConditionSchema` record vs the `ViewFilterRule` array)', + replacement: + '`z.array(ViewFilterRuleSchema)` — the rule array `[{ field, operator, value }, ...]` ' + + 'every other `filter` input in `ComponentPropsMap` already declares ' + + '(`record:related_list` and its Add-affordance picker). A record-form filter ' + + "`{ status: 'won' }` becomes `[{ field: 'status', operator: 'equals', value: 'won' }]`; " + + "an operator object `{ amount: { $gt: 100 } }` becomes " + + "`[{ field: 'amount', operator: 'greater_than', value: 100 }]`; several keys become " + + 'several rules (they AND). Legacy operator shorthands (`eq`, `gt`, `notIn`, …) are ' + + 'accepted and normalized on parse', + reason: + 'One filter orthography platform-wide (objectui#6206, maintainer batch adjudication ' + + "2026-08-25, verbatim 「同意」, Option B). `ComponentPropsMap['element:number'].filter` " + + 'was the one `filter` input in the map declared as the MongoDB-style record ' + + '(`FilterConditionSchema`) while its siblings declared the `ViewFilterRule` array, so ' + + 'the filter a list view stores and renders was refused by the KPI element beside it, ' + + 'and the objectui parity gate had to carry a reasoned exemption to look away. The ' + + 'convergence was sequenced consumer-first (ruling recorded 2026-08-25, Option A): ' + + 'objectui#6828 made `ObjectStackAdapter.aggregate()` lower a rule array through the ' + + 'same `translateFilterArray` its `find()` path runs before the analytics wire, and the ' + + 'objectui pin carrying it was re-measured before this entry moved — authored array → ' + + 'adapter lowering → filter AST → accepted by `lowerAnalyticsWhere`, which still refuses ' + + 'a RAW rule-object array by design. The ruled migration check ran with the change: the ' + + 'sweep of first-party corpora (examples/, skills/, create-objectstack, content/docs/, ' + + 'packages/apps/, spec fixtures) found ZERO `element:number` authors writing a ' + + 'record-form `filter`, so no rewrite ships — this entry carries the prescription for ' + + 'authors outside the repo.', + acceptanceCriteria: + "`ComponentPropsMap['element:number'].safeParse({ object, aggregate, filter: [{ field: " + + "'status', operator: 'equals', value: 'won' }] })` succeeds and the parsed `filter` is " + + "the same rule array; a record-form `filter: { status: 'won' }` is refused at the " + + '`filter` path (`invalid_type`, expected array). At runtime the element renders its ' + + 'aggregate on an analytics-capable deployment with the array filter applied — the same ' + + 'filter a list view renders. Downstream (objectui, after a released spec version reaches ' + + 'the pin): the registry declaration for this input and the spec entry agree with no ' + + 'parity exemption, which is what closes objectui#6206.', + }, { id: 'engine-dotted-filter-refused', surface: diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index e2830d0680..76498f61d1 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -269,7 +269,7 @@ import type * as M170 from './ui/component.zod.js'; import type * as M183 from './api/sortability.zod.js'; // --------------------------------------------------------------------------- -// 836 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 835 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 @@ -1556,7 +1556,11 @@ export type Iso816 = Assert, z.infer< ty export type Iso817 = Assert, z.infer< typeof M155.ActionType > >>; // ui/component.zod.ts -export type Iso818 = Assert, z.infer< typeof M170.ElementNumberPropsSchema > >>; +// `ElementNumberPropsSchema` (Iso818) left the family on the ui#6206 +// convergence: its `filter` now carries `z.array(ViewFilterRuleSchema)`, whose +// own input ≠ infer (`operator` is normalized on parse — `ViewFilterRuleParsed` +// exists for exactly that reason), so `ElementNumberPropsParsed` is declared +// and the pin deleted. export type Iso819 = Assert, z.infer< typeof M170.ElementRecordPickerPropsSchema > >>; export type Iso820 = Assert, z.infer< typeof M170.RecordHighlightsField > >>; export type Iso821 = Assert, z.infer< typeof M170.RecordPathProps > >>; @@ -1682,7 +1686,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 836 isomorphic pins', () => { + it('still declares all 835 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 @@ -2068,9 +2072,17 @@ describe('ADR-0122 type-alias convention', () => { // `.pipe()` anywhere, so the two shapes coincide and ADR-0122 gives it a // pin rather than an `XParsed`. Its id is `Iso866`, the next free one — // ids are claims about pins, not positions. + // + // 836 -> 835 is #12039's ui#6206 convergence (the card's Key 2): + // `ElementNumberPropsSchema.filter` now carries `z.array(ViewFilterRuleSchema)`, + // whose own input ≠ infer (`operator` is normalized on parse — measured: + // `ViewFilterRuleParsed` already exists for exactly that reason), so + // `element:number` left the isomorphic family the way ADR-0122 prescribes: + // `ElementNumberPropsParsed` declared, the Iso818 pin deleted. -1 converted + // to an `XParsed` pair; the Iso number stays vacant. const self = readFileSync(fileURLToPath(import.meta.url), 'utf8'); const pins = self.match(/^export type Iso\d+ = Assert { }); }); +// --------------------------------------------------------------------------- +// element:number `filter` — the ViewFilterRule ARRAY orthography (ui#6206-B) +// --------------------------------------------------------------------------- +describe("element:number `filter` — one filter orthography platform-wide (ui#6206 Option B)", () => { + const number = ComponentPropsMap['element:number']; + const relatedList = ComponentPropsMap['record:related_list']; + const RULES = [{ field: 'status', operator: 'equals', value: 'won' }]; + const RECORD_FORM = { status: 'won' }; + /** The issues a parse raised AT `key` (top-level), whatever else it raised. */ + const issuesAt = (r: { success: boolean; error?: { issues: Array<{ path: PropertyKey[]; code: string }> } }, key: string) => + r.success ? [] : r.error!.issues.filter((i) => i.path[0] === key); + + it('accepts a ViewFilterRule[] filter — the acceptance criterion', () => { + // Before the 2026-08-25 ruling this exact value was REFUSED here (the entry + // said `FilterConditionSchema`, the MongoDB-style record) while every + // sibling `filter` input in the map accepted it. + const r = number.safeParse({ object: 'order', aggregate: 'count', filter: RULES }); + expect(r.success).toBe(true); + expect(r.data!.filter).toEqual(RULES); + }); + + it('the array carries the REAL ViewFilterRuleSchema, not a lookalike: operators normalize, value shapes are checked', () => { + // `eq` is a legacy spelling `normalizeFilterOperator` lowers to `equals` — a + // plain `z.array(z.object(...))` would have echoed it back unchanged. + const legacy = number.safeParse({ + object: 'order', aggregate: 'count', + filter: [{ field: 'status', operator: 'eq', value: 'won' }], + }); + expect(legacy.success).toBe(true); + expect(legacy.data!.filter![0].operator).toBe('equals'); + // `in` takes an array; a scalar is refused at `filter.0.value` by the rule's + // own superRefine — the value-shape check rides in with the schema. + const scalarIn = number.safeParse({ + object: 'order', aggregate: 'count', + filter: [{ field: 'status', operator: 'in', value: 'won' }], + }); + expect(scalarIn.success).toBe(false); + expect(scalarIn.error!.issues.map((i) => i.path.join('.'))).toContain('filter.0.value'); + }); + + it('the MongoDB-style record form — what this entry alone used to accept — is REFUSED at the `filter` path', () => { + // Reverse verification of the convergence, asserted on the issue envelope + // rather than on a bare `success === false`: the refusal is located at + // `filter` and names the expected kind. Migration: + // `element-number-filter-rule-array`. + const r = number.safeParse({ object: 'order', aggregate: 'count', filter: RECORD_FORM }); + expect(r.success).toBe(false); + const atFilter = issuesAt(r, 'filter'); + expect(atFilter).toHaveLength(1); + expect(atFilter[0].code).toBe('invalid_type'); + expect(atFilter[0]).toMatchObject({ expected: 'array' }); + // An operator-object record (`{ amount: { $gt: 100 } }`) is the same form + // and gets the same verdict — no arm accepts any spelling of the record. + const opRecord = number.safeParse({ object: 'order', aggregate: 'sum', field: 'amount', filter: { amount: { $gt: 100 } } }); + expect(opRecord.success).toBe(false); + expect(issuesAt(opRecord, 'filter').map((i) => i.code)).toEqual(['invalid_type']); + }); + + it('shares the array orthography with the sibling `filter` inputs — one value, two doors, the same verdicts', () => { + // The ruling is "one filter orthography platform-wide", so the pin is + // cross-entry: the same rule array raises no issue at `filter` on either + // door, and the same record form is refused at `filter` with the same + // issue code on both. Each door is asked only about ITS `filter` — the + // other keys the related list requires are not this pin's subject. + expect(issuesAt(number.safeParse({ object: 'order', aggregate: 'count', filter: RULES }), 'filter')).toEqual([]); + expect(issuesAt(relatedList.safeParse({ filter: RULES }), 'filter')).toEqual([]); + const numberRefusal = issuesAt(number.safeParse({ object: 'order', aggregate: 'count', filter: RECORD_FORM }), 'filter'); + const relatedRefusal = issuesAt(relatedList.safeParse({ filter: RECORD_FORM }), 'filter'); + expect(numberRefusal.map((i) => i.code)).toEqual(['invalid_type']); + expect(relatedRefusal.map((i) => i.code)).toEqual(numberRefusal.map((i) => i.code)); + }); + + it('positive control: a well-formed multi-rule array with a real `in` rule parses through the element', () => { + const r = number.safeParse({ + object: 'order', aggregate: 'sum', field: 'amount', + filter: [ + { field: 'status', operator: 'in', value: ['won', 'closed'] }, + { field: 'amount', operator: 'greater_than', value: 100 }, + ], + }); + expect(r.success).toBe(true); + expect(r.data!.filter).toHaveLength(2); + }); +}); + // --------------------------------------------------------------------------- // Element Props Schemas // --------------------------------------------------------------------------- diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 82e973e081..17cc5d99e7 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -1799,7 +1799,25 @@ export const ElementNumberPropsSchema = lazySchema(() => strictObject({ field: z.string().optional().describe('Field to aggregate'), aggregate: z.enum(['count', 'sum', 'avg', 'min', 'max']) .describe('Aggregation function'), - filter: FilterConditionSchema.optional().describe('Filter criteria'), + /** + * Filter rules narrowing the aggregate — the `ViewFilterRule` ARRAY form, + * `[{ field, operator, value }, ...]`, the one filter orthography every + * other `filter` input in this map already declares (`record:related_list` + * and its Add-affordance picker). Until the ui#6206 ruling (2026-08-25, + * Option B, verbatim 「同意」: one filter orthography platform-wide) this + * entry alone said `FilterConditionSchema`, the MongoDB-style record form — + * so the filter a list view stores and renders was refused by the KPI + * element beside it. Sequenced consumer-first (the 2026-08-25 Option-A + * ordering ruling): objectui#6828 made `ObjectStackAdapter.aggregate()` + * lower a rule array through the same `translateFilterArray` its `find()` + * path runs, and the pin carrying it (`d8ec8d6d`) was re-measured before + * this declaration moved — authored array → adapter lowering → filter AST → + * accepted at the analytics door (which still refuses a RAW rule-object + * array, by design). The record form is refused at `filter`; the migration + * prescription is the `element-number-filter-rule-array` semantic entry. + */ + filter: z.array(ViewFilterRuleSchema).optional() + .describe('Filter rules narrowing the aggregate — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` input in this map shares. The MongoDB-style record form is refused — see migration `element-number-filter-rule-array`'), format: z.enum(['number', 'currency', 'percent']).optional().describe('Number display format'), prefix: z.string().optional().describe('Prefix text (e.g. "$")'), suffix: z.string().optional().describe('Suffix text (e.g. "%")'), @@ -1807,6 +1825,15 @@ export const ElementNumberPropsSchema = lazySchema(() => strictObject({ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), })); export type ElementNumberProps = z.input; +/** + * ADR-0122: the parsed state differs from the authored state on exactly one + * key — `filter` carries `ViewFilterRuleSchema` (the ui#6206 convergence), + * whose own input ≠ infer (`operator` is normalized on parse, which is why + * `ViewFilterRuleParsed` exists). So `element:number` leaves the type-alias + * convention pin's isomorphic family (the Iso818 line deleted with this + * alias), taking the `ObjectGridPropsParsed` route its comment prescribes. + */ +export type ElementNumberPropsParsed = z.infer; export const ElementImagePropsSchema = lazySchema(() => strictObject({ surface: 'this `element:image`', From 8068a7f317684f55c70fcc33e182ea0d67395cb7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 03:31:35 +0000 Subject: [PATCH 2/2] test(spec): rewrite the one in-repo record-form element:number filter fixture to the array orthography; regenerate api-surface, export-origins, docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ruled migration sweep found exactly one first-party author of the record-form `filter` on `element:number` — `component.test.ts`'s "should accept full number props" fixture — and zero in examples/, skills/, create-objectstack, content/docs/, packages/apps/. The entry and changeset now say so. `check:generated --fix` proved three artifacts stale and regenerated them: api-surface and export-origins carry the new `ElementNumberPropsParsed` export, the docs reference carries the array shape. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GDA48PuRFrHyRfdkBz8m21 --- .changeset/element-number-filter-rule-array.md | 5 +++-- content/docs/references/ui/component.mdx | 12 +++++++++++- packages/spec/api-surface/ui.json | 1 + packages/spec/export-origins/ui.json | 1 + .../semantic/18.element-number-filter-rule-array.ts | 12 +++++++----- packages/spec/src/migrations/registry.ts | 12 +++++++----- packages/spec/src/ui/component.test.ts | 5 ++++- 7 files changed, 34 insertions(+), 14 deletions(-) diff --git a/.changeset/element-number-filter-rule-array.md b/.changeset/element-number-filter-rule-array.md index 60a2a78dea..ed8baaa48d 100644 --- a/.changeset/element-number-filter-rule-array.md +++ b/.changeset/element-number-filter-rule-array.md @@ -39,7 +39,8 @@ filter: [{ field: 'amount', operator: 'greater_than', value: 100 }] The ruled migration check ran with the change: the sweep of first-party corpora (examples/, skills/, create-objectstack, content/docs/, packages/apps/, -spec fixtures) found zero `element:number` authors writing a record-form -`filter`, so no rewrite ships. `ElementNumberPropsParsed` is declared +spec fixtures) found one `element:number` author writing a record-form +`filter` — a spec test fixture, rewritten to the array form here — and zero +outside the spec package. `ElementNumberPropsParsed` is declared (ADR-0122: the parsed state now differs on `filter`, whose `operator` is normalized on parse). `element:record_picker.filter` is not changed here. diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 50cfcd3867..27550205f3 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -175,12 +175,22 @@ const result = AIChatWindowProps.parse(data); | **object** | `string` | ✅ | Source object | | **field** | `string` | optional | Field to aggregate | | **aggregate** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max'>` | ✅ | Aggregation function | -| **filter** | `any` | optional | Filter criteria | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter rules narrowing the aggregate — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` input in this map shares. The MongoDB-style record form is refused — see migration `element-number-filter-rule-array` | | **format** | `Enum<'number' \| 'currency' \| 'percent'>` | optional | Number display format | | **prefix** | `string` | optional | Prefix text (e.g. "$") | | **suffix** | `string` | optional | Suffix text (e.g. "%") | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | +### Nested Shape: `ElementNumberProps.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: `ElementNumberProps.aria` | Property | Type | Required | Description | diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index a72455a5aa..bf6158c69c 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -136,6 +136,7 @@ "ElementImagePropsSchema (const)", "ElementMetadataViewerPropsSchema (const)", "ElementNumberProps (type)", + "ElementNumberPropsParsed (type)", "ElementNumberPropsSchema (const)", "ElementRecordPickerProps (type)", "ElementRecordPickerPropsSchema (const)", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index e71c8012d9..46d29b2383 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -136,6 +136,7 @@ "ElementImagePropsSchema": "src/ui/component.zod.ts#ElementImagePropsSchema (const)", "ElementMetadataViewerPropsSchema": "src/ui/component.zod.ts#ElementMetadataViewerPropsSchema (const)", "ElementNumberProps": "src/ui/component.zod.ts#ElementNumberProps (type)", + "ElementNumberPropsParsed": "src/ui/component.zod.ts#ElementNumberPropsParsed (type)", "ElementNumberPropsSchema": "src/ui/component.zod.ts#ElementNumberPropsSchema (const)", "ElementRecordPickerProps": "src/ui/component.zod.ts#ElementRecordPickerProps (type)", "ElementRecordPickerPropsSchema": "src/ui/component.zod.ts#ElementRecordPickerPropsSchema (const)", diff --git a/packages/spec/src/migrations/entries/semantic/18.element-number-filter-rule-array.ts b/packages/spec/src/migrations/entries/semantic/18.element-number-filter-rule-array.ts index 771693e19f..d2049059f8 100644 --- a/packages/spec/src/migrations/entries/semantic/18.element-number-filter-rule-array.ts +++ b/packages/spec/src/migrations/entries/semantic/18.element-number-filter-rule-array.ts @@ -30,9 +30,10 @@ export const entry: SemanticMigration = { + 'adapter lowering → filter AST → accepted by `lowerAnalyticsWhere`, which still refuses ' + 'a RAW rule-object array by design. The ruled migration check ran with the change: the ' + 'sweep of first-party corpora (examples/, skills/, create-objectstack, content/docs/, ' - + 'packages/apps/, spec fixtures) found ZERO `element:number` authors writing a ' - + 'record-form `filter`, so no rewrite ships — this entry carries the prescription for ' - + 'authors outside the repo.', + + 'packages/apps/, spec fixtures) found ONE `element:number` author writing a record-form ' + + '`filter` — a spec test fixture, rewritten to the array form in the same change — and ' + + 'zero outside the spec package; this entry carries the prescription for authors outside ' + + 'the repo.', acceptanceCriteria: "`ComponentPropsMap['element:number'].safeParse({ object, aggregate, filter: [{ field: " + "'status', operator: 'equals', value: 'won' }] })` succeeds and the parsed `filter` is " @@ -40,6 +41,7 @@ export const entry: SemanticMigration = { + '`filter` path (`invalid_type`, expected array). At runtime the element renders its ' + 'aggregate on an analytics-capable deployment with the array filter applied — the same ' + 'filter a list view renders. Downstream (objectui, after a released spec version reaches ' - + 'the pin): the registry declaration for this input and the spec entry agree with no ' - + 'parity exemption, which is what closes objectui#6206.', + + "the pin): the `element:number.filter:array` entry in `OFF_SPEC_ARM_EXEMPTIONS` " + + '(`registry-inputs-spec-parity.test.ts`) becomes deletable, which is what closes ' + + 'objectui#6206.', }; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index ff741500f9..ec50867f19 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -6222,9 +6222,10 @@ const step18: MigrationStep = { + 'adapter lowering → filter AST → accepted by `lowerAnalyticsWhere`, which still refuses ' + 'a RAW rule-object array by design. The ruled migration check ran with the change: the ' + 'sweep of first-party corpora (examples/, skills/, create-objectstack, content/docs/, ' - + 'packages/apps/, spec fixtures) found ZERO `element:number` authors writing a ' - + 'record-form `filter`, so no rewrite ships — this entry carries the prescription for ' - + 'authors outside the repo.', + + 'packages/apps/, spec fixtures) found ONE `element:number` author writing a record-form ' + + '`filter` — a spec test fixture, rewritten to the array form in the same change — and ' + + 'zero outside the spec package; this entry carries the prescription for authors outside ' + + 'the repo.', acceptanceCriteria: "`ComponentPropsMap['element:number'].safeParse({ object, aggregate, filter: [{ field: " + "'status', operator: 'equals', value: 'won' }] })` succeeds and the parsed `filter` is " @@ -6232,8 +6233,9 @@ const step18: MigrationStep = { + '`filter` path (`invalid_type`, expected array). At runtime the element renders its ' + 'aggregate on an analytics-capable deployment with the array filter applied — the same ' + 'filter a list view renders. Downstream (objectui, after a released spec version reaches ' - + 'the pin): the registry declaration for this input and the spec entry agree with no ' - + 'parity exemption, which is what closes objectui#6206.', + + "the pin): the `element:number.filter:array` entry in `OFF_SPEC_ARM_EXEMPTIONS` " + + '(`registry-inputs-spec-parity.test.ts`) becomes deletable, which is what closes ' + + 'objectui#6206.', }, { id: 'engine-dotted-filter-refused', diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index 14393823c9..f32d604936 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -1256,11 +1256,14 @@ describe('ElementNumberPropsSchema', () => { object: 'order', field: 'amount', aggregate: 'sum', - filter: { status: 'paid' }, + // The ViewFilterRule array form (ui#6206-B) — this fixture authored the + // record form `{ status: 'paid' }` while the entry alone accepted it. + filter: [{ field: 'status', operator: 'equals', value: 'paid' }], format: 'currency', prefix: '$', suffix: ' USD', }); + expect(props.filter).toEqual([{ field: 'status', operator: 'equals', value: 'paid' }]); expect(props.format).toBe('currency'); expect(props.prefix).toBe('$'); expect(props.suffix).toBe(' USD');