Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .changeset/filter-orthography-binding-and-object-blocks.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- adr-0087: registered element-data-source-and-object-block-filter-rule-array -->

**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).
48 changes: 44 additions & 4 deletions content/docs/references/ui/component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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. |


---

Expand Down Expand Up @@ -381,7 +391,7 @@ Sort field and direction pair
| **title** | `string \| Record<string, string>` | 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. |
Expand Down Expand Up @@ -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<string, any> }` | 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 |
Expand Down Expand Up @@ -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 |
Expand All @@ -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. |


---

Expand Down Expand Up @@ -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 |
Expand All @@ -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. |


---

Expand Down
Loading
Loading