diff --git a/.changeset/28020-view-filter-or-group.md b/.changeset/28020-view-filter-or-group.md new file mode 100644 index 00000000000..38dd5b2983f --- /dev/null +++ b/.changeset/28020-view-filter-or-group.md @@ -0,0 +1,33 @@ +--- +'@objectstack/spec': minor +'@objectstack/lint': minor +'@objectstack/metadata-protocol': minor +--- + +feat(spec): a stored view filter list can hold an any-of group, `{ or: [rule, ...] }` + +Clause-②: yes (widening) +Metadata-protocol: added + +A view filter list (`ListView.filter`, a view tab's `filter`, `Page.interfaceConfig.filterBy`, an element data source's `filter`, the related-list and picker filters and the component `filter` doors) was an AND list of flat `{ field, operator, value }` rules, so a view whose natural filter is an any-of ("open, or completed in the last 7 days") could not be stored. The list now takes one more element kind beside the rule: `{ or: [rule, ...] }`, a non-empty array of flat rules of which ANY may match. The list still ANDs its elements. + +```ts +filter: [ + { or: [ + { field: 'status', operator: 'equals', value: 'open' }, + { field: 'completed_at', operator: 'after', value: '{7_days_ago}' }, + ] }, + { field: 'owner', operator: 'equals', value: '{current_user_id}' }, +] +``` + +- New exports from `@objectstack/spec/ui`: `ViewFilterOrGroupSchema`, `ViewFilterElementSchema` (rule or group — what every carrier holds now), their `ViewFilterOrGroup` / `ViewFilterElement` types and `...Parsed` twins, and `isViewFilterOrGroup(value)`, the one test that tells the two element kinds apart. +- A group's members are `ViewFilterRuleSchema` itself, so the operator vocabulary, the legacy-alias fold, the operator/value-shape coupling and the `icontains` comparand check apply inside a group exactly as at the top level. +- Refused at parse: an empty group (`too_small` at `filter.N.or`), a group inside a group (one level only; `unrecognized_keys` naming `or` on the inner element), a member that is not a rule, and a rule key written beside `or`. `$or`, `any`, `anyOf`, `logic` and `conditions` written on a rule are answered with the group shape. The key is `or` by the maintainer's ruling: it matches the and/or wording of Notion, monday.com and Dataverse filters, and a nested level added later would pair with `and`. +- Every filter stored before this change parses exactly as it did: the same output for an accepted rule, and the same issues (code, path, message) for a refused one — the element union reports only the branch the element claims. +- The wire door strips the console's row `id` inside a group as on a plain rule. +- `ViewFilterRule` (the type) is unchanged and still means one flat rule. A reader typed against a carrier's element now sees `ViewFilterElement`, and must step into a group (`isViewFilterOrGroup`) rather than read `field` off every element. +- `solution-blueprint` view `filters` deliberately stay flat rules: the model writes them through a strict mirror that cannot carry a union. +- `[PLANNED — not yet rendered]`: no console applies the group yet. The list query build reads flat rules only, so until it reads this element a view that stores a group is refused when it loads (the server answers the passed-through group with a malformed-filter error and the list shows no rows); it fails closed, and the group's describe says so. + +Readers updated: `@objectstack/lint`'s filter field-key walk (`filter-walk.ts`, used by the list-view, dataset and widget field-reference rules), the preset-comparand rule, and the page field-binding rule (`filterBy` and a related list's `filter`) step into a group, so a field inside one is checked and `or` is never reported as a field. `@objectstack/metadata-protocol`'s `computeViewReferenceDiagnostics` checks the fields inside a group in a preset tab's filter. diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 3c6ed44e69f..d66141a6f6e 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1856,7 +1856,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `never` | optional | [REMOVED] `view.list.filterableFields` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — the key was declared as a shorthand for `userFilters.fields` (the quick-filter bar), but the one renderer that read it used it as a whitelist of the fields the Filter panel offers instead, so one key carried two meanings and drew no quick filter. Delete the key; no per-view Filter-panel whitelist replaces it. For a curated end-user filter bar, declare `userFilters: { element: 'dropdown', fields: [{ field: '' }] }`. The Filter panel offers every field the user may read: field read security decides, and a field the object definition marks `hidden: true` is not offered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -1935,7 +1935,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `never` | optional | [REMOVED] `view.list.filterableFields` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — the key was declared as a shorthand for `userFilters.fields` (the quick-filter bar), but the one renderer that read it used it as a whitelist of the fields the Filter panel offers instead, so one key carried two meanings and drew no quick filter. Delete the key; no per-view Filter-panel whitelist replaces it. For a curated end-user filter bar, declare `userFilters: { element: 'dropdown', fields: [{ field: '' }] }`. The Filter panel offers every field the user may read: field read security decides, and a field the object definition marks `hidden: true` is not offered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index ddc9e850495..ba999fbf3ee 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -375,7 +375,7 @@ const result = ApiMethod.parse(data); | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `never` | optional | [REMOVED] `view.list.filterableFields` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — the key was declared as a shorthand for `userFilters.fields` (the quick-filter bar), but the one renderer that read it used it as a whitelist of the fields the Filter panel offers instead, so one key carried two meanings and drew no quick filter. Delete the key; no per-view Filter-panel whitelist replaces it. For a curated end-user filter bar, declare `userFilters: { element: 'dropdown', fields: [{ field: '' }] }`. The Filter panel offers every field the user may read: field read security decides, and a field the object definition marks `hidden: true` is not offered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 2215a7e8e78..da2b12fc792 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1571 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1573 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -33,8 +33,8 @@ counts are sums of the rows they head. Regenerate with | [Shared Protocol](/docs/references/shared) | 10 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 37 | 287 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | -| [UI Protocol](/docs/references/ui) | 16 | 172 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **204** | **1571** | 14 protocol modules | +| [UI Protocol](/docs/references/ui) | 16 | 174 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | +| **Total** | **204** | **1573** | 14 protocol modules | --- @@ -371,7 +371,7 @@ The runtime environment — logging, jobs, cache, metrics, notifications, i18n a ## UI Protocol -**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 172 schemas** +**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 174 schemas** Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. @@ -392,7 +392,7 @@ Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI lay | [`report.zod.ts`](/docs/references/ui/report) | `JoinedReportBlock`, `Report`, `ReportChart`, `ReportSort`, `ReportType` | | [`responsive.zod.ts`](/docs/references/ui/responsive) | `ResponsiveStyles`, `StyleMap` | | [`sharing.zod.ts`](/docs/references/ui/sharing) | `SharingConfig` | -| [`view.zod.ts`](/docs/references/ui/view) | `AddRecordConfig`, `AppearanceConfig`, `CalendarConfig`, `ColumnPrefix`, `ColumnSummary`, `ColumnSummaryConfig`, `EmptyState`, `FormButtonConfig`, `FormField`, `FormSection`, `FormSelectOption`, `FormView`, `GalleryConfig`, `GanttConfig`, `GanttQuickFilter`, `GroupingConfig`, `GroupingField`, `HttpMethodSubset`, `HttpRequest`, `KanbanConfig`, `ListChartConfig`, `ListColumn`, `ListMapConfig`, `ListView`, `NavigationConfig`, `NavigationMode`, `ObjectListView`, `ObjectUserFilters`, `PaginationConfig`, `RowColorConfig`, `RowHeight`, `SelectionConfig`, `TimelineConfig`, `TreeConfig`, `UserActionsConfig`, `UserFilterField`, `UserFilters`, `View`, `ViewData`, `ViewFilterRule`, `ViewItem`, `ViewItemName`, `ViewItemWire`, `ViewKind`, `ViewScope`, `ViewSharing`, `ViewTab`, `VisualizationType` | +| [`view.zod.ts`](/docs/references/ui/view) | `AddRecordConfig`, `AppearanceConfig`, `CalendarConfig`, `ColumnPrefix`, `ColumnSummary`, `ColumnSummaryConfig`, `EmptyState`, `FormButtonConfig`, `FormField`, `FormSection`, `FormSelectOption`, `FormView`, `GalleryConfig`, `GanttConfig`, `GanttQuickFilter`, `GroupingConfig`, `GroupingField`, `HttpMethodSubset`, `HttpRequest`, `KanbanConfig`, `ListChartConfig`, `ListColumn`, `ListMapConfig`, `ListView`, `NavigationConfig`, `NavigationMode`, `ObjectListView`, `ObjectUserFilters`, `PaginationConfig`, `RowColorConfig`, `RowHeight`, `SelectionConfig`, `TimelineConfig`, `TreeConfig`, `UserActionsConfig`, `UserFilterField`, `UserFilters`, `View`, `ViewData`, `ViewFilterElement`, `ViewFilterOrGroup`, `ViewFilterRule`, `ViewItem`, `ViewItemName`, `ViewItemWire`, `ViewKind`, `ViewScope`, `ViewSharing`, `ViewTab`, `VisualizationType` | --- diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 71d96dfcdec..c14aaa5fda2 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -486,7 +486,7 @@ const result = ActionButtonPropsSchema.parse(data); | **objectName** | `string` | optional | Object this calendar binds to. Optional because the component-level `dataSource` binding can supply the object instead | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar config `{ startDateField, endDateField?, titleField?, colorField?, allDayField?, initialDate? }` — the same block a list view's `calendar` declares; `startDateField` is required | | **defaultView** | `Enum<'month' \| 'week' \| 'day'>` | optional | Initial view mode | -| **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` | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | 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** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Row order for the fetched events — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array` | | **data** | `any[]` | optional | Pre-fetched records — skips the internal fetch | | **staticData** | `any[]` | optional | Static inline records | @@ -505,7 +505,7 @@ const result = ActionButtonPropsSchema.parse(data); | **allDayField** | `string` | optional | Field carrying the all-day flag for each event (names a boolean field, not a value): a record whose flag is true is drawn as an all-day band rather than at a clock time, and one whose flag is absent or false is not all-day. Omit to leave the renderer inference in place — an event with no end date draws as all-day | | **initialDate** | `string` | optional | Where the calendar opens: the month, week or day the active view mode shows that holds this date; omit it to open on today. A record anchor: `first_record` opens on the earliest start date (the startDateField value) among the records the view's filter selects, `next_record` on the earliest on or after the viewer's today (a `date` start field compares as a calendar day), and either opens on today when no record qualifies. Or a wrapped date-macro token, resolved each time the calendar opens: `{today}`, `{next_month_start}`, `{current_quarter_start}`, `{3_months_from_now}` (the full vocabulary is `DATE_MACRO_TOKENS` and the parameterised `DATE_MACRO_PARAM_RE` grammar in `@objectstack/spec/data`); a fixed calendar date is not accepted, because it goes stale as time passes. | -### Nested Shape: `ObjectCalendarProps.filter[number]` +### Nested Shape: `ObjectCalendarProps.filter[number][option 1]` View filter rule @@ -515,6 +515,14 @@ View filter rule | **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. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | +### Nested Shape: `ObjectCalendarProps.filter[number][option 2]` + +[PLANNED — not yet rendered] An any-of group: `{ or: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ or: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **or** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | + ### Nested Shape: `ObjectCalendarProps.sort[number]` Sort field and direction pair @@ -675,7 +683,7 @@ Sort field and direction pair | **objectName** | `string` | optional | Object this gantt binds to — the THIRD record source `resolveRecordSourceConfig` resolves, after `data` and `staticData`. Optional because the component-level `dataSource` binding can supply the object instead | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source binding (ViewDataSchema — discriminated on `provider`: object \| value \| schema), read FIRST by `resolveRecordSourceConfig`. Static inline rows live at `{ provider: 'value', items: [...] }` or at `staticData`; the bare-array shortcut is refused | | **staticData** | `any[]` | optional | Inline records — read SECOND by `resolveRecordSourceConfig`, wrapped into a `{ provider: 'value' }` config | -| **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 MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | 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 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 | Task order for the fetched bars — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array` | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration, the author face — the same block `ListViewSchema.gantt` declares, and the one the renderer validates this node against. Taken WHOLE when present: the flat top-level spelling beside it is ignored | | **navigation** | `{ mode?: Enum<'drawer' \| 'page' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Task-click navigation config — the same block `ListViewSchema.navigation` declares (`{ mode, size, openNewTab, preventNavigation }`), `mode` one of the four `NavigationModeSchema` values (`drawer` by default). The renderer's own default is `{ mode: 'drawer' }` when the key is absent | @@ -712,7 +720,7 @@ Sort field and direction pair | **schemaId** | `string` | ✅ | Schema identifier — typically the metadata type name | | **schema** | `Record` | optional | Inline JSON Schema (Draft 2020-12). Optional when schemaId is resolvable. | -### Nested Shape: `ObjectGanttProps.filter[number]` +### Nested Shape: `ObjectGanttProps.filter[number][option 1]` View filter rule @@ -722,6 +730,14 @@ View filter rule | **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. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | +### Nested Shape: `ObjectGanttProps.filter[number][option 2]` + +[PLANNED — not yet rendered] An any-of group: `{ or: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ or: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **or** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | + ### Nested Shape: `ObjectGanttProps.sort[number]` Sort field and direction pair @@ -800,7 +816,7 @@ Sort field and direction pair | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string; link?: object }` | optional | What the grid draws instead of an empty table: `{ title, message, icon, link }` — the list view's own empty-state shape | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | optional | Columns — all field-name strings, or all column entries `{ field, label?, width?, align?, hidden?, sortable?, … }`, the same union a list view's `columns` declares. One spelling per list: an array mixing strings and column objects is refused | | **fields** | `string[]` | optional | Field-name fallback the grid reads when `columns` is absent — bare field names (`['name', 'amount']`); write column decoration such as `label` or `width` on `columns` | -| **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` | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | 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** | `never` | optional | [REMOVED] `object-grid` property `defaultFilters` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was the legacy second spelling of `filter`: the same rules, read only when `filter` lowered to nothing, so one intent had two spellings and a grid authoring both silently ignored this one. Use `filter`. Rename the key where `filter` is empty; the value (a ViewFilterRule array, `[{ field, operator, value }, ...]`) is unchanged. Where `filter` already has rules, delete this key: the grid never read it there. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Initial row order — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array` | | **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; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -862,7 +878,7 @@ Sort field and direction pair | **link** | `boolean` | optional | Functions as the primary navigation link (triggers View navigation) | | **action** | `string` | optional | Registered Action ID to execute when clicked | -### Nested Shape: `ObjectGridProps.filter[number]` +### Nested Shape: `ObjectGridProps.filter[number][option 1]` View filter rule @@ -872,6 +888,14 @@ View filter rule | **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. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | +### Nested Shape: `ObjectGridProps.filter[number][option 2]` + +[PLANNED — not yet rendered] An any-of group: `{ or: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ or: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **or** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | + ### Nested Shape: `ObjectGridProps.sort[number]` Sort field and direction pair @@ -997,7 +1021,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** | `string[] \| { id: string; title: string; cards?: object[]; limit?: integer; … }[]` | optional | Swimlane definitions — all lane objects `{ id, title, cards?, limit?, className?, collapsed? }` (one per `groupBy` value), or all bare value strings (drawn only on a board with no `groupBy`). NOT a field projection; one spelling per list | -| **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` | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | 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 this key only when it is unset — and on this face unset is the whole rule, because every cap this key accepts is one the binding gate already treats as authored | | **data** | `any[]` | optional | Static inline cards — bypasses the object query | | **cardTitle** | `string` | optional | Field rendered as each card title | @@ -1021,7 +1045,7 @@ Sort field and direction pair | **className** | `string` | optional | CSS class names applied to the lane | | **collapsed** | `boolean` | optional | Whether the lane first renders collapsed — a title spine with its cards withheld, which the viewer can reopen | -### Nested Shape: `ObjectKanbanProps.filter[number]` +### Nested Shape: `ObjectKanbanProps.filter[number][option 1]` View filter rule @@ -1031,6 +1055,14 @@ View filter rule | **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. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | +### Nested Shape: `ObjectKanbanProps.filter[number][option 2]` + +[PLANNED — not yet rendered] An any-of group: `{ or: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ or: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **or** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | + ### Nested Shape: `ObjectKanbanProps.grouping` | Property | Type | Required | Description | @@ -1067,7 +1099,7 @@ View filter rule | **objectName** | `string` | optional | Object this map binds to — the THIRD record source `getDataConfig` resolves, after `data` and `staticData`. Optional because the component-level `dataSource` binding can supply the object instead | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source binding (ViewDataSchema — discriminated on `provider`: object \| value \| schema), read FIRST by `getDataConfig`. Static inline rows live at `{ provider: 'value', items: [...] }` or at `staticData`; the bare-array shortcut is refused | | **staticData** | `any[]` | optional | Inline records — read SECOND by `getDataConfig`, wrapped into a `{ provider: 'value' }` config | -| **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 MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | 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 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 | Marker order for the fetched records — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array` | | **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map field config, the author face — the same block `ListViewSchema.map` declares, and the one the renderer validates this node against. Taken WHOLE when present: the flat top-level spelling beside it is ignored | | **mapStyle** | `string` | optional | MapLibre style URL or spec, overriding the public demo tiles. Read before `map.style`; NOT the base node `style`, which is an inline CSS record | @@ -1096,7 +1128,7 @@ View filter rule | **schemaId** | `string` | ✅ | Schema identifier — typically the metadata type name | | **schema** | `Record` | optional | Inline JSON Schema (Draft 2020-12). Optional when schemaId is resolvable. | -### Nested Shape: `ObjectMapProps.filter[number]` +### Nested Shape: `ObjectMapProps.filter[number][option 1]` View filter rule @@ -1106,6 +1138,14 @@ View filter rule | **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. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | +### Nested Shape: `ObjectMapProps.filter[number][option 2]` + +[PLANNED — not yet rendered] An any-of group: `{ or: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ or: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **or** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | + ### Nested Shape: `ObjectMapProps.sort[number]` Sort field and direction pair @@ -1211,7 +1251,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** | `{ field?: string; function: Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>; groupBy?: string \| object }` | optional | Aggregation run against the object: `{ field?, function, groupBy? }` — `function` one of count / sum / avg / min / max / count_distinct (`field` needed for all but count), `groupBy` a field name or a `{ field, dateGranularity }` node, absent for one number over every row | -| **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` | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | 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 | @@ -1232,7 +1272,7 @@ Sort field and direction pair | **function** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>` | ✅ | Aggregation function — `count`, `sum`, `avg`, `min`, `max` or `count_distinct` | | **groupBy** | `string \| { field: string; dateGranularity?: Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; alias?: string }` | optional | Field the rows are grouped by, or a `{ field, dateGranularity }` date-bucket node. Omit it for the one number over every row (`_all`) | -### Nested Shape: `ObjectMetricProps.filter[number]` +### Nested Shape: `ObjectMetricProps.filter[number][option 1]` View filter rule @@ -1242,6 +1282,14 @@ View filter rule | **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. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | +### Nested Shape: `ObjectMetricProps.filter[number][option 2]` + +[PLANNED — not yet rendered] An any-of group: `{ or: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ or: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **or** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | + ### Nested Shape: `ObjectMetricProps.trend` | Property | Type | Required | Description | @@ -1278,7 +1326,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **objectName** | `string` | optional | Object this timeline binds to. Optional because the component-level `dataSource` binding can supply the object instead — this block registers through `ElementDataSourceGate`, which lowers the binding onto this key before the renderer sees the node | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline configuration, the author face — the same block `ListViewSchema.timeline` declares: `{ startDateField, endDateField, titleField, groupByField, colorField, scale }`. The flat top-level spellings beside it are the runtime handoff, not a second authoring spelling | -| **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 MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | 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 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 | Row order for the fetched entries — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array` | | **limit** | `integer` | optional | Maximum number of records loaded onto the rail (row cap); lowered to the query's top-level `$top` (renderer default 100). A timeline renders one rail with no pagination control, so this is the author's window rather than a page size | | **data** | `any[]` | optional | Pre-fetched records — read FIRST as the rail's row source, ahead of the data-scope binding and the fetch, and composed into entries through the same `timeline` field bindings a fetched row takes; authoring it suppresses the object query entirely. Distinct from `items`, which is the already-composed entry shape and wins over this key when both are written | @@ -1303,7 +1351,7 @@ View filter rule | **colorField** | `string` | optional | Field to derive each item color from (it names a field, not a color): the option color declared on that field for the record value, else the value itself when it already is a color literal (hex, rgb() or hsl()), else the timeline default marker color | | **scale** | `Enum<'hour' \| 'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional (default: `"week"`) | Default timeline scale | -### Nested Shape: `ObjectTimelineProps.filter[number]` +### Nested Shape: `ObjectTimelineProps.filter[number][option 1]` View filter rule @@ -1313,6 +1361,14 @@ View filter rule | **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. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | +### Nested Shape: `ObjectTimelineProps.filter[number][option 2]` + +[PLANNED — not yet rendered] An any-of group: `{ or: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ or: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **or** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | + ### Nested Shape: `ObjectTimelineProps.sort[number]` Sort field and direction pair @@ -1368,7 +1424,7 @@ Sort field and direction pair | **objectName** | `string` | optional | Object this tree binds to — the THIRD record source `resolveRecordSourceConfig` resolves, after `data` and `staticData`. Optional because either of the first two rungs resolves the source without it: `data` can name the object itself (`{ provider: 'object', object }`) and `staticData` needs none. ⚠️ NOT supplied by the component-level `dataSource` binding the sibling blocks name — this renderer registers no such gate | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source binding (ViewDataSchema — discriminated on `provider`: object \| value \| schema), read FIRST by `resolveRecordSourceConfig`. Static inline rows live at `{ provider: 'value', items: [...] }` or at `staticData`; the bare-array shortcut is refused | | **staticData** | `any[]` | optional | Inline records — read SECOND by `resolveRecordSourceConfig`, wrapped into a `{ provider: 'value' }` config | -| **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 MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | 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 MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration, the author face — the same block `ListViewSchema.tree` declares: `{ parentField?, labelField?, fields?, defaultExpandedDepth? }`. `parentField` auto-detects from the object schema when omitted | | **navigation** | `{ mode?: Enum<'drawer' \| 'page' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Row-click navigation config — the same block `ListViewSchema.navigation` declares (`{ mode, size, openNewTab, preventNavigation }`), `mode` one of the four `NavigationModeSchema` values (`drawer` by default) | @@ -1394,7 +1450,7 @@ Sort field and direction pair | **schemaId** | `string` | ✅ | Schema identifier — typically the metadata type name | | **schema** | `Record` | optional | Inline JSON Schema (Draft 2020-12). Optional when schemaId is resolvable. | -### Nested Shape: `ObjectTreeProps.filter[number]` +### Nested Shape: `ObjectTreeProps.filter[number][option 1]` View filter rule @@ -1404,6 +1460,14 @@ View filter rule | **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. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | +### Nested Shape: `ObjectTreeProps.filter[number][option 2]` + +[PLANNED — not yet rendered] An any-of group: `{ or: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ or: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **or** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | + ### Nested Shape: `ObjectTreeProps.tree` | Property | Type | Required | Description | @@ -1824,7 +1888,7 @@ Type: `string` | **readonly** | `boolean` | optional | Render the grid read-only: cells locked, no Save button, and no adding or removing lines (renderer default: editable) | | **minRows** | `number` | optional | Minimum number of rows | | **maxRows** | `number` | optional | Maximum number of rows | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Additional criteria for the child rows — the ViewFilterRule array form `[{ field, operator, value }, ...]`, AND-combined with the parent relationship condition, never substituted for it: it can only narrow this record's lines | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | optional | Additional criteria for the child rows — the ViewFilterRule array form `[{ field, operator, value }, ...]`, AND-combined with the parent relationship condition, never substituted for it: it can only narrow this record's lines | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Load order for the child rows — the SortItem array form `[{ field, order }, ...]` (renderer default: storage order) | | **limit** | `integer` | optional | Row cap for the child fetch (renderer default: 500). The grid has no pagination, so this is the window of rows that are editable and saved together | @@ -1853,7 +1917,7 @@ Type: `string` | **readonlyWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the cell is read-only when TRUE, evaluated per row against the row as `record` plus the header as `parent` (e.g. P`parent.status == 'paid'`). | | **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the cell is required when TRUE. Same `record` + `parent` scope as `readonlyWhen`. PRESENTATION ONLY: this flags the cell inline-invalid in the grid; nothing on the write path reads it. The server-enforced contract is the child FIELD's own `requiredWhen` — a transition gate, see `Field.requiredWhen` — which hydration copies onto an identity-only column, so declaring the requirement here alone enforces nothing. | -### Nested Shape: `RecordLineItemsProps.filter[number]` +### Nested Shape: `RecordLineItemsProps.filter[number][option 1]` View filter rule @@ -1863,6 +1927,14 @@ View filter rule | **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. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | +### Nested Shape: `RecordLineItemsProps.filter[number][option 2]` + +[PLANNED — not yet rendered] An any-of group: `{ or: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ or: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **or** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | + ### Nested Shape: `RecordLineItemsProps.sort[number]` Sort field and direction pair @@ -1963,11 +2035,11 @@ Sort field and direction pair | **objectName** | `string` | ✅ | Related object name (e.g., "task", "opportunity") | | **relationshipField** | `string` | ✅ | Field on related object that points to this record (e.g., "account_id") | | **relationshipValueField** | `string` | optional (default: `"id"`) | Parent-record field whose value relationshipField stores (default 'id'; e.g. 'name' for name-keyed junctions). | -| **parentDataSource** | `{ object: string; view?: string; filter?: object[]; sort?: object[]; … }` | optional | The PARENT record this related list hangs off, bound by a query of its own: an object binding `{ object, view?, filter?, sort?, limit? }`, the shape `dataSource` takes. Read by the single-record rule: the FIRST row of the bound set by its `sort` is the parent (declare `limit: 1` to say one row is meant), and when no row answers, the block renders a loud absence, never an empty list, which would read as "this record has no related records". `relationshipValueField` is read on that row. When this key is absent, the record page's record context remains the parent. The node-level `dataSource` on this block keeps its published meaning, the CHILD rows the list draws (it fills `objectName`, `columns`, `filter`, `sort` and `limit`), and is never read as the parent. | +| **parentDataSource** | `{ object: string; view?: string; filter?: (object \| object)[]; sort?: object[]; … }` | optional | The PARENT record this related list hangs off, bound by a query of its own: an object binding `{ object, view?, filter?, sort?, limit? }`, the shape `dataSource` takes. Read by the single-record rule: the FIRST row of the bound set by its `sort` is the parent (declare `limit: 1` to say one row is meant), and when no row answers, the block renders a loud absence, never an empty list, which would read as "this record has no related records". `relationshipValueField` is read on that row. When this key is absent, the record page's record context remains the parent. The node-level `dataSource` on this block keeps its published meaning, the CHILD rows the list draws (it fills `objectName`, `columns`, `filter`, `sort` and `limit`), and is never read as the parent. | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | optional | Fields to display in the related list — either plain field-name strings, or the same per-column entries a saved list view declares (`ListColumn`: `field`, plus `label`, `width`, `align`, `hidden`, `sortable`, `summary`, …). A view-supplied list may arrive in the `ListColumn` spelling: objectui composes a saved view's `columns` onto this block verbatim, and this key declares the SAME union as `listViews[].columns`. One spelling per list — the two arms are exclusive, so an array mixing strings and column objects is refused. Optional: when omitted, columns derive from the related object's highlightFields / default list columns (a related list is just another surface that lists that object). Override chain: child highlightFields → field-level relatedListColumns (field-name strings only) → this inline list. | | **sort** | `string \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Sort order for related records | | **limit** | `integer` | optional (default: `5`) | Number of records to display initially | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Additional filter criteria for related records | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | optional | Additional filter criteria for related records | | **title** | `string \| Record` | optional | Custom title for the related list | | **showViewAll** | `boolean` | optional (default: `true`) | Show "View All" link to see all related records | | **actions** | `string[]` | optional | Action IDs available for related records | @@ -1983,7 +2055,7 @@ Sort field and direction pair | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object to query | | **view** | `string` | optional | Named view to apply | -| **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` | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | 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 | @@ -2006,7 +2078,7 @@ Sort field and direction pair | **link** | `boolean` | optional | Functions as the primary navigation link (triggers View navigation) | | **action** | `string` | optional | Registered Action ID to execute when clicked | -### Nested Shape: `RecordRelatedListProps.filter[number]` +### Nested Shape: `RecordRelatedListProps.filter[number][option 1]` View filter rule @@ -2016,11 +2088,19 @@ View filter rule | **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. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | +### Nested Shape: `RecordRelatedListProps.filter[number][option 2]` + +[PLANNED — not yet rendered] An any-of group: `{ or: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ or: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **or** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | + ### Nested Shape: `RecordRelatedListProps.add` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **picker** | `{ object: string; valueField?: string; labelField?: string; filter?: object[] }` | ✅ | Where the Add affordance sources records from. | +| **picker** | `{ object: string; valueField?: string; labelField?: string; filter?: (object \| object)[] }` | ✅ | Where the Add affordance sources records from. | | **linkField** | `string` | optional | Field on `objectName` that stores the picked record id (junction case). Omit for a 1:m re-parent. | | **label** | `string \| Record` | optional | Label for the Add button (default "Add"). | diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index 26119a323dd..b2b0a8d937f 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -52,11 +52,11 @@ API binding of a page element: `{ provider: 'api', read: { url }, path? }` reads | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object to query | | **view** | `string` | optional | Named view to apply | -| **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` | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | 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]` +### Nested Shape: `ElementDataSource.filter[number][option 1]` View filter rule @@ -66,6 +66,14 @@ View filter rule | **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. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | +### Nested Shape: `ElementDataSource.filter[number][option 2]` + +[PLANNED — not yet rendered] An any-of group: `{ or: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ or: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **or** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | + ### Nested Shape: `ElementDataSource.sort[number]` Sort field and direction pair @@ -89,7 +97,7 @@ Interface-level page configuration (Airtable parity) | **source** | `string` | optional | Source object name for the page | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | optional | Columns shown by the page. Blank = all object fields. Defined directly on the page (no view inheritance). | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Default sort order for the page, defined directly on the page. | -| **filterBy** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Always-on page filter (base filter). | +| **filterBy** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | optional | Always-on page filter (base filter). | | **levels** | `integer` | optional | Number of hierarchy levels to display | | **sourceView** | `string` | optional | @deprecated Legacy named-view inheritance. Define columns/sort/filterBy on the page instead. | | **appearance** | `{ showDescription?: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | @@ -130,7 +138,7 @@ Sort field and direction pair | **field** | `string` | ✅ | Field name to sort by | | **order** | `Enum<'asc' \| 'desc'>` | ✅ | Sort direction | -### Nested Shape: `InterfacePageConfig.filterBy[number]` +### Nested Shape: `InterfacePageConfig.filterBy[number][option 1]` View filter rule @@ -140,6 +148,14 @@ View filter rule | **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. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | +### Nested Shape: `InterfacePageConfig.filterBy[number][option 2]` + +[PLANNED — not yet rendered] An any-of group: `{ or: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ or: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **or** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | + ### Nested Shape: `InterfacePageConfig.appearance` | Property | Type | Required | Description | @@ -207,12 +223,12 @@ View filter rule | **type** | `Enum<'record' \| 'home' \| 'app' \| 'utility' \| 'list'>` | optional (default: `"record"`) | Page type | | **variables** | `{ name: string; type?: Enum<'string' \| 'number' \| 'boolean' \| 'object' \| 'array' \| 'record_id'>; defaultValue?: any; source?: string }[]` | optional | Local page state, exposed to expressions as `page.` and writable by interactive elements via `source` (master/detail, filtered dashboards). | | **object** | `string` | optional | Bound object (for Record pages) | -| **record** | `{ object: string; view?: string; filter?: object[]; sort?: object[] }` | optional | The row an app page is about — a one-row query `{ object, view?, filter?, sort? }` whose FIRST row (by `sort`, read under the caller's identity) the page's components read as `record` in `visibleWhen`. A count or a plan is a field on that row: a `summary` roll-up (`record.environment_count > 1`) or the object's own field (`record.plan != 'free'`). No row, no `record`: the predicate faults and the component renders. Only on `type: 'app'` — refused on every other page type (a record page's row is the record it opens). Planned in the renderer: until the console mounts it, `record` stays unbound on an app page. | +| **record** | `{ object: string; view?: string; filter?: (object \| object)[]; sort?: object[] }` | optional | The row an app page is about — a one-row query `{ object, view?, filter?, sort? }` whose FIRST row (by `sort`, read under the caller's identity) the page's components read as `record` in `visibleWhen`. A count or a plan is a field on that row: a `summary` roll-up (`record.environment_count > 1`) or the object's own field (`record.plan != 'free'`). No row, no `record`: the predicate faults and the component renders. Only on `type: 'app'` — refused on every other page type (a record page's row is the record it opens). Planned in the renderer: until the console mounts it, `record` stays unbound on an app page. | | **template** | `Enum<'default' \| 'header-sidebar-main' \| 'three-column' \| 'dashboard'>` | optional (default: `"default"`) | Layout template — 'default', 'header-sidebar-main', 'three-column' or 'dashboard'. Each places its own regions (PAGE_TEMPLATE_SLOTS); a region the template does not place, or a second region filling the same slot, is refused at the region's name. | | **regions** | `{ name: string; width?: Enum<'small' \| 'medium' \| 'large' \| 'full'>; components: object[] }[]` | optional | Layout regions (header, main, sidebar, footer) with their components. Optional — list pages use interfaceConfig, slotted pages use slots, and an empty full page falls back to the synthesized default layout. | | **isDefault** | `boolean` | optional (default: `false`) | Whether this record page is the default record page of its `object` — the page the record view opens. At most one `isDefault: true` record page may exist for an object in a deployment: a second is refused (by defineStack / os validate, at package registration and at metadata save), naming both pages. Saving a page under the existing default's own name is that page, not a second default. Refused on any other page — a page that is not a record page bound to an object: nothing reads it there. An app opens on its first navigation item, and the root landing follows the app's own isDefault. | | **assignedProfiles** | `never` | optional | [REMOVED] `page.assignedProfiles` was removed in @objectstack/spec 17.5.0 (ADR-0090 D2, ADR-0049 enforce-or-remove) — it was named for the Profile concept ADR-0090 D2 deleted, and it gated nothing: no renderer, route or metadata read door ever read the key, so a page that "assigned profiles" stayed open to every caller who could reach it. Delete the key. Page audience is the permission set's: gate the DATA the page shows with the object's permission sets, and bind those sets to people through positions (named in a position's `permissionSets`) — those are the checks the runtime actually runs. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **interfaceConfig** | `{ source?: string; columns?: string[] \| object[]; sort?: object[]; filterBy?: object[]; … }` | optional | Interface-level page configuration (for Airtable-style interface pages) | +| **interfaceConfig** | `{ source?: string; columns?: string[] \| object[]; sort?: object[]; filterBy?: (object \| object)[]; … }` | optional | Interface-level page configuration (for Airtable-style interface pages) | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | | **print** | `{ paperSize?: Enum<'A4' \| 'A5' \| 'Letter' \| 'Legal'>; orientation?: Enum<'portrait' \| 'landscape'>; margins?: object; repeatHeader?: boolean; … }` | optional | Print declaration — makes this page a print page, a document authored in the page's own blocks: paper size and orientation, margins in millimetres, the running header and footer (the page's own `header` / `footer` regions), page numbers and page-break hints, each mapped to print CSS. A print page is a `kind: 'full'` page of `type: 'record'`, `'home'` or `'app'` with its blocks in `regions`, and every block in it must come from the printable block subset: containers that draw every child (`page:section`, `page:card`, `page:footer`), field blocks (`record:details`, `record:highlights`), the child-record table `record:line_items`, `element:text`, `element:image`, `element:divider`, `element:definition-list`, `element:repeater`, `element:number` and `object-metric`; `os validate` / `os build` / `os lint` and the metadata save door refuse any other block inside it. Applied by the console's browser print rendering, which prints the page through the browser's own print dialog (where it can be saved as PDF); the server-side PDF renderer is to follow. A list view's print button is `allowPrinting`, not this. | | **kind** | `Enum<'full' \| 'slotted' \| 'html' \| 'react'>` | optional (default: `"full"`) | Page override mode. full \| slotted = structured authoring; html = author-written constrained JSX compiled (parsed, never executed) to the tree (ADR-0080; ADR-0081 named it html), styled by the registered components' structured props plus a JSON `style` object with hsl(var(--token)) theme colors; react = real-React source executed at render by the runtime (ADR-0081), styled by inline `style` with the same token colors; it runs author JS, so it is gated by a host capability that defaults OFF and is turned on server-side via the OS_PAGE_REACT_ENABLED=true env toggle. Do not author Tailwind classes in page source in either tier: `source` is runtime metadata the build-time Tailwind never scans, so utility classNames silently produce no CSS (ADR-0065; ADR-0080). | @@ -242,7 +258,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object the page row is read from | | **view** | `string` | optional | Named view to apply | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria — the ViewFilterRule array form `[{ field, operator, value }, ...]`, AND-combined with the filter of the named view | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | optional | Filter criteria — the ViewFilterRule array form `[{ field, operator, value }, ...]`, AND-combined with the filter of the named view | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Sort order — the page row is the FIRST row of the bound set under it | ### Nested Shape: `Page.regions[number]` @@ -260,7 +276,7 @@ View filter rule | **source** | `string` | optional | Source object name for the page | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | optional | Columns shown by the page. Blank = all object fields. Defined directly on the page (no view inheritance). | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Default sort order for the page, defined directly on the page. | -| **filterBy** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Always-on page filter (base filter). | +| **filterBy** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | optional | Always-on page filter (base filter). | | **levels** | `integer` | optional | Number of hierarchy levels to display | | **sourceView** | `string` | optional | @deprecated Legacy named-view inheritance. Define columns/sort/filterBy on the page instead. | | **appearance** | `{ showDescription?: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | @@ -325,7 +341,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 `features` and `os.user` — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). It mounts NO `data` root on an element that no `api` data source binds: there a `data.*` read faults and a faulting predicate renders the component, so such a gate never gates; the page's row is `record` — the record a record page opens, or the row an app page's `record` binding reads (none without one; a block's own `dataSource` binds no `record` for its siblings). A count or a plan is a field on that row (a `summary` roll-up: `record.environment_count > 1`; `record.plan != 'free'`), never `features.*`, which are the deployment's public auth flags, the same for every organisation. `data` is mounted only on an element whose `dataSource` is an `api` data source (`provider: 'api'`): there, and on every element nested inside it, `data` is the value the binding selects, and the element's `visibleWhen` is decided once the read answers. To gate a component on a system capability, write `current_user.hasCapability('')` with the name the gated action's `requiredPermissions` declares — it answers from the viewer's `systemPermissions`, the list the action gate reads, never a proxy such as `current_user.can('', 'edit')`; a host that binds no capability list makes it fault, and a faulting predicate renders the component. ADR-0066 D4: `visibleWhen` hides, `requiredPermissions` refuses. ⚠️ A `page:tabs` item `visibleWhen` is a different evaluator: it binds `data` to the record ROW. e.g. "page.selectedProjectId != ''" | | **visibility** | `never` | optional | [REMOVED] `page.components[].visibility` was removed in @objectstack/spec 18 (ADR-0089 D2) — it was the page component's deprecated spelling of the conditional-visibility predicate, which the parse folded into `visibleWhen` until this release. Use `visibleWhen`. Rename the key; the value (a CEL predicate) is unchanged. Where a component writes both, `visibleWhen` is the one that was in effect: delete `visibility`. | -| **dataSource** | `{ object: string; view?: string; filter?: object[]; sort?: object[]; … } \| { provider: 'api'; read: object; path?: string }` | optional | Per-element data binding: an object binding `{ object, view?, filter?, sort?, limit? }` that overrides the page object, or an API binding `{ provider: 'api', read: { url }, path? }` that reads one same-origin endpoint | +| **dataSource** | `{ object: string; view?: string; filter?: (object \| object)[]; sort?: object[]; … } \| { provider: 'api'; read: object; path?: string }` | optional | Per-element data binding: an object binding `{ object, view?, filter?, sort?, limit? }` that overrides the page object, or an API binding `{ provider: 'api', read: { url }, path? }` that reads one same-origin endpoint | | **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; `--write` applies the ones it can prove, and you apply the rest by hand. | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | @@ -344,7 +360,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object to query | | **view** | `string` | optional | Named view to apply | -| **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` | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | 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 | @@ -448,10 +464,10 @@ Print declaration: paper size, orientation, margins, the running header and foot | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object the page row is read from | | **view** | `string` | optional | Named view to apply | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria — the ViewFilterRule array form `[{ field, operator, value }, ...]`, AND-combined with the filter of the named view | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | optional | Filter criteria — the ViewFilterRule array form `[{ field, operator, value }, ...]`, AND-combined with the filter of the named view | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Sort order — the page row is the FIRST row of the bound set under it | -### Nested Shape: `PageRecordBinding.filter[number]` +### Nested Shape: `PageRecordBinding.filter[number][option 1]` View filter rule @@ -461,6 +477,14 @@ View filter rule | **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. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | +### Nested Shape: `PageRecordBinding.filter[number][option 2]` + +[PLANNED — not yet rendered] An any-of group: `{ or: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ or: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **or** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | + ### Nested Shape: `PageRecordBinding.sort[number]` Sort field and direction pair @@ -497,7 +521,7 @@ Sort field and direction pair | **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 `features` and `os.user` — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). It mounts NO `data` root on an element that no `api` data source binds: there a `data.*` read faults and a faulting predicate renders the component, so such a gate never gates; the page's row is `record` — the record a record page opens, or the row an app page's `record` binding reads (none without one; a block's own `dataSource` binds no `record` for its siblings). A count or a plan is a field on that row (a `summary` roll-up: `record.environment_count > 1`; `record.plan != 'free'`), never `features.*`, which are the deployment's public auth flags, the same for every organisation. `data` is mounted only on an element whose `dataSource` is an `api` data source (`provider: 'api'`): there, and on every element nested inside it, `data` is the value the binding selects, and the element's `visibleWhen` is decided once the read answers. To gate a component on a system capability, write `current_user.hasCapability('')` with the name the gated action's `requiredPermissions` declares — it answers from the viewer's `systemPermissions`, the list the action gate reads, never a proxy such as `current_user.can('', 'edit')`; a host that binds no capability list makes it fault, and a faulting predicate renders the component. ADR-0066 D4: `visibleWhen` hides, `requiredPermissions` refuses. ⚠️ A `page:tabs` item `visibleWhen` is a different evaluator: it binds `data` to the record ROW. e.g. "page.selectedProjectId != ''" | | **visibility** | `never` | optional | [REMOVED] `page.components[].visibility` was removed in @objectstack/spec 18 (ADR-0089 D2) — it was the page component's deprecated spelling of the conditional-visibility predicate, which the parse folded into `visibleWhen` until this release. Use `visibleWhen`. Rename the key; the value (a CEL predicate) is unchanged. Where a component writes both, `visibleWhen` is the one that was in effect: delete `visibility`. | -| **dataSource** | `{ object: string; view?: string; filter?: object[]; sort?: object[]; … } \| { provider: 'api'; read: object; path?: string }` | optional | Per-element data binding: an object binding `{ object, view?, filter?, sort?, limit? }` that overrides the page object, or an API binding `{ provider: 'api', read: { url }, path? }` that reads one same-origin endpoint | +| **dataSource** | `{ object: string; view?: string; filter?: (object \| object)[]; sort?: object[]; … } \| { provider: 'api'; read: object; path?: string }` | optional | Per-element data binding: an object binding `{ object, view?, filter?, sort?, limit? }` that overrides the page object, or an API binding `{ provider: 'api', read: { url }, path? }` that reads one same-origin endpoint | | **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; `--write` applies the ones it can prove, and you apply the rest by hand. | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 85408a02d0e..e1af9fe765c 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -59,8 +59,8 @@ nothing. The one item-name grammar itself lives in ## TypeScript Usage ```typescript -import { AddRecordConfigSchema, AppearanceConfigSchema, CalendarConfigSchema, ColumnPrefixSchema, ColumnSummarySchema, ColumnSummaryConfigSchema, EmptyStateSchema, FormButtonConfigSchema, FormFieldSchema, FormSectionSchema, FormSelectOptionSchema, FormViewSchema, GalleryConfigSchema, GanttConfigSchema, GanttQuickFilterSchema, GroupingConfigSchema, GroupingFieldSchema, HttpMethodSubsetSchema, HttpRequestSchema, KanbanConfigSchema, ListChartConfigSchema, ListColumnSchema, ListMapConfigSchema, ListViewSchema, NavigationConfigSchema, NavigationModeSchema, ObjectListViewSchema, ObjectUserFiltersSchema, PaginationConfigSchema, RowColorConfigSchema, RowHeightSchema, SelectionConfigSchema, TimelineConfigSchema, TreeConfigSchema, UserActionsConfigSchema, UserFilterFieldSchema, UserFiltersSchema, ViewSchema, ViewDataSchema, ViewFilterRuleSchema, ViewItemSchema, ViewItemNameSchema, ViewItemWireSchema, ViewKindSchema, ViewScopeSchema, ViewSharingSchema, ViewTabSchema, VisualizationTypeSchema } from '@objectstack/spec/ui'; -import type { AddRecordConfig, AppearanceConfig, CalendarConfig, ColumnPrefix, ColumnSummary, ColumnSummaryConfig, EmptyState, FormButtonConfig, FormField, FormSection, FormSelectOption, FormView, GalleryConfig, GanttConfig, GanttQuickFilter, GroupingConfig, HttpMethodSubset, HttpRequest, KanbanConfig, ListChartConfig, ListColumn, ListMapConfig, ListView, NavigationConfig, NavigationMode, PaginationConfig, RowColorConfig, RowHeight, SelectionConfig, TimelineConfig, TreeConfig, UserActionsConfig, UserFilterField, UserFilters, View, ViewData, ViewFilterRule, ViewItem, ViewItemName, ViewItemWire, ViewKind, ViewScope, ViewSharing, ViewTab, VisualizationType } from '@objectstack/spec/ui'; +import { AddRecordConfigSchema, AppearanceConfigSchema, CalendarConfigSchema, ColumnPrefixSchema, ColumnSummarySchema, ColumnSummaryConfigSchema, EmptyStateSchema, FormButtonConfigSchema, FormFieldSchema, FormSectionSchema, FormSelectOptionSchema, FormViewSchema, GalleryConfigSchema, GanttConfigSchema, GanttQuickFilterSchema, GroupingConfigSchema, GroupingFieldSchema, HttpMethodSubsetSchema, HttpRequestSchema, KanbanConfigSchema, ListChartConfigSchema, ListColumnSchema, ListMapConfigSchema, ListViewSchema, NavigationConfigSchema, NavigationModeSchema, ObjectListViewSchema, ObjectUserFiltersSchema, PaginationConfigSchema, RowColorConfigSchema, RowHeightSchema, SelectionConfigSchema, TimelineConfigSchema, TreeConfigSchema, UserActionsConfigSchema, UserFilterFieldSchema, UserFiltersSchema, ViewSchema, ViewDataSchema, ViewFilterElementSchema, ViewFilterOrGroupSchema, ViewFilterRuleSchema, ViewItemSchema, ViewItemNameSchema, ViewItemWireSchema, ViewKindSchema, ViewScopeSchema, ViewSharingSchema, ViewTabSchema, VisualizationTypeSchema } from '@objectstack/spec/ui'; +import type { AddRecordConfig, AppearanceConfig, CalendarConfig, ColumnPrefix, ColumnSummary, ColumnSummaryConfig, EmptyState, FormButtonConfig, FormField, FormSection, FormSelectOption, FormView, GalleryConfig, GanttConfig, GanttQuickFilter, GroupingConfig, HttpMethodSubset, HttpRequest, KanbanConfig, ListChartConfig, ListColumn, ListMapConfig, ListView, NavigationConfig, NavigationMode, PaginationConfig, RowColorConfig, RowHeight, SelectionConfig, TimelineConfig, TreeConfig, UserActionsConfig, UserFilterField, UserFilters, View, ViewData, ViewFilterElement, ViewFilterOrGroup, ViewFilterRule, ViewItem, ViewItemName, ViewItemWire, ViewKind, ViewScope, ViewSharing, ViewTab, VisualizationType } from '@objectstack/spec/ui'; // Validate data const result = AddRecordConfigSchema.parse(data); @@ -772,7 +772,7 @@ Map view configuration | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `never` | optional | [REMOVED] `view.list.filterableFields` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — the key was declared as a shorthand for `userFilters.fields` (the quick-filter bar), but the one renderer that read it used it as a whitelist of the fields the Filter panel offers instead, so one key carried two meanings and drew no quick filter. Delete the key; no per-view Filter-panel whitelist replaces it. For a curated end-user filter bar, declare `userFilters: { element: 'dropdown', fields: [{ field: '' }] }`. The Filter panel offers every field the user may read: field read security decides, and a field the object definition marks `hidden: true` is not offered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -855,7 +855,7 @@ Map view configuration | **link** | `boolean` | optional | Functions as the primary navigation link (triggers View navigation) | | **action** | `string` | optional | Registered Action ID to execute when clicked | -### Nested Shape: `ListView.filter[number]` +### Nested Shape: `ListView.filter[number][option 1]` View filter rule @@ -865,6 +865,14 @@ View filter rule | **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. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | +### Nested Shape: `ListView.filter[number][option 2]` + +[PLANNED — not yet rendered] An any-of group: `{ or: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ or: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **or** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | + ### Nested Shape: `ListView.userFilters` | Property | Type | Required | Description | @@ -1153,7 +1161,7 @@ View filter rule | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `never` | optional | [REMOVED] `view.list.filterableFields` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — the key was declared as a shorthand for `userFilters.fields` (the quick-filter bar), but the one renderer that read it used it as a whitelist of the fields the Filter panel offers instead, so one key carried two meanings and drew no quick filter. Delete the key; no per-view Filter-panel whitelist replaces it. For a curated end-user filter bar, declare `userFilters: { element: 'dropdown', fields: [{ field: '' }] }`. The Filter panel offers every field the user may read: field read security decides, and a field the object definition marks `hidden: true` is not offered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -1236,7 +1244,7 @@ View filter rule | **link** | `boolean` | optional | Functions as the primary navigation link (triggers View navigation) | | **action** | `string` | optional | Registered Action ID to execute when clicked | -### Nested Shape: `ObjectListView.filter[number]` +### Nested Shape: `ObjectListView.filter[number][option 1]` View filter rule @@ -1246,6 +1254,14 @@ View filter rule | **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. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | +### Nested Shape: `ObjectListView.filter[number][option 2]` + +[PLANNED — not yet rendered] An any-of group: `{ or: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ or: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **or** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | + ### Nested Shape: `ObjectListView.selection` | Property | Type | Required | Description | @@ -1687,7 +1703,7 @@ Tab configuration for multi-tab view interface | **label** | `string \| Record` | optional | Display label | | **icon** | `string` | optional | Tab icon name | | **view** | `string` | optional | Referenced list view name from listViews | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Tab-specific filter criteria | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | optional | Tab-specific filter criteria | | **order** | `integer` | optional | Tab display order | | **pinned** | `boolean` | optional (default: `false`) | Pin tab (cannot be removed by users) | | **isDefault** | `boolean` | optional (default: `false`) | Set as the default active tab | @@ -1727,7 +1743,7 @@ Tab configuration for multi-tab view interface | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `never` | optional | [REMOVED] `view.list.filterableFields` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — the key was declared as a shorthand for `userFilters.fields` (the quick-filter bar), but the one renderer that read it used it as a whitelist of the fields the Filter panel offers instead, so one key carried two meanings and drew no quick filter. Delete the key; no per-view Filter-panel whitelist replaces it. For a curated end-user filter bar, declare `userFilters: { element: 'dropdown', fields: [{ field: '' }] }`. The Filter panel offers every field the user may read: field read security decides, and a field the object definition marks `hidden: true` is not offered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -1806,7 +1822,7 @@ Tab configuration for multi-tab view interface | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `never` | optional | [REMOVED] `view.list.filterableFields` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — the key was declared as a shorthand for `userFilters.fields` (the quick-filter bar), but the one renderer that read it used it as a whitelist of the fields the Filter panel offers instead, so one key carried two meanings and drew no quick filter. Delete the key; no per-view Filter-panel whitelist replaces it. For a curated end-user filter bar, declare `userFilters: { element: 'dropdown', fields: [{ field: '' }] }`. The Filter panel offers every field the user may read: field read security decides, and a field the object definition marks `hidden: true` is not offered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -1928,6 +1944,99 @@ This schema accepts one of the following structures: --- +--- + +## ViewFilterElement + +One view filter element: a flat rule, or an any-of group of flat rules + +### Union Options + +This schema accepts one of the following structures: + +#### Option 1 + +View filter rule + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **field** | `string` | ✅ | Field name to filter on | +| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| 'starts_with' \| 'ends_with' \| 'greater_than' \| 'less_than' \| 'greater_than_or_equal' \| … +10 more>` | ✅ | 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. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | + +### Allowed Values: `ViewFilterElement[option 1].operator` + +* `equals` +* `not_equals` +* `contains` +* `not_contains` +* `icontains` +* `starts_with` +* `ends_with` +* `greater_than` +* `less_than` +* `greater_than_or_equal` +* `less_than_or_equal` +* `in` +* `not_in` +* `is_empty` +* `is_not_empty` +* `is_null` +* `is_not_null` +* `before` +* `after` +* `between` + +--- + +#### Option 2 + +[PLANNED — not yet rendered] An any-of group: `{ or: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ or: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **or** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | + +### Nested Shape: `ViewFilterElement[option 2].or[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. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | + +--- + + +--- + +## ViewFilterOrGroup + +[PLANNED — not yet rendered] An any-of group: `{ or: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ or: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **or** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | + +### Nested Shape: `ViewFilterOrGroup.or[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. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | + + --- ## ViewFilterRule @@ -2008,7 +2117,7 @@ This schema accepts one of the following structures: | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `never` | optional | [REMOVED] `view.list.filterableFields` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — the key was declared as a shorthand for `userFilters.fields` (the quick-filter bar), but the one renderer that read it used it as a whitelist of the fields the Filter panel offers instead, so one key carried two meanings and drew no quick filter. Delete the key; no per-view Filter-panel whitelist replaces it. For a curated end-user filter bar, declare `userFilters: { element: 'dropdown', fields: [{ field: '' }] }`. The Filter panel offers every field the user may read: field read security decides, and a field the object definition marks `hidden: true` is not offered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -2180,7 +2289,7 @@ This schema accepts one of the following structures: | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `never` | optional | [REMOVED] `view.list.filterableFields` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — the key was declared as a shorthand for `userFilters.fields` (the quick-filter bar), but the one renderer that read it used it as a whitelist of the fields the Filter panel offers instead, so one key carried two meanings and drew no quick filter. Delete the key; no per-view Filter-panel whitelist replaces it. For a curated end-user filter bar, declare `userFilters: { element: 'dropdown', fields: [{ field: '' }] }`. The Filter panel offers every field the user may read: field read security decides, and a field the object definition marks `hidden: true` is not offered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -2368,13 +2477,13 @@ Tab configuration for multi-tab view interface | **label** | `string \| Record` | optional | Display label | | **icon** | `string` | optional | Tab icon name | | **view** | `string` | optional | Referenced list view name from listViews | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Tab-specific filter criteria | +| **filter** | `({ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] } \| { or: object[] })[]` | optional | Tab-specific filter criteria | | **order** | `integer` | optional | Tab display order | | **pinned** | `boolean` | optional (default: `false`) | Pin tab (cannot be removed by users) | | **isDefault** | `boolean` | optional (default: `false`) | Set as the default active tab | | **visible** | `boolean` | optional (default: `true`) | Tab visibility | -### Nested Shape: `ViewTab.filter[number]` +### Nested Shape: `ViewTab.filter[number][option 1]` View filter rule @@ -2384,6 +2493,14 @@ View filter rule | **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. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | +### Nested Shape: `ViewTab.filter[number][option 2]` + +[PLANNED — not yet rendered] An any-of group: `{ or: [rule, ...] }` matches a record when ANY of its rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, so `[{ or: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the last 7 days. One level only: its members are flat rules. No console reads it yet: until the list query build reads the group, a view that stores one is refused when it loads (the list reports a malformed filter and shows no rows). + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **or** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | ✅ | The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, each judged exactly as a rule at the top level of the filter. A group cannot hold a group. | + --- diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md index 4e2536b9446..725fcd426a4 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `ui/` | 216 | 207 | 4 | 0 | 5 | +| `ui/` | 217 | 208 | 4 | 0 | 5 | ## `ui/` — sites @@ -44,9 +44,9 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `report.zod.ts` | 4 | | `responsive.zod.ts` | 1 | | `sharing.zod.ts` | 1 | -| `view.zod.ts` | 58 | +| `view.zod.ts` | 59 | | `widget.zod.ts` | 1 | -| **total** | **216** | +| **total** | **217** | ## `ui/` — open @@ -54,15 +54,15 @@ Per file, how many of its sites still silently discard unknown keys. The `Class` column that decides the bucket split is hand-written in the ledger; the arithmetic over it is here. -**5 strip of 216**, in 4 file(s). +**5 strip of 217**, in 4 file(s). | File | Strip | Sites | |---|---|---| | `action-params.zod.ts` | 1 | 1 | | `app.zod.ts` | 1 | 19 | -| `view.zod.ts` | 2 | 58 | +| `view.zod.ts` | 2 | 59 | | `widget.zod.ts` | 1 | 1 | -| **total** | **5** | **216** | +| **total** | **5** | **217** | | Bucket | Sites | |---|---| diff --git a/packages/lint/src/filter-walk-or-group.test.ts b/packages/lint/src/filter-walk-or-group.test.ts new file mode 100644 index 00000000000..b19cd69b126 --- /dev/null +++ b/packages/lint/src/filter-walk-or-group.test.ts @@ -0,0 +1,77 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#28020] The filter field-key walk steps INTO an any-of group element of a + * view filter list (`{ or: [rule, ...] }`). + * + * Before the walk knew the element, a group read as a field-keyed condition + * object: its own key `or` was reported as the field it filters by, and the + * rules inside it — the fields the query really filters by — were never seen. + * So a group cost a false finding AND hid a true one. These pins hold both + * halves, on the walk itself and through the list-view rule that reads it. + */ + +import { describe, expect, it } from 'vitest'; +import { walkFilterFieldKeys } from './filter-walk.js'; +import { LIST_VIEW_FIELD_UNKNOWN, validateListViewFieldRefs } from './validate-list-view-field-refs.js'; + +const keysOf = (filter: unknown) => { + const out: Array<{ field: string; path: string }> = []; + walkFilterFieldKeys(filter, 'filter', (key) => out.push(key)); + return out; +}; + +describe('walkFilterFieldKeys and the any-of group', () => { + it('reports the rules of a group, at their own paths, and never `or` itself', () => { + expect(keysOf([ + { or: [ + { field: 'status', operator: 'equals', value: 'open' }, + { field: 'completed_at', operator: 'after', value: '{7_days_ago}' }, + ] }, + { field: 'owner', operator: 'equals', value: '{current_user_id}' }, + ])).toEqual([ + { field: 'status', path: 'filter[0].or[0].field' }, + { field: 'completed_at', path: 'filter[0].or[1].field' }, + { field: 'owner', path: 'filter[1].field' }, + ]); + }); + + it('CONTROL: a whole-filter condition object whose field is named `or` is still a condition', () => { + // Only a MEMBER of a filter list is an element; a Mongo-style record that + // is the whole filter keeps its field-keyed reading. + expect(keysOf({ or: 'x' })).toEqual([{ field: 'or', path: 'filter.or' }]); + }); +}); + +describe('validateListViewFieldRefs through a group', () => { + const stack = (filter: unknown) => ({ + name: 'probe', + objects: [{ + name: 'duly_task', + fields: [ + { name: 'title', type: 'text' }, + { name: 'status', type: 'select' }, + { name: 'completed_at', type: 'datetime' }, + ], + }], + views: [{ name: 'duly_task', object: 'duly_task', list: { type: 'grid', columns: ['title'], filter } }], + }); + + it('a group of real fields is silent', () => { + const findings = validateListViewFieldRefs(stack([{ or: [ + { field: 'status', operator: 'equals', value: 'open' }, + { field: 'completed_at', operator: 'after', value: '{7_days_ago}' }, + ] }])); + expect(findings).toEqual([]); + }); + + it('an unknown field inside a group is an error at its own path', () => { + const findings = validateListViewFieldRefs(stack([{ or: [ + { field: 'status', operator: 'equals', value: 'open' }, + { field: 'no_such_field', operator: 'equals', value: 'x' }, + ] }])); + expect(findings.map((f) => [f.rule, f.path])).toEqual([ + [LIST_VIEW_FIELD_UNKNOWN, 'views[0].list.filter[0].or[1].field'], + ]); + }); +}); diff --git a/packages/lint/src/filter-walk.ts b/packages/lint/src/filter-walk.ts index 9bdbdcbfa61..fc88bd84896 100644 --- a/packages/lint/src/filter-walk.ts +++ b/packages/lint/src/filter-walk.ts @@ -49,6 +49,7 @@ */ import { VALID_AST_OPERATORS } from '@objectstack/spec/data'; +import { isViewFilterOrGroup } from '@objectstack/spec/ui'; import { recordsOf } from './object-graph.js'; /** Any plain metadata record. */ @@ -279,6 +280,15 @@ export function walkFilterFieldKeys( // Group ['and'|'or', ...members] or a bare list — recurse the members. node.forEach((member, i) => { if (typeof member === 'string') return; // the leading keyword + // [#28020] An any-of group element of a view filter list. It names no + // field itself, so it is stepped INTO: read as a condition object it + // would report its own key `or` as a field, and its rules would go + // unchecked. + const rules = filterGroupRules(member); + if (rules) { + rules.forEach((rule, j) => walkFilterFieldKeys(rule, `${path}[${i}].or[${j}]`, visit, depth + 2)); + return; + } walkFilterFieldKeys(member, `${path}[${i}]`, visit, depth + 1); }); return; @@ -294,3 +304,21 @@ export function walkFilterFieldKeys( conditionFieldKeys(node, path, '', visit, depth); } + +/** + * [#28020] The rules of an any-of group element of a view filter list + * (`{ or: [rule, ...] }`, `ViewFilterOrGroupSchema`), or `undefined` when + * `member` is not one. + * + * Asked only of a MEMBER of a filter array — the position a group element + * occupies — so a field-keyed condition object whose field happens to be named + * `or` (`{ or: ['a'] }` as a whole filter) is still read as one. Which value + * is a group is the spec's own predicate, `isViewFilterOrGroup`, never a + * restated test. Exported for the other walks of a filter list in this package, + * which must step into a group the same way. + */ +export function filterGroupRules(member: unknown): unknown[] | undefined { + if (!isViewFilterOrGroup(member)) return undefined; + const rules = member.or; + return Array.isArray(rules) ? rules : undefined; +} diff --git a/packages/lint/src/validate-page-field-bindings.test.ts b/packages/lint/src/validate-page-field-bindings.test.ts index 77b85c37341..312ee5bf47a 100644 --- a/packages/lint/src/validate-page-field-bindings.test.ts +++ b/packages/lint/src/validate-page-field-bindings.test.ts @@ -616,6 +616,33 @@ describe('validatePageFieldBindings — binding precedence and traversal', () => 'pages[0].interfaceConfig.filterBy[0].field', ]); }); + + // [#28020] An any-of group element names no field itself — its rules do, and + // each is checked like a top-level rule. Read through `fieldRefsFrom` alone, + // the group yielded nothing and every rule inside it went unchecked. + it('checks the rules inside an any-of group of interfaceConfig.filterBy', () => { + const findings = validatePageFieldBindings({ + ...baseStack(), + pages: [ + { + name: 'list_page', + type: 'list', + object: 'crm_lead', + interfaceConfig: { + source: 'crm_lead', + filterBy: [ + { or: [ + { field: 'status', operator: 'equals', value: 'open' }, + { field: 'ghost_in_group', operator: 'equals', value: 'x' }, + ] }, + { field: 'amount', operator: 'greater_than', value: 1 }, + ], + }, + }, + ], + }); + expect(findings.map((f) => f.path)).toEqual(['pages[0].interfaceConfig.filterBy[0].or[1].field']); + }); }); describe('validatePageFieldBindings — false-positive floor', () => { diff --git a/packages/lint/src/validate-page-field-bindings.ts b/packages/lint/src/validate-page-field-bindings.ts index 4cc9c3c43fe..0b58c005cdd 100644 --- a/packages/lint/src/validate-page-field-bindings.ts +++ b/packages/lint/src/validate-page-field-bindings.ts @@ -107,6 +107,7 @@ export interface PageFieldFinding { } import { walkPageComponents, type AnyRec } from './page-walk.js'; +import { filterGroupRules } from './filter-walk.js'; // Real pages DO reference registry-injected columns — e.g. `sys_user.page.ts` // lists `created_at` in a related-list's columns — so the shared set is load- // bearing here, not merely defensive. @@ -169,6 +170,25 @@ export function fieldRefsFrom(value: unknown, basePath: string): FieldRef[] { return out; } +/** + * [#28020] Field references in a view filter LIST — the `ViewFilterRule` array + * a list page's `filterBy` and a related list's `filter` carry. + * + * Plain rules are ordinary {@link fieldRefsFrom} work. An any-of group element + * (`{ or: [rule, ...] }`) names no field of its own, so its rules are read in + * its place: {@link fieldRefsFrom} alone finds neither `field` nor `name` on the + * group and would skip every field the group's rules filter by. + */ +function filterFieldRefs(value: unknown, basePath: string): FieldRef[] { + if (!Array.isArray(value)) return fieldRefsFrom(value, basePath); + const out: FieldRef[] = []; + for (let i = 0; i < value.length; i++) { + const rules = filterGroupRules(value[i]); + out.push(...(rules ? fieldRefsFrom(rules, `${basePath}[${i}].or`) : fieldRefsFrom(value[i], `${basePath}[${i}]`))); + } + return out; +} + /** * Field references in a `sort` value. * @@ -419,7 +439,7 @@ export function relatedListFieldRefs( related: [ ...fieldRefsFrom(props.columns, at('columns')), ...sortFieldRefs(props.sort, at('sort')), - ...fieldRefsFrom(props.filter, at('filter')), + ...filterFieldRefs(props.filter, at('filter')), ...fieldRefsFrom(props.relationshipField, at('relationshipField')), ...(add ? fieldRefsFrom(add.linkField, at('add.linkField')) : []), ], @@ -673,7 +693,7 @@ export function validatePageFieldBindings(stack: AnyRec): PageFieldFinding[] { const refs: FieldRef[] = [ ...fieldRefsFrom(cfg.columns, `${base}.columns`), ...sortFieldRefs(cfg.sort, `${base}.sort`), - ...fieldRefsFrom(cfg.filterBy, `${base}.filterBy`), + ...filterFieldRefs(cfg.filterBy, `${base}.filterBy`), ]; const userFilters = isRec(cfg.userFilters) ? cfg.userFilters : undefined; if (userFilters) { diff --git a/packages/lint/src/validate-preset-comparands.test.ts b/packages/lint/src/validate-preset-comparands.test.ts index 71238684b25..c68e2e41699 100644 --- a/packages/lint/src/validate-preset-comparands.test.ts +++ b/packages/lint/src/validate-preset-comparands.test.ts @@ -55,6 +55,26 @@ describe('validatePresetComparands (#8793 — the ruled C half of #8690)', () => expect(f.hint).toContain('{date-macro}'); }); + // [#28020] A rule inside an any-of group element of a view filter list is + // judged exactly as a top-level rule — the group is stepped into, not read as + // a field-keyed condition whose one "field" is `or`. + it('judges the rules inside an any-of group of a view filter list', () => { + const findings = validatePresetComparands({ + views: [{ + name: 'recent', + filter: [ + { or: [ + { field: 'status', operator: 'equals', value: 'open' }, + { field: 'created_at', operator: 'after', value: 'last_7_days' }, + ] }, + ], + }], + }); + expect(findings.map((f) => f.path)).toEqual(['views[0].filter[0].or[1].value']); + expect(findings[0]!.rule).toBe(FILTER_PRESET_COMPARAND); + expect(findings[0]!.message).toContain('last_7_days'); + }); + it('judges all three authored filter shapes', () => { const findings = validatePresetComparands({ views: [{ diff --git a/packages/lint/src/validate-preset-comparands.ts b/packages/lint/src/validate-preset-comparands.ts index d3700e1f9d1..dbf9e51d8f5 100644 --- a/packages/lint/src/validate-preset-comparands.ts +++ b/packages/lint/src/validate-preset-comparands.ts @@ -10,7 +10,7 @@ import { } from '@objectstack/spec/data'; import { normalizeFilterOperator } from '@objectstack/spec/ui'; -import { walkAuthoredFilters, type FilterSurface } from './filter-walk.js'; +import { filterGroupRules, walkAuthoredFilters, type FilterSurface } from './filter-walk.js'; import { indexObjectGraph, recordsOf, resolveFieldPath, type ObjectGraph } from './object-graph.js'; /** @@ -705,6 +705,14 @@ function judgeFilterValue( // Group ['and'|'or', ...members] or bare list — recurse the members. node.forEach((member, i) => { if (typeof member === 'string') return; // the leading keyword + // [#28020] An any-of group element: its rules are judged exactly as + // top-level rules are — a preset in one is the same refusal. + const rules = filterGroupRules(member); + if (rules) { + rules.forEach((rule, j) => + judgeFilterValue(rule, `${path}[${i}].or[${j}]`, where, out, depth + 2, isTemporal)); + return; + } judgeFilterValue(member, `${path}[${i}]`, where, out, depth + 1, isTemporal); }); return; diff --git a/packages/metadata-protocol/src/metadata-diagnostics.ts b/packages/metadata-protocol/src/metadata-diagnostics.ts index 6eb8ac1e30e..6cf38c21da5 100644 --- a/packages/metadata-protocol/src/metadata-diagnostics.ts +++ b/packages/metadata-protocol/src/metadata-diagnostics.ts @@ -28,6 +28,7 @@ import type { z } from 'zod'; import { getMetadataTypeSchema, stripReadDecorations } from '@objectstack/spec/kernel'; import type { MetadataValidationResult } from '@objectstack/spec/kernel'; import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; +import { isViewFilterOrGroup } from '@objectstack/spec/ui'; // [#5598] The READ path's share of the #5364 expansion. `zodIssuesToMetadataIssues` // is the ONE ranking this package speaks; the save path's 422 calls the same // function, so a document's verdict cannot depend on whether it was being saved @@ -486,7 +487,17 @@ export function computeViewReferenceDiagnostics( | undefined; userFilters?.fields?.forEach((f, i) => requireField(f?.field, `userFilters.fields.${i}.field`)); userFilters?.tabs?.forEach((t, i) => - t?.filter?.forEach((r, j) => requireField(r?.field, `userFilters.tabs.${i}.filter.${j}.field`))); + t?.filter?.forEach((r, j) => { + // [#28020] An any-of group element names no field itself; each of + // its rules names one, and each is checked like a top-level rule. + if (isViewFilterOrGroup(r)) { + const rules = Array.isArray(r.or) ? (r.or as Array<{ field?: string }>) : []; + rules.forEach((rule, k) => + requireField(rule?.field, `userFilters.tabs.${i}.filter.${j}.or.${k}.field`)); + return; + } + requireField(r?.field, `userFilters.tabs.${i}.filter.${j}.field`); + })); // A list view's OWN `tabs` and `filterableFields` are not walked: each is a // `retiredKey` tombstone on every list-view shape. The write door refuses diff --git a/packages/metadata-protocol/src/metadata-diagnostics.view-filter-or-group.test.ts b/packages/metadata-protocol/src/metadata-diagnostics.view-filter-or-group.test.ts new file mode 100644 index 00000000000..7de50b0eb2f --- /dev/null +++ b/packages/metadata-protocol/src/metadata-diagnostics.view-filter-or-group.test.ts @@ -0,0 +1,62 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#28020] Two readers of a stored view filter list, and the any-of group + * element (`{ or: [rule, ...] }`) the list can now hold. + * + * - `computeViewReferenceDiagnostics` checks every field a list view's preset + * tabs filter by. A group names no field itself, so read as a rule it was + * skipped whole and the fields its rules filter by went unchecked. + * - `graftNormalizedOperators` copies the spec's operator fold back onto an + * authored body by STRUCTURE, with no key list, so it reaches the rules + * inside a group without knowing the group exists. Pinned so that stays true. + */ + +import { describe, expect, it } from 'vitest'; +import { ViewMetadataSchema } from '@objectstack/spec/ui'; +import { computeViewReferenceDiagnostics } from './metadata-diagnostics.js'; +import { graftNormalizedOperators } from './protocol.js'; + +const objectDef = { fields: { status: { type: 'select' }, completed_at: { type: 'datetime' } } }; + +describe('computeViewReferenceDiagnostics and an any-of group in a preset tab', () => { + const view = (rules: unknown[]) => ({ + userFilters: { tabs: [{ name: 'recent', filter: [{ or: rules }] }] }, + }); + + it('a group of real fields is valid', () => { + expect(computeViewReferenceDiagnostics(view([ + { field: 'status', operator: 'equals', value: 'open' }, + { field: 'completed_at', operator: 'after', value: '{7_days_ago}' }, + ]), objectDef)).toEqual({ valid: true }); + }); + + it('an unknown field inside a group is reported at its own path', () => { + const result = computeViewReferenceDiagnostics(view([ + { field: 'status', operator: 'equals', value: 'open' }, + { field: 'ghost', operator: 'equals', value: 'x' }, + ]), objectDef); + expect(result.valid).toBe(false); + expect(result.errors).toEqual([{ + path: 'userFilters.tabs.0.filter.0.or.1.field', + message: 'Field "ghost" does not exist on the source object', + code: 'reference_not_found', + }]); + }); +}); + +describe('graftNormalizedOperators reaches the rules inside a group', () => { + it('folds an alias operator inside a group and changes nothing else', () => { + const authored = { + name: 'task.recent', object: 'task', viewKind: 'list', label: 'Recent', type: 'grid', columns: ['name'], + filter: [{ or: [{ field: 'amount', operator: 'gt', value: 1 }, { field: 'status', operator: 'equals', value: 'open' }] }], + }; + const parsed = ViewMetadataSchema.safeParse(authored); + expect(parsed.success).toBe(true); + const out = graftNormalizedOperators(authored, parsed.data) as typeof authored; + expect(out.filter).toEqual([ + { or: [{ field: 'amount', operator: 'greater_than', value: 1 }, { field: 'status', operator: 'equals', value: 'open' }] }, + ]); + expect(authored.filter[0]!.or[0]!.operator).toBe('gt'); + }); +}); diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index fb64982ab8f..4401a101579 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -468,7 +468,13 @@ "ViewData (type)", "ViewDataParsed (type)", "ViewDataSchema (const)", + "ViewFilterElement (type)", + "ViewFilterElementParsed (type)", + "ViewFilterElementSchema (const)", "ViewFilterOperator (type)", + "ViewFilterOrGroup (type)", + "ViewFilterOrGroupParsed (type)", + "ViewFilterOrGroupSchema (const)", "ViewFilterRule (type)", "ViewFilterRuleParsed (type)", "ViewFilterRuleSchema (const)", @@ -555,6 +561,7 @@ "isKnownComponentType (function)", "isRecordContextBlockType (function)", "isViewContainerShaped (function)", + "isViewFilterOrGroup (function)", "listViewGroupKeyPredicate (function)", "listViewGroupOrderColumn (function)", "misplacedDefaultPageRefusal (function)", diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 057a463d806..d503ecbc500 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -1362,6 +1362,7 @@ "ui/View:name", "ui/View:object", "ui/View:protection", + "ui/ViewFilterOrGroup:or", "ui/ViewFilterRule:field", "ui/ViewFilterRule:operator", "ui/ViewFilterRule:value", diff --git a/packages/spec/declaration-map/ui.json b/packages/spec/declaration-map/ui.json index ad203b4f624..18436b21268 100644 --- a/packages/spec/declaration-map/ui.json +++ b/packages/spec/declaration-map/ui.json @@ -287,6 +287,10 @@ "View": "ui/View", "ViewData": "ui/ViewData", "ViewDataSchema": "ui/ViewData", + "ViewFilterElement": "ui/ViewFilterElement", + "ViewFilterElementSchema": "ui/ViewFilterElement", + "ViewFilterOrGroup": "ui/ViewFilterOrGroup", + "ViewFilterOrGroupSchema": "ui/ViewFilterOrGroup", "ViewFilterRule": "ui/ViewFilterRule", "ViewFilterRuleSchema": "ui/ViewFilterRule", "ViewItem": "ui/ViewItem", diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 9a3aeb656ab..99d392c3a96 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -2,8 +2,8 @@ "description": "Shrink-only ledger of every PUBLISHED JSON Schema that is STILL WIDER than the Zod type it was generated from, because a rule written as `.refine()` reaches the runtime and not the file (#18670). `z.toJSONSchema()` has no arm for a `custom` check: a plain record, the same record with a `.refine()`, and the same record with an ABORTING `.refine()` all project byte-identically (measured on zod 4.4.3, the version packages/spec resolves). So a document one of these files ACCEPTS can still be refused at parse time, and an author -- or an AI -- validating against packages/spec/json-schema/** finds out a release later. Each `sites` path is a position under that schema at which a refinement is dropped; the same paths are written onto the artifact itself as `x-dropped-refinements`. Item 2 closed the first patterns: a refinement DECLARED through the closed list in src/shared/refinement-projection.ts is emitted into the published file, reads `projected` rather than `dropped`, and its row LEAVES this ledger in the same PR -- which is why the ledger shrinks and never grows on a repair. Every refinement outside that closed list stays here, and adding an arm to the list is a public-contract decision, not a refactor. Hand-edited on purpose and with no `gen:` script: a generator would let a new gap be admitted by running a command instead of by a decision, which is the silence this ledger exists to end. Adding, removing or moving a site fails packages/spec/scripts/build-schemas.ts until the line moves with it, and the failure prints the corrected entry in full. ⛔ Do not delete or weaken a refinement to shorten this file -- the runtime rule is correct; it is the projection that is silent, and the remedy is to teach the closed list a NAMED pattern, never to drop the rule.", "measured": { "zod": "4.4.3", - "publishedSchemasWithDroppedRefinements": 244, - "droppedRefinementSites": 906, + "publishedSchemasWithDroppedRefinements": 246, + "droppedRefinementSites": 942, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, @@ -133,6 +133,7 @@ "manifest.views.element.list.bulkActionDefs.element", "manifest.views.element.list.emptyState.link.url", "manifest.views.element.list.filter.element", + "manifest.views.element.list.filter.element.options[0]", "manifest.views.element.list.gantt.groupByField", "manifest.views.element.list.grouping.fields.element.field", "manifest.views.element.list.kanban.groupByField", @@ -261,6 +262,7 @@ "data.options[1].manifest.views.element.list.bulkActionDefs.element", "data.options[1].manifest.views.element.list.emptyState.link.url", "data.options[1].manifest.views.element.list.filter.element", + "data.options[1].manifest.views.element.list.filter.element.options[0]", "data.options[1].manifest.views.element.list.gantt.groupByField", "data.options[1].manifest.views.element.list.grouping.fields.element.field", "data.options[1].manifest.views.element.list.kanban.groupByField", @@ -297,6 +299,7 @@ "list.bulkActionDefs.element", "list.emptyState.link.url", "list.filter.element", + "list.filter.element.options[0]", "list.gantt.groupByField", "list.grouping.fields.element.field", "list.kanban.groupByField", @@ -394,6 +397,7 @@ "options[1].manifest.views.element.list.bulkActionDefs.element", "options[1].manifest.views.element.list.emptyState.link.url", "options[1].manifest.views.element.list.filter.element", + "options[1].manifest.views.element.list.filter.element.options[0]", "options[1].manifest.views.element.list.gantt.groupByField", "options[1].manifest.views.element.list.grouping.fields.element.field", "options[1].manifest.views.element.list.kanban.groupByField", @@ -463,6 +467,7 @@ "data.packages.element.options[1].manifest.views.element.list", "data.packages.element.options[1].manifest.views.element.list.bulkActionDefs.element", "data.packages.element.options[1].manifest.views.element.list.filter.element", + "data.packages.element.options[1].manifest.views.element.list.filter.element.options[0]", "data.packages.element.options[1].manifest.views.element.list.gantt.groupByField", "data.packages.element.options[1].manifest.views.element.list.kanban.groupByField", "data.packages.element.options[1].manifest.views.element.list.timeline.groupByField" @@ -502,6 +507,7 @@ "data.listViews.valueType.bulkActionDefs.element", "data.listViews.valueType.emptyState.link.url", "data.listViews.valueType.filter.element", + "data.listViews.valueType.filter.element.options[0]", "data.listViews.valueType.gantt.groupByField", "data.listViews.valueType.grouping.fields.element.field", "data.listViews.valueType.kanban.groupByField", @@ -963,6 +969,7 @@ "listViews.valueType.bulkActionDefs.element", "listViews.valueType.emptyState.link.url", "listViews.valueType.filter.element", + "listViews.valueType.filter.element.options[0]", "listViews.valueType.gantt.groupByField", "listViews.valueType.grouping.fields.element.field", "listViews.valueType.kanban.groupByField", @@ -1298,6 +1305,7 @@ "operations.element.options[3].object.listViews.valueType.bulkActionDefs.element", "operations.element.options[3].object.listViews.valueType.emptyState.link.url", "operations.element.options[3].object.listViews.valueType.filter.element", + "operations.element.options[3].object.listViews.valueType.filter.element.options[0]", "operations.element.options[3].object.listViews.valueType.gantt.groupByField", "operations.element.options[3].object.listViews.valueType.grouping.fields.element.field", "operations.element.options[3].object.listViews.valueType.kanban.groupByField", @@ -1325,6 +1333,7 @@ "object.listViews.valueType.bulkActionDefs.element", "object.listViews.valueType.emptyState.link.url", "object.listViews.valueType.filter.element", + "object.listViews.valueType.filter.element.options[0]", "object.listViews.valueType.gantt.groupByField", "object.listViews.valueType.grouping.fields.element.field", "object.listViews.valueType.kanban.groupByField", @@ -1374,6 +1383,7 @@ "options[3].object.listViews.valueType.bulkActionDefs.element", "options[3].object.listViews.valueType.emptyState.link.url", "options[3].object.listViews.valueType.filter.element", + "options[3].object.listViews.valueType.filter.element.options[0]", "options[3].object.listViews.valueType.gantt.groupByField", "options[3].object.listViews.valueType.grouping.fields.element.field", "options[3].object.listViews.valueType.kanban.groupByField", @@ -1578,7 +1588,8 @@ }, "ui/ElementDataSource": { "sites": [ - "filter.element" + "filter.element", + "filter.element.options[0]" ] }, "ui/EmptyState": { @@ -1634,7 +1645,8 @@ "ui/InterfacePageConfig": { "sites": [ "emptyState.link.url", - "filterBy.element" + "filterBy.element", + "filterBy.element.options[0]" ] }, "ui/JoinedReportBlock": { @@ -1655,6 +1667,7 @@ "bulkActionDefs.element", "emptyState.link.url", "filter.element", + "filter.element.options[0]", "gantt.groupByField", "grouping.fields.element.field", "kanban.groupByField", @@ -1681,7 +1694,8 @@ }, "ui/ObjectCalendarProps": { "sites": [ - "filter.element" + "filter.element", + "filter.element.options[0]" ] }, "ui/ObjectFormProps": { @@ -1696,6 +1710,7 @@ "ui/ObjectGanttProps": { "sites": [ "filter.element", + "filter.element.options[0]", "gantt.groupByField" ] }, @@ -1704,12 +1719,14 @@ "bulkActionDefs.element", "emptyState.link.url", "filter.element", + "filter.element.options[0]", "grouping.fields.element.field" ] }, "ui/ObjectKanbanProps": { "sites": [ "filter.element", + "filter.element.options[0]", "grouping.fields.element.field" ] }, @@ -1719,6 +1736,7 @@ "bulkActionDefs.element", "emptyState.link.url", "filter.element", + "filter.element.options[0]", "gantt.groupByField", "grouping.fields.element.field", "kanban.groupByField", @@ -1727,7 +1745,8 @@ }, "ui/ObjectMapProps": { "sites": [ - "filter.element" + "filter.element", + "filter.element.options[0]" ] }, "ui/ObjectMasterDetailFormProps": { @@ -1746,19 +1765,22 @@ "drillDown.report.blocks.element.runtimeFilter", "drillDown.report.runtimeFilter", "drillDown.report.runtimeFilter.lazy", - "filter.element" + "filter.element", + "filter.element.options[0]" ] }, "ui/ObjectTimelineProps": { "sites": [ "", "filter.element", + "filter.element.options[0]", "timeline.groupByField" ] }, "ui/ObjectTreeProps": { "sites": [ - "filter.element" + "filter.element", + "filter.element.options[0]" ] }, "ui/Page": { @@ -1766,23 +1788,27 @@ "", "interfaceConfig.emptyState.link.url", "record.filter.element", + "record.filter.element.options[0]", "slots.header.options[0].type" ] }, "ui/PageComponent": { "sites": [ "dataSource.options[0].filter.element", + "dataSource.options[0].filter.element.options[0]", "type" ] }, "ui/PageRecordBinding": { "sites": [ - "filter.element" + "filter.element", + "filter.element.options[0]" ] }, "ui/PageRegion": { "sites": [ "components.element.lazy.dataSource.options[0].filter.element", + "components.element.lazy.dataSource.options[0].filter.element.options[0]", "components.element.lazy.type" ] }, @@ -1800,7 +1826,8 @@ "ui/RecordLineItemsProps": { "sites": [ "columns.element", - "filter.element" + "filter.element", + "filter.element.options[0]" ] }, "ui/RecordQuickActionsProps": { @@ -1810,7 +1837,8 @@ }, "ui/RecordRelatedListProps": { "sites": [ - "filter.element" + "filter.element", + "filter.element.options[0]" ] }, "ui/Report": { @@ -1829,7 +1857,8 @@ }, "ui/UserFilters": { "sites": [ - "tabs.element.filter.element" + "tabs.element.filter.element", + "tabs.element.filter.element.options[0]" ] }, "ui/View": { @@ -1842,12 +1871,24 @@ "list.bulkActionDefs.element", "list.emptyState.link.url", "list.filter.element", + "list.filter.element.options[0]", "list.gantt.groupByField", "list.grouping.fields.element.field", "list.kanban.groupByField", "list.timeline.groupByField" ] }, + "ui/ViewFilterOrGroup": { + "sites": [ + "or.element" + ] + }, + "ui/ViewFilterElement": { + "sites": [ + "", + "options[0]" + ] + }, "ui/ViewFilterRule": { "sites": [ "" @@ -1859,6 +1900,7 @@ "options[0].config.bulkActionDefs.element", "options[0].config.emptyState.link.url", "options[0].config.filter.element", + "options[0].config.filter.element.options[0]", "options[0].config.gantt.groupByField", "options[0].config.grouping.fields.element.field", "options[0].config.kanban.groupByField", @@ -1875,6 +1917,7 @@ "options[0].config.bulkActionDefs.element", "options[0].config.emptyState.link.url", "options[0].config.filter.element", + "options[0].config.filter.element.options[0]", "options[0].config.gantt.groupByField", "options[0].config.grouping.fields.element.field", "options[0].config.kanban.groupByField", @@ -1887,7 +1930,8 @@ }, "ui/ViewTab": { "sites": [ - "filter.element" + "filter.element", + "filter.element.options[0]" ] } } diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index db3ec47a59c..56a1a756dbf 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -451,7 +451,13 @@ "ViewData": "src/ui/view.zod.ts#ViewData (type)", "ViewDataParsed": "src/ui/view.zod.ts#ViewDataParsed (type)", "ViewDataSchema": "src/ui/view.zod.ts#ViewDataSchema (const)", + "ViewFilterElement": "src/ui/view.zod.ts#ViewFilterElement (type)", + "ViewFilterElementParsed": "src/ui/view.zod.ts#ViewFilterElementParsed (type)", + "ViewFilterElementSchema": "src/ui/view.zod.ts#ViewFilterElementSchema (const)", "ViewFilterOperator": "src/ui/view.zod.ts#ViewFilterOperator (type)", + "ViewFilterOrGroup": "src/ui/view.zod.ts#ViewFilterOrGroup (type)", + "ViewFilterOrGroupParsed": "src/ui/view.zod.ts#ViewFilterOrGroupParsed (type)", + "ViewFilterOrGroupSchema": "src/ui/view.zod.ts#ViewFilterOrGroupSchema (const)", "ViewFilterRule": "src/ui/view.zod.ts#ViewFilterRule (type)", "ViewFilterRuleParsed": "src/ui/view.zod.ts#ViewFilterRuleParsed (type)", "ViewFilterRuleSchema": "src/ui/view.zod.ts#ViewFilterRuleSchema (const)", @@ -538,6 +544,7 @@ "isKnownComponentType": "src/ui/component-type-vocabulary.ts#isKnownComponentType (function)", "isRecordContextBlockType": "src/ui/react-blocks.ts#isRecordContextBlockType (function)", "isViewContainerShaped": "src/ui/assembled-views.zod.ts#isViewContainerShaped (function)", + "isViewFilterOrGroup": "src/ui/view.zod.ts#isViewFilterOrGroup (function)", "listViewGroupKeyPredicate": "src/ui/view-grouping-query.ts#listViewGroupKeyPredicate (function)", "listViewGroupOrderColumn": "src/ui/view-grouping-query.ts#listViewGroupOrderColumn (function)", "misplacedDefaultPageRefusal": "src/ui/default-record-page.ts#misplacedDefaultPageRefusal (function)", diff --git a/packages/spec/json-schema.manifest/ui.json b/packages/spec/json-schema.manifest/ui.json index 151713915b5..400f5f41cab 100644 --- a/packages/spec/json-schema.manifest/ui.json +++ b/packages/spec/json-schema.manifest/ui.json @@ -163,6 +163,8 @@ "ui/UserFilters", "ui/View", "ui/ViewData", + "ui/ViewFilterElement", + "ui/ViewFilterOrGroup", "ui/ViewFilterRule", "ui/ViewItem", "ui/ViewItemName", diff --git a/packages/spec/liveness/view.json b/packages/spec/liveness/view.json index 4fd9c253b0f..caceb08cfe7 100644 --- a/packages/spec/liveness/view.json +++ b/packages/spec/liveness/view.json @@ -49,7 +49,7 @@ }, "filter": { "status": "live", - "note": "objectui: ListView.tsx client-side filter shaping (audit L15); no list-render prop reaches the server query (audit data-flow note). The console's filter rows carry a React list key that the wire door removes before the parse (packages/spec/src/ui/view.zod.ts, the VIEW_CONSOLE_ROW_DECORATIONS header and the ViewFilterRule docblock; moved out of spec source, #23434): `id: crypto.randomUUID()` is stamped on each new row by objectui @b9814b334 packages/components/src/custom/filter-builder.tsx#FilterBuilder (its `addCondition`), and again on a stored filter read back into the builder by objectui @b9814b334 packages/plugin-view/src/config/view-config-utils.ts#parseTriplet and objectui @b9814b334 packages/plugin-view/src/config/view-config-utils.ts#parseSingleOrNested (both reached from that file's parseSpecFilter); re-read 2026-10-15, first cited by line, sha-less." + "note": "objectui: ListView.tsx client-side filter shaping (audit L15); no list-render prop reaches the server query (audit data-flow note). The console's filter rows carry a React list key that the wire door removes before the parse (packages/spec/src/ui/view.zod.ts, the VIEW_CONSOLE_ROW_DECORATIONS header and the ViewFilterRule docblock; moved out of spec source, #23434): `id: crypto.randomUUID()` is stamped on each new row by objectui @b9814b334 packages/components/src/custom/filter-builder.tsx#FilterBuilder (its `addCondition`), and again on a stored filter read back into the builder by objectui @b9814b334 packages/plugin-view/src/config/view-config-utils.ts#parseTriplet and objectui @b9814b334 packages/plugin-view/src/config/view-config-utils.ts#parseSingleOrNested (both reached from that file's parseSpecFilter); re-read 2026-10-15, first cited by line, sha-less. One element kind rides this live verdict at PLANNED strength (#28020, 2026-10-31): the any-of group `{ or: [rule, ...] }` (ViewFilterOrGroupSchema, its describe marked `[PLANNED — not yet rendered]`) has no console reader yet — the list query build (ui/packages/plugin-list/src/ListView.tsx, buildEffectiveFilter; ui/packages/core/src/utils/filter-converter.ts, convertFiltersToAST) and the builder save fold (ui/packages/app-shell/src/views/viewFilterFold.ts, which refuses an OR group) read flat rules only, read on this tree at 22ce3d13fe. The console half is #28370; amend this note, citing the group read, when it lands." }, "sort": { "status": "live", diff --git a/packages/spec/src/ai/solution-blueprint.zod.ts b/packages/spec/src/ai/solution-blueprint.zod.ts index 48a4827d38e..943b6366465 100644 --- a/packages/spec/src/ai/solution-blueprint.zod.ts +++ b/packages/spec/src/ai/solution-blueprint.zod.ts @@ -170,6 +170,12 @@ export const BlueprintViewSchema = lazySchema(() => z.object({ .describe('Field names shown as columns (in order). For a gallery, INCLUDE the image/avatar/file field (it becomes the card cover); for a gantt, INCLUDE the start date column before the end date column.'), groupBy: z.string().regex(SNAKE_CASE).optional() .describe('REQUIRED for kanban views: the select/status field whose options become the board columns (e.g. "stage", "status"). Without it a kanban renders as a plain list. Optional for gantt (groups leaf tasks into summary rows).'), + // [#28020] Flat rules only: the any-of group element `ListView.filter` takes + // (`ViewFilterElementSchema`) is deliberately NOT admitted here. The design + // model writes this key through the strict mirror below, which cannot carry a + // union, and the applier that turns it into a built view lives outside this + // repository; widening the lenient side alone would accept a shape no producer + // emits and no reader here was measured on. filters: z.array(ViewFilterRuleSchema).optional() .describe('Which records this view shows: the list-view filter rules [{ field, operator, value }] the built view carries, ANDed. REQUIRED whenever the view\'s name or label states a condition — an 「overdue / 逾期」, 「my open / 我的未完成」, 「待审批」 or 「本月」 view MUST carry the rules that make its name true, e.g. overdue → [{ field: "due_date", operator: "less_than", value: "{today}" }, { field: "status", operator: "not_equals", value: "completed" }]; my open → [{ field: "owner", operator: "equals", value: "{current_user_id}" }, { field: "status", operator: "equals", value: "open" }]. WITHOUT it the view shows EVERY record under a name that promises a subset. For a select field compare its option VALUE, never its label. Omit for a view that genuinely spans every record.'), sort: z.array(SortItemSchema).optional() diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 0879ab5d1bd..36f5e353794 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -13620,8 +13620,10 @@ function dollarKeysReason(keys: readonly string[]): string { ? '' : ` (its top-level ${quoteKeys(others)} ${others.length === 1 ? 'is' : 'are'} not a field either)`; return `carries the combinator${combinators.length === 1 ? '' : 's'} ${quoteKeys(combinators)}` - + `${alsoNotFields}: a rule array's rules only AND, so \`$or\` and \`$not\` have no rule ` - + 'spelling and `$and` only the separate rules it joins, and a combinator is never flattened — ' + + `${alsoNotFields}: a rule array's elements AND, so \`$and\` is only the separate rules it ` + + 'joins, `$or` over flat rules only an any-of group element `{ or: [rule, ...] }` (declared, ' + + 'not yet applied by any console) that this conversion does not write, and `$not` has no rule ' + + 'spelling; a combinator is never flattened — ' + 'that would change which rows the filter selects. Decide which rows it should select, and ' + 'write the rules that select exactly those'; } diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index f4deacf10fa..fb9ec8c6fac 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { - ViewFilterRuleSchema, + ViewFilterElementSchema, ViewDataSchema, GanttConfigSchema, TreeConfigSchema, @@ -1713,7 +1713,7 @@ export const RecordRelatedListProps = strictObject({ })) ]).optional().describe('Sort order for related records'), limit: z.number().int().positive().default(5).describe('Number of records to display initially'), - filter: z.array(ViewFilterRuleSchema).optional().describe('Additional filter criteria for related records'), + filter: z.array(ViewFilterElementSchema).optional().describe('Additional filter criteria for related records'), title: I18nLabelSchema.optional().describe('Custom title for the related list'), showViewAll: z.boolean().default(true).describe('Show "View All" link to see all related records'), actions: z.array(z.string()).optional().describe('Action IDs available for related records'), @@ -1749,7 +1749,7 @@ export const RecordRelatedListProps = strictObject({ object: z.string().describe('Object to pick records from (the far side of an m2m, or the child object for a 1:m re-parent).'), valueField: z.string().default('id').describe('Field on the picked record used as the link value (default `id`).'), labelField: z.string().optional().describe('Field shown in the picker rows (defaults to the object title field).'), - filter: z.array(ViewFilterRuleSchema).optional().describe('Restrict which records the picker offers.'), + filter: z.array(ViewFilterElementSchema).optional().describe('Restrict which records the picker offers.'), }).describe('Where the Add affordance sources records from.'), linkField: z.string().optional().describe('Field on `objectName` that stores the picked record id (junction case). Omit for a 1:m re-parent.'), label: I18nLabelSchema.optional().describe('Label for the Add button (default "Add").'), @@ -2533,7 +2533,7 @@ export const RecordLineItemsProps = lazySchema(() => strictObject({ .describe('Render the grid read-only: cells locked, no Save button, and no adding or removing lines (renderer default: editable)'), minRows: z.number().optional().describe('Minimum number of rows'), maxRows: z.number().optional().describe('Maximum number of rows'), - filter: z.array(ViewFilterRuleSchema).optional() + filter: z.array(ViewFilterElementSchema).optional() .describe('Additional criteria for the child rows — the ViewFilterRule array form `[{ field, operator, value }, ...]`, AND-combined with the parent relationship condition, never substituted for it: it can only narrow this record\'s lines'), sort: z.array(SortItemSchema).optional() .describe('Load order for the child rows — the SortItem array form `[{ field, order }, ...]` (renderer default: storage order)'), @@ -4999,7 +4999,7 @@ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ * `filter`; the migration prescription is the * `element-data-source-and-object-block-filter-rule-array` semantic entry. */ - filter: z.array(ViewFilterRuleSchema, { + filter: z.array(ViewFilterElementSchema, { error: ruleArrayFilterError({ surface: 'this `object-grid`', migration: 'element-data-source-and-object-block-filter-rule-array', @@ -5737,7 +5737,7 @@ export const ObjectMetricPropsSchema = lazySchema(() => strictObject({ * at `filter`; see migration * `element-data-source-and-object-block-filter-rule-array`. */ - filter: z.array(ViewFilterRuleSchema, { + filter: z.array(ViewFilterElementSchema, { error: ruleArrayFilterError({ surface: 'this `object-metric`', migration: 'element-data-source-and-object-block-filter-rule-array', @@ -5920,7 +5920,7 @@ export const ObjectKanbanPropsSchema = lazySchema(() => strictObject({ * `filter`; see migration * `element-data-source-and-object-block-filter-rule-array`. */ - filter: z.array(ViewFilterRuleSchema, { + filter: z.array(ViewFilterElementSchema, { error: ruleArrayFilterError({ surface: 'this `object-kanban`', migration: 'element-data-source-and-object-block-filter-rule-array', @@ -6220,7 +6220,7 @@ export const ObjectCalendarPropsSchema = lazySchema(() => strictObject({ * `filter`; see migration * `element-data-source-and-object-block-filter-rule-array`. */ - filter: z.array(ViewFilterRuleSchema, { + filter: z.array(ViewFilterElementSchema, { error: ruleArrayFilterError({ surface: 'this `object-calendar`', migration: 'element-data-source-and-object-block-filter-rule-array', @@ -7493,7 +7493,7 @@ export const ObjectMapPropsSchema = lazySchema(() => strictObject({ * does for the kanban and the calendar. The record form is refused at * `filter`. */ - filter: z.array(ViewFilterRuleSchema, { + filter: z.array(ViewFilterElementSchema, { error: ruleArrayFilterError({ surface: 'this `object-map`', migration: 'element-data-source-and-object-block-filter-rule-array', @@ -7694,7 +7694,7 @@ export const ObjectGanttPropsSchema = lazySchema(() => strictObject({ staticData: z.array(z.unknown()).optional() .describe("Inline records — read SECOND by `resolveRecordSourceConfig`, wrapped into a `{ provider: 'value' }` config"), /** Base query filter — the family's one `ViewFilterRule` array orthography (#15449). */ - filter: z.array(ViewFilterRuleSchema, { + filter: z.array(ViewFilterElementSchema, { error: ruleArrayFilterError({ surface: 'this `object-gantt`', migration: 'element-data-source-and-object-block-filter-rule-array', @@ -7903,7 +7903,7 @@ export const ObjectTreePropsSchema = lazySchema(() => strictObject({ staticData: z.array(z.unknown()).optional() .describe("Inline records — read SECOND by `resolveRecordSourceConfig`, wrapped into a `{ provider: 'value' }` config"), /** Base query filter — the family's one `ViewFilterRule` array orthography (#15449). */ - filter: z.array(ViewFilterRuleSchema, { + filter: z.array(ViewFilterElementSchema, { error: ruleArrayFilterError({ surface: 'this `object-tree`', migration: 'element-data-source-and-object-block-filter-rule-array', @@ -8273,7 +8273,7 @@ export const ObjectTimelinePropsSchema = lazySchema(() => strictObject({ timeline: TimelineConfigSchema.optional() .describe('Timeline configuration, the author face — the same block `ListViewSchema.timeline` declares: { startDateField, endDateField, titleField, groupByField, colorField, scale }. The flat top-level spellings beside it are the runtime handoff, not a second authoring spelling'), /** Base query filter — the family's one `ViewFilterRule` array orthography (#15449). */ - filter: z.array(ViewFilterRuleSchema, { + filter: z.array(ViewFilterElementSchema, { error: ruleArrayFilterError({ surface: 'this `object-timeline`', migration: 'element-data-source-and-object-block-filter-rule-array', diff --git a/packages/spec/src/ui/filter-rule-array-guidance.test.ts b/packages/spec/src/ui/filter-rule-array-guidance.test.ts index 9688b2c53a2..5abdbe2c47a 100644 --- a/packages/spec/src/ui/filter-rule-array-guidance.test.ts +++ b/packages/spec/src/ui/filter-rule-array-guidance.test.ts @@ -164,7 +164,12 @@ describe('§1 the record form is refused WITH the new spelling', () => { ); expect(issue.code).toBe('invalid_type'); expect(issue.message).not.toContain("field: '$or'"); - expect(issue.message).toContain('`$or` is a combinator, not a field, and the rule array has no spelling for it'); + expect(issue.message).toContain('`$or` is a combinator, not a field, and the rule array spells a combinator only as its own elements'); + // [#28020] The one combinator the rule array can spell is named, with its + // status: the any-of group is declared but no console applies it yet. + expect(issue.message).toContain('`$or` over flat rules is one any-of group element'); + expect(issue.message).toContain('no console applies it yet'); + expect(issue.message).toContain('`$not` cannot be written at all'); expect(issue.message).toContain('flattening one changes which rows the filter selects'); expect(issue.message).toContain('decide which rows this filter should select'); expect(issue.message).toContain('migration `element-data-source-and-object-block-filter-rule-array`'); diff --git a/packages/spec/src/ui/filter-rule-array.ts b/packages/spec/src/ui/filter-rule-array.ts index ad2405c8c8b..d7efe87769c 100644 --- a/packages/spec/src/ui/filter-rule-array.ts +++ b/packages/spec/src/ui/filter-rule-array.ts @@ -130,7 +130,7 @@ function ruleKeys(): readonly string[] { * * @example * ```ts - * filter: z.array(ViewFilterRuleSchema, { + * filter: z.array(ViewFilterElementSchema, { * error: ruleArrayFilterError({ * surface: 'this `object-grid`', * migration: 'element-data-source-and-object-block-filter-rule-array', @@ -187,14 +187,19 @@ export function ruleArrayFilterError(options: RuleArrayFilterErrorOptions): z.co lead = ''; } const named = combinators.map((key) => `\`${key}\``).join(' and '); + // [#28020] The rule array gained ONE combinator spelling, the any-of group + // element, and this sentence names it — with its status, because no console + // applies it yet: prescribing it bare would send an author to a filter that + // does not narrow the rows (`ViewFilterOrGroupSchema`'s PLANNED note). const combinatorSentence = combinators.length === 0 ? '' : ` ${named} ${combinators.length === 1 ? 'is a combinator, not a field' : 'are combinators, not fields'}, ` - + `and the rule array has no spelling for ${combinators.length === 1 ? 'it' : 'them'}: its rules ` - + `only AND, so \`$or\` and \`$not\` cannot be written as rules at all, and \`$and\` only as the ` - + `separate rules it joins. Nothing rewrites a combinator for you — flattening one changes which ` - + `rows the filter selects — so decide which rows this filter should select, and write the rules ` - + `that select exactly those.`; + + `and the rule array spells a combinator only as its own elements: they AND, so \`$and\` is ` + + `the separate rules it joins, \`$or\` over flat rules is one any-of group element ` + + `\`{ or: [{ field, operator, value }, ...] }\` (declared, but no console applies it yet, so it ` + + `does not narrow the rows today), and \`$not\` cannot be written at all. Nothing rewrites a ` + + `combinator for you — flattening one changes which rows the filter selects — so decide which ` + + `rows this filter should select, and write the rules that select exactly those.`; return ( `\`filter\` on ${surface} takes the ViewFilterRule ARRAY form \`${ruleForm}\`, ` diff --git a/packages/spec/src/ui/page.test.ts b/packages/spec/src/ui/page.test.ts index 798327fbe5e..b2c36424030 100644 --- a/packages/spec/src/ui/page.test.ts +++ b/packages/spec/src/ui/page.test.ts @@ -726,7 +726,9 @@ describe('ElementDataSourceSchema `filter` — one filter orthography platform-w filter: [{ field: 'status', operator: 'ne', value: 'done' }], }); expect(legacy.success).toBe(true); - expect(legacy.data!.filter![0].operator).toBe('not_equals'); + // [#28020] An element is a rule OR an any-of group, so the folded rule is + // asserted whole rather than read off the element union. + expect(legacy.data!.filter![0]).toEqual({ field: 'status', operator: 'not_equals', value: 'done' }); // `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({ diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index 12d1dfbfa36..1b8eacc1520 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -12,7 +12,7 @@ import { UserActionsConfigSchema, AppearanceConfigSchema, UserFiltersSchema, - ViewFilterRuleSchema, + ViewFilterElementSchema, AddRecordConfigSchema, ListColumnSchema, EmptyStateSchema, @@ -327,7 +327,7 @@ export const ElementDataSourceSchema = lazySchema(() => strictObject({ * 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, { + filter: z.array(ViewFilterElementSchema, { error: ruleArrayFilterError({ surface: 'this element data source', migration: 'element-data-source-and-object-block-filter-rule-array', @@ -872,7 +872,7 @@ export const PageRecordBindingSchema = lazySchema(() => strictObject({ }, { object: z.string().describe('Object the page row is read from'), view: z.string().optional().describe('Named view to apply'), - filter: z.array(ViewFilterRuleSchema).optional() + filter: z.array(ViewFilterElementSchema).optional() .describe('Filter criteria — the ViewFilterRule array form `[{ field, operator, value }, ...]`, AND-combined with the filter of the named view'), sort: z.array(SortItemSchema).optional().describe('Sort order — the page row is the FIRST row of the bound set under it'), })); @@ -1055,7 +1055,7 @@ export const InterfacePageConfigSchema = lazySchema(() => strictObject({ .describe('Columns shown by the page. Blank = all object fields. Defined directly on the page (no view inheritance).'), sort: z.array(SortItemSchema).optional() .describe('Default sort order for the page, defined directly on the page.'), - filterBy: z.array(ViewFilterRuleSchema).optional().describe('Always-on page filter (base filter).'), + filterBy: z.array(ViewFilterElementSchema).optional().describe('Always-on page filter (base filter).'), levels: z.number().int().min(1).optional().describe('Number of hierarchy levels to display'), /** @deprecated Back-compat only. Pre-revision pages inherited columns/filter/sort diff --git a/packages/spec/src/ui/view-filter-or-group.test.ts b/packages/spec/src/ui/view-filter-or-group.test.ts new file mode 100644 index 00000000000..61d1aef5b2c --- /dev/null +++ b/packages/spec/src/ui/view-filter-or-group.test.ts @@ -0,0 +1,255 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#28020] A view filter list can hold an any-of group: `{ or: [rule, ...] }`. + * + * The list stays an AND list of flat `ViewFilterRule`s; one new element kind + * sits beside the rule and holds a non-empty list of flat rules of which ANY + * may match. These pins hold the four halves of that contract: + * + * 1. a group is ACCEPTED on every carrier of the filter list; + * 2. every filter stored before the group parses exactly as it did — same + * output for an accepted rule, same issues (code, path, message) for a + * refused one, because the element union reports only the claimed branch; + * 3. the rule's own refinements apply INSIDE a group exactly as at top level; + * 4. an empty group, a nested group and a non-rule member are REFUSED, each + * with its own code at its own path. + * + * Every rejection pin asserts the issue code and path, and the message's + * leading words where the wording is the contract (a prescription an author + * acts on), never a bare throw. + */ + +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import { + ListViewSchema, + ViewFilterOrGroupSchema, + ViewFilterElementSchema, + ViewFilterRuleSchema, + ViewMetadataSchema, + ViewTabSchema, + isViewFilterOrGroup, +} from './view.zod'; +import { ElementDataSourceSchema, InterfacePageConfigSchema } from './page.zod'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const SPEC_SRC = resolve(HERE, '..'); + +type Issue = { code: string; path: PropertyKey[]; message: string }; + +function issuesOf(result: { success: boolean; error?: { issues: unknown[] } }): Issue[] { + expect(result.success).toBe(false); + return (result.error!.issues as Issue[]).map(({ code, path, message }) => ({ code, path, message })); +} + +const listView = (filter: unknown) => ({ type: 'grid', columns: ['name'], filter }); + +/** "open, or completed in the last 7 days" — the view the card names. */ +const OPEN_OR_RECENT = { + or: [ + { field: 'status', operator: 'equals', value: 'open' }, + { field: 'completed_at', operator: 'after', value: '{7_days_ago}' }, + ], +}; + +describe('a group is accepted on every carrier of the filter list', () => { + const carriers: Array<[string, (filter: unknown) => { success: boolean; data?: unknown }]> = [ + ['ListView.filter', (f) => ListViewSchema.safeParse(listView(f))], + ['ViewTab.filter', (f) => ViewTabSchema.safeParse({ name: 'recent', filter: f })], + ['ElementDataSource.filter', (f) => ElementDataSourceSchema.safeParse({ object: 'task', filter: f })], + ['InterfacePageConfig.filterBy', (f) => InterfacePageConfigSchema.safeParse({ source: 'task', filterBy: f })], + ]; + + it.each(carriers)('%s takes a group beside a plain rule', (_name, parse) => { + const filter = [OPEN_OR_RECENT, { field: 'owner', operator: 'equals', value: '{current_user_id}' }]; + const result = parse(filter); + expect(result.success).toBe(true); + }); + + it('the group parses to itself — nothing added, dropped or reordered', () => { + const parsed = ListViewSchema.parse(listView([OPEN_OR_RECENT])); + expect(parsed.filter).toEqual([OPEN_OR_RECENT]); + }); + + it('every filter-list carrier in the spec holds the ELEMENT, not the bare rule', () => { + // A source scan: a carrier added later with the bare rule array would take + // no group. Two bare-rule arrays are expected and named: the group's own + // members (one level only) and the solution-blueprint view filters, which + // deliberately stay flat (their docblock says why). + const files: string[] = []; + const walk = (dir: string): void => { + for (const entry of readdirSync(dir)) { + const path = join(dir, entry); + if (statSync(path).isDirectory()) walk(path); + else if (entry.endsWith('.zod.ts')) files.push(path); + } + }; + walk(SPEC_SRC); + const bare: string[] = []; + for (const file of files) { + readFileSync(file, 'utf8').split('\n').forEach((line) => { + const code = line.trim(); + if (code.startsWith('*') || code.startsWith('//')) return; + if (code.includes('z.array(ViewFilterRuleSchema')) bare.push(`${file.slice(SPEC_SRC.length + 1)}:${code.split(':')[0]}`); + }); + } + expect(bare.sort()).toEqual(['ai/solution-blueprint.zod.ts:filters', 'ui/view.zod.ts:or']); + }); +}); + +describe('a filter stored before the group parses exactly as it did', () => { + // Accepted rules, alias spellings among them: the element parses each to + // what the rule alone parses it to. + const accepted: unknown[] = [ + { field: 'status', operator: 'equals', value: 'active' }, + { field: 'stage', operator: 'notIn', value: ['won', 'lost'] }, + { field: 'amount', operator: 'gte', value: 100 }, + { field: 'close_date', operator: 'between', value: ['2024-01-01', '2024-12-31'] }, + { field: 'archived_at', operator: 'isNull' }, + { field: 'name', operator: 'icontains', value: 'acme' }, + ]; + + it.each(accepted)('accepts %j with the rule\'s own output', (rule) => { + expect(ViewFilterElementSchema.parse(rule)).toEqual(ViewFilterRuleSchema.parse(rule)); + }); + + // Refused rules: the element reports the RULE's issues, flat — never an + // `invalid_union` with the group branch's complaint beside them. + const refused: unknown[] = [ + { field: 'stage', operator: 'in', value: 'won' }, + { field: 'name', operator: 'icontains', value: '' }, + { field: 'status', operator: 'equals' }, + { field: 'status', operator: 'nope', value: 'x' }, + { field: 'status', operator: 'equals', value: 'x', id: 'row-1' }, + { operator: 'equals', value: 'x' }, + ]; + + it.each(refused)('refuses %j with the rule\'s own issues', (rule) => { + const asElement = issuesOf(ViewFilterElementSchema.safeParse(rule)); + const asRule = issuesOf(ViewFilterRuleSchema.safeParse(rule)); + expect(asElement).toEqual(asRule); + expect(asElement.some((issue) => issue.code === 'invalid_union')).toBe(false); + }); + + it('a refused rule is located at its own path through a carrier', () => { + const issues = issuesOf(ListViewSchema.safeParse(listView([{ field: 'stage', operator: 'in', value: 'won' }]))); + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe('custom'); + expect(issues[0]!.path).toEqual(['filter', 0, 'value']); + expect(issues[0]!.message.startsWith('Operator "in" on field "stage" requires an ARRAY of values.')).toBe(true); + }); +}); + +describe('the rule\'s refinements apply inside a group exactly as at top level', () => { + it('the value shape is coupled to the operator', () => { + const issues = issuesOf(ListViewSchema.safeParse(listView([{ or: [{ field: 'stage', operator: 'in', value: 'won' }] }]))); + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe('custom'); + expect(issues[0]!.path).toEqual(['filter', 0, 'or', 0, 'value']); + expect(issues[0]!.message.startsWith('Operator "in" on field "stage" requires an ARRAY of values.')).toBe(true); + }); + + it('the `icontains` comparand is judged', () => { + const issues = issuesOf(ListViewSchema.safeParse(listView([{ or: [{ field: 'name', operator: 'icontains', value: '' }] }]))); + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe('custom'); + expect(issues[0]!.path).toEqual(['filter', 0, 'or', 0, 'value']); + }); + + it('legacy operator spellings fold to canonical inside a group too', () => { + const parsed = ListViewSchema.parse(listView([{ or: [{ field: 'amount', operator: 'gt', value: 1 }] }])); + expect(parsed.filter).toEqual([{ or: [{ field: 'amount', operator: 'greater_than', value: 1 }] }]); + }); +}); + +describe('what a group refuses', () => { + it('an EMPTY group — it would match no record', () => { + const issues = issuesOf(ListViewSchema.safeParse(listView([{ or: [] }]))); + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe('too_small'); + expect(issues[0]!.path).toEqual(['filter', 0, 'or']); + expect(issues[0]!.message.startsWith('An any-of group needs at least one rule')).toBe(true); + }); + + it('a NESTED group — one level only, refused by name', () => { + const nested = { or: [{ or: [{ field: 'status', operator: 'equals', value: 'open' }] }] }; + const issues = issuesOf(ListViewSchema.safeParse(listView([nested]))); + const unknown = issues.find((issue) => issue.code === 'unrecognized_keys'); + expect(unknown?.path).toEqual(['filter', 0, 'or', 0]); + expect(unknown?.message).toContain('an any-of group cannot hold another group'); + }); + + it('a member that is not a rule', () => { + const issues = issuesOf(ListViewSchema.safeParse(listView([{ or: ['status = open'] }]))); + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe('invalid_type'); + expect(issues[0]!.path).toEqual(['filter', 0, 'or', 0]); + }); + + it('a non-array `or`', () => { + const issues = issuesOf(ListViewSchema.safeParse(listView([{ or: { field: 'status' } }]))); + expect(issues[0]!.code).toBe('invalid_type'); + expect(issues[0]!.path).toEqual(['filter', 0, 'or']); + }); + + it('a rule key written beside `or` — it belongs to a rule', () => { + const issues = issuesOf(ListViewSchema.safeParse(listView([{ ...OPEN_OR_RECENT, field: 'owner' }]))); + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe('unrecognized_keys'); + expect(issues[0]!.path).toEqual(['filter', 0]); + expect(issues[0]!.message).toContain('an any-of group holds only `or`'); + }); + + // `any` was this group's key before the maintainer's ruling renamed it to `or`; it is answered like the others. + it.each(['$or', 'any', 'anyOf', 'logic', 'conditions'])('the OR spelling `%s` on a rule is answered with the group', (key) => { + const issues = issuesOf(ListViewSchema.safeParse(listView([{ [key]: [{ field: 'status', operator: 'equals', value: 'open' }] }]))); + const unknown = issues.find((issue) => issue.code === 'unrecognized_keys'); + expect(unknown?.path).toEqual(['filter', 0]); + expect(unknown?.message).toContain('`{ or: [rule, ...] }`'); + }); +}); + +describe('the wire door and the published shape', () => { + it('the console row `id` is stripped inside a group as on a plain rule', () => { + const body = { + name: 'task.recent', object: 'task', viewKind: 'list', label: 'Recent', type: 'grid', columns: ['name'], + filter: [{ id: 'root', or: [{ id: 'r1', field: 'status', operator: 'equals', value: 'open' }] }], + }; + const parsed = ViewMetadataSchema.safeParse(body); + expect(parsed.success).toBe(true); + expect((parsed.data as { filter: unknown }).filter).toEqual([ + { or: [{ field: 'status', operator: 'equals', value: 'open' }] }, + ]); + }); + + it('…while the AUTHORING door still refuses an authored `id` on a group, by name', () => { + const issues = issuesOf(ListViewSchema.safeParse(listView([{ id: 'root', ...OPEN_OR_RECENT }]))); + expect(issues[0]!.code).toBe('unrecognized_keys'); + expect(issues[0]!.message).toContain('`id` is a console row key'); + }); + + it('the element publishes both kinds, and the group says it is not yet rendered', () => { + const json = z.toJSONSchema(ViewFilterElementSchema, { io: 'input', unrepresentable: 'any' }) as { + anyOf?: Array<{ properties?: Record }>; + }; + expect(json.anyOf?.map((branch) => Object.keys(branch.properties ?? {}).sort())).toEqual([ + ['field', 'operator', 'value'], + ['or'], + ]); + expect(ViewFilterOrGroupSchema.description?.startsWith('[PLANNED — not yet rendered]')).toBe(true); + }); + + it('`isViewFilterOrGroup` tells the element kinds apart by the own `or` key', () => { + expect(isViewFilterOrGroup(OPEN_OR_RECENT)).toBe(true); + expect(isViewFilterOrGroup({ or: 'not yet valid' })).toBe(true); + expect(isViewFilterOrGroup({ field: 'status', operator: 'equals', value: 'open' })).toBe(false); + expect(isViewFilterOrGroup(Object.create({ or: [] }))).toBe(false); + expect(isViewFilterOrGroup([OPEN_OR_RECENT])).toBe(false); + expect(isViewFilterOrGroup(null)).toBe(false); + }); +}); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 05c15dc948a..34a03f8645e 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -498,6 +498,45 @@ const VIEW_CONSOLE_ROW_ID_GUIDANCE = + 'per row for React) — it is not part of the authoring contract, and the write path ' + 'removes it before validating. Delete it from authored metadata.'; +/** + * [#28020] The one key of an any-of group element of a view filter list + * ({@link ViewFilterOrGroupSchema}). Its value is an array of builder rows, so + * the wire door strips row decorations inside it too. + */ +const VIEW_FILTER_OR_GROUP_KEY = 'or' as const; + +/** [#28020] What a rule INSIDE an any-of group is told when it carries `or` itself. */ +const VIEW_FILTER_NESTED_GROUP_GUIDANCE = + 'an any-of group cannot hold another group — its members are flat rules `{ field, operator, value }`. ' + + 'Move the inner group\'s rules into this group (any of them may match), or write the inner group as its ' + + 'own element of the filter list, where it ANDs with the others.'; + +/** + * [#28020] The spellings an author reaching for "OR" writes on a rule — the + * console builder's `{ logic, conditions }`, the Query-DSL `$or`, and the + * natural near-misses `any` / `anyOf` — each answered with the one any-of + * shape this list has. `or` itself is not here: it is the group's real key + * ({@link VIEW_FILTER_OR_GROUP_KEY}), so an element carrying it is a group, and + * a rule only meets it inside a group, as a nested group. + */ +const VIEW_FILTER_OR_SPELLING: string = + 'a view filter writes "any of" as `{ or: [rule, ...] }`, its own element of the filter list, whose ' + + 'flat rules `{ field, operator, value }` match when ANY of them does; the list still ANDs its elements. ' + + 'There is no `$or`, `any`, `anyOf`, `logic` or `conditions` key on a filter rule.'; +const VIEW_FILTER_OR_SPELLING_GUIDANCE: Readonly> = { + $or: VIEW_FILTER_OR_SPELLING, + any: VIEW_FILTER_OR_SPELLING, + anyOf: VIEW_FILTER_OR_SPELLING, + logic: VIEW_FILTER_OR_SPELLING, + conditions: VIEW_FILTER_OR_SPELLING, +}; + +/** [#28020] What an any-of group is told when it carries a rule's own key. */ +const VIEW_FILTER_GROUP_STRAY_RULE_KEY_GUIDANCE = + 'an any-of group holds only `or`, the array of its rules — a rule key beside it belongs to a rule. Put ' + + 'the rule inside `or` (any of the group\'s rules may match), or write it as its own element of the ' + + 'filter list, where it ANDs with the group.'; + function stripRowDecorations(value: unknown, isRow: boolean, depth: number): unknown { if (depth > VIEW_DECORATION_MAX_DEPTH || !value || typeof value !== 'object') return value; @@ -524,7 +563,12 @@ function stripRowDecorations(value: unknown, isRow: boolean, depth: number): unk } for (const [k, v] of Object.entries(next ?? dict)) { - const out = stripRowDecorations(v, VIEW_DECORATED_ROW_CARRIERS.includes(k), depth + 1); + // [#28020] The rules of an any-of group are builder rows of the same + // filter, one level down: `or` carries rows only on a row (a group + // element of `filter[]`), so a key spelled `or` anywhere else in a view + // body is left alone. + const carriesRows = VIEW_DECORATED_ROW_CARRIERS.includes(k) || (isRow && k === VIEW_FILTER_OR_GROUP_KEY); + const out = stripRowDecorations(v, carriesRows, depth + 1); if (out !== v) { next ??= { ...dict }; next[k] = out; @@ -1005,6 +1049,12 @@ export const ViewFilterRuleSchema = lazySchema(() => strictObject({ history: VIEW_HISTORY, guidance: { id: VIEW_CONSOLE_ROW_ID_GUIDANCE, + // [#28020] A rule is reached two ways: as a plain element of a filter list, + // and as a member of an any-of group. An element carrying `or` is routed + // to the group before it gets here, so this key only ever arrives on a + // rule INSIDE a group — a nested group, which the one-level shape refuses. + [VIEW_FILTER_OR_GROUP_KEY]: VIEW_FILTER_NESTED_GROUP_GUIDANCE, + ...VIEW_FILTER_OR_SPELLING_GUIDANCE, }, }, { /** Field name to filter on */ @@ -1050,6 +1100,210 @@ export type ViewFilterRule = z.input; /** Post-parse shape of {@link ViewFilterRule} — defaults applied, transforms run (ADR-0122). */ export type ViewFilterRuleParsed = z.infer; +/** + * [#28020] True when `value` is an any-of group element of a view filter list + * ({@link ViewFilterOrGroupSchema}): a plain object carrying its own + * `or` key. + * + * It answers WHICH element kind a value is, not whether it is a valid one: the + * schema routes every element this predicate claims to the group shape, which + * then judges it (a non-array `or`, an empty one, a stray key). Exported for + * the readers that walk a stored filter list and must step INTO a group rather + * than misread it as a rule or a field-keyed condition — the lint filter walks + * and the view reference diagnostics. ⛔ Never restate the test at a reader: + * this predicate is the one place the element kinds are told apart. + */ +export function isViewFilterOrGroup(value: unknown): value is { [VIEW_FILTER_OR_GROUP_KEY]: unknown } { + return typeof value === 'object' && value !== null && !Array.isArray(value) + && Object.prototype.hasOwnProperty.call(value, VIEW_FILTER_OR_GROUP_KEY); +} + +/** + * [#28020] View Filter OR Group Schema — one element of a view filter list + * that holds flat rules of which ANY may match. + * + * ## The stored shape, and why it is this one + * + * A view filter list (`ListView.filter` and every other carrier of + * {@link ViewFilterElementSchema}) is an AND list. Until this element, that was + * all it could say: an "open, or completed in the last 7 days" view had no + * spelling, although the console's filter builder already offers an OR toggle + * (it refuses to save one, `viewFilterFold.ts`'s `or_logic`). This element is + * the one way to say "any of": it sits in the list beside the plain rules, the + * list still ANDs its elements, and the group matches a record when at least + * one of its rules does. + * + * - **A member of the rule list, not a second filter vocabulary.** + * `FilterConditionSchema`'s `$or` is the Query-DSL dialect, and the seven + * `filter` doors of the objectui#6206 family converged away from it onto the + * rule array (`filter-rule-array.ts`); admitting it here would put two + * shapes in storage and make every reader accept both (PD#12). The group's + * members ARE {@link ViewFilterRuleSchema}, so the operator vocabulary, the + * alias fold, the value-shape coupling and the `icontains` comparand check + * apply inside a group exactly as at the top level — by construction, not by + * a second copy. + * - **One level.** A group holds flat rules only; a group inside a group is + * refused by name. The console builder edits one logic over flat rows and has + * no control that creates a sub-group (`FilterBuilderRowGroup`), so nothing + * it can produce needs more. + * - **`or`, not the builder's `{ logic, conditions }`.** The builder's group + * is its whole editing state, and its `logic: 'and'` arm would be a second + * spelling of the AND the list already is, leaving `logic` a key with one + * legal value. One key named for its meaning is the smaller contract. The + * key is `or` by the maintainer's ruling on #28020: it matches the and/or + * wording of Notion, monday.com and Dataverse filters, and a nested level + * added later would pair with `and`. The builder's spellings, `$or`, `any` + * and `anyOf` are answered with this shape when written on a rule + * ({@link VIEW_FILTER_OR_SPELLING_GUIDANCE}). + * - **Not empty.** An any-of over zero rules matches no record — a view that is + * permanently empty — so `{ or: [] }` is refused rather than stored. + * + * Every filter stored before this element still parses, byte for byte: a plain + * rule never carries `or`, so it is routed to {@link ViewFilterRuleSchema} + * exactly as before. + * + * ## [PLANNED — not yet rendered] + * + * No reader applies the group yet. The query a list view runs is built by the + * console (`ui/packages/core/src/utils/filter-converter.ts#toFilterNode`), which + * lowers flat rules only and passes a group through untouched, so the server + * refuses the query as a malformed filter (400 `INVALID_FILTER`) and the list + * shows that error and no rows. It fails closed: a stored group never widens + * what a view, a related list or a picker shows. The describe below says so, + * so no author is told it works. + * The console half is #28370; the marker leaves when it lands. + * + * @example + * ```ts + * // status is "open", OR completed within the last 7 days — and owned by me + * filter: [ + * { or: [ + * { field: 'status', operator: 'equals', value: 'open' }, + * { field: 'completed_at', operator: 'after', value: '{7_days_ago}' }, + * ] }, + * { field: 'owner', operator: 'equals', value: '{current_user_id}' }, + * ] + * ``` + */ +export const ViewFilterOrGroupSchema = lazySchema(() => strictObject({ + surface: 'this any-of filter group', + history: VIEW_HISTORY, + guidance: { + // The console's group carries a React key too; the wire door removes it + // from a group element as from a rule (`stripViewConsoleDecorations`). + id: VIEW_CONSOLE_ROW_ID_GUIDANCE, + field: VIEW_FILTER_GROUP_STRAY_RULE_KEY_GUIDANCE, + operator: VIEW_FILTER_GROUP_STRAY_RULE_KEY_GUIDANCE, + value: VIEW_FILTER_GROUP_STRAY_RULE_KEY_GUIDANCE, + }, +}, { + /** + * The rules of which ANY may match — at least one, each a flat + * {@link ViewFilterRuleSchema}. + */ + or: z.array(ViewFilterRuleSchema) + .min(1, { + error: () => + 'An any-of group needs at least one rule: `{ or: [] }` is a disjunction of zero rules, which ' + + 'matches no record, so the view would be permanently empty. Write the rules of which any may ' + + 'match, or delete the group.', + }) + .describe( + 'The rules of which ANY may match: a non-empty array of flat filter rules `{ field, operator, value }`, ' + + 'each judged exactly as a rule at the top level of the filter. A group cannot hold a group.', + ), +}).describe( + '[PLANNED — not yet rendered] An any-of group: `{ or: [rule, ...] }` matches a record when ANY of its ' + + 'rules matches. It sits in the filter list beside plain rules, and the list still ANDs every element, ' + + 'so `[{ or: [{ field: "status", operator: "equals", value: "open" }, { field: "completed_at", ' + + 'operator: "after", value: "{7_days_ago}" }] }]` selects the open records and the ones completed in the ' + + 'last 7 days. One level only: its members are flat rules. No console reads it yet: until the list ' + + 'query build reads the group, a view that stores one is refused when it loads (the list reports a ' + + 'malformed filter and shows no rows).', +)); + +export type ViewFilterOrGroup = z.input; +/** Post-parse shape of {@link ViewFilterOrGroup} — defaults applied, transforms run (ADR-0122). */ +export type ViewFilterOrGroupParsed = z.infer; + +/** + * [#28020] Report ONE element kind's verdict, never both. + * + * {@link ViewFilterElementSchema} is a union, and a union that fails reports + * every branch: `invalid_union`, with the per-branch truth nested in `errors[]` + * at paths relative to the element. On this union that would bury the rule's + * own refusals — a value-shape issue at `filter.0.value`, an unknown key named + * with its guidance — one level down, beside a group branch's complaint about + * a missing `or` that the author never meant to write. (zod returns a branch's + * issues unwrapped only when exactly one branch failed non-fatally, which an + * unknown key or a bad operator never is.) + * + * The element kind is not a guess: {@link isViewFilterOrGroup} decides it, so + * the claimed branch's issues replace the union's own issue, flat, and every + * path and message an author read before the group existed is the one they + * read now. The same shape {@link focusClaimedBranch} takes on + * `ViewMetadataSchema`, applied where the branch decision is a single key. + * + * It changes no verdict: it only runs when the union already failed, adds no + * issue of its own, and removes none from the claimed branch. + */ +function reportClaimedFilterElementBranch(): z.core.$ZodCheck { + const check = new z.core.$ZodCheck({ check: 'custom', when: () => true }) as z.core.$ZodCheck; + check._zod.check = (payload) => { + for (let index = 0; index < payload.issues.length; index += 1) { + const issue = payload.issues[index] as { + code?: string; + path?: readonly PropertyKey[]; + errors?: readonly z.core.$ZodIssue[][]; + }; + if (issue?.code !== 'invalid_union' || !Array.isArray(issue.errors)) continue; + // Only THIS union's own issue: raised at the element itself, one error + // list per branch. A nested union's issue carries a path. + if ((issue.path?.length ?? 0) !== 0 || issue.errors.length !== 2) continue; + const claimed = issue.errors[isViewFilterOrGroup(payload.value) ? 1 : 0] ?? []; + payload.issues.splice(index, 1, ...(claimed as unknown as typeof payload.issues)); + return; + } + }; + return check; +} + +/** + * [#28020] One element of a view filter list: a flat + * {@link ViewFilterRuleSchema}, or an any-of {@link ViewFilterOrGroupSchema}. + * + * This is what every filter-list carrier holds — `ListView.filter`, a view + * tab's filter, `Page.filterBy`, an element data source's filter, the + * related-list and picker filters and the component filter doors — so the + * group reaches all of them at once and none can drift. The list ANDs its + * elements; a group ORs its rules. + * + * Which branch judges an element is decided by {@link isViewFilterOrGroup} + * (an own `or` key), and only that branch's issues are reported — see + * {@link reportClaimedFilterElementBranch}. A stored filter of plain rules is + * therefore judged, and refused, exactly as before. + * + * @example + * ```ts + * filter: [ + * { or: [ + * { field: 'status', operator: 'equals', value: 'open' }, + * { field: 'completed_at', operator: 'after', value: '{7_days_ago}' }, + * ] }, + * { field: 'archived_at', operator: 'is_empty' }, + * ] + * ``` + */ +export const ViewFilterElementSchema = lazySchema(() => + z.union([ViewFilterRuleSchema, ViewFilterOrGroupSchema]) + .check(reportClaimedFilterElementBranch()) + .describe('One view filter element: a flat rule, or an any-of group of flat rules'), +); + +export type ViewFilterElement = z.input; +/** Post-parse shape of {@link ViewFilterElement} — defaults applied, transforms run (ADR-0122). */ +export type ViewFilterElementParsed = z.infer; + /** * Column Summary Function Schema * Aggregation function for column footer (Airtable-style column summaries) @@ -1698,7 +1952,7 @@ export const ViewTabSchema = lazySchema(() => strictObject({ label: I18nLabelSchema.optional().describe('Display label').meta({ title: 'Label' }), icon: z.string().optional().describe('Tab icon name').meta({ title: 'Icon' }), view: z.string().optional().describe('Referenced list view name from listViews').meta({ title: 'List View' }), - filter: z.array(ViewFilterRuleSchema).optional().describe('Tab-specific filter criteria').meta({ title: 'Filter' }), + filter: z.array(ViewFilterElementSchema).optional().describe('Tab-specific filter criteria').meta({ title: 'Filter' }), order: z.number().int().min(0).optional().describe('Tab display order').meta({ title: 'Display Order' }), pinned: z.boolean().default(false).describe('Pin tab (cannot be removed by users)').meta({ title: 'Pinned' }), isDefault: z.boolean().default(false).describe('Set as the default active tab').meta({ title: 'Default Tab' }), @@ -3067,7 +3321,7 @@ const ListViewShapeSchema = lazySchema(() => strictObject({ + 'no projection, so neither of them applies: which columns show is then left to the ' + 'renderer (objectui\'s `ListView` grid derives the object\'s default columns).', ), - filter: z.array(ViewFilterRuleSchema).optional().describe('Filter criteria (JSON Rules)'), + filter: z.array(ViewFilterElementSchema).optional().describe('Filter criteria (JSON Rules)'), /** * Sort order — the structured `{ field, order }[]` array, and only that. * diff --git a/ui/packages/types/src/__tests__/gantt-flat-config-declared-keys.test.ts b/ui/packages/types/src/__tests__/gantt-flat-config-declared-keys.test.ts index 06a475e03fb..6a9009a7d3d 100644 --- a/ui/packages/types/src/__tests__/gantt-flat-config-declared-keys.test.ts +++ b/ui/packages/types/src/__tests__/gantt-flat-config-declared-keys.test.ts @@ -113,7 +113,7 @@ import { describe, it, expect } from 'vitest'; import { ObjectGanttSchema } from '../zod/objectql.zod.js'; import { StrictAnyComponentSchema } from '../zod/index.zod'; import type { GanttConfig, ObjectGanttSchema as ObjectGanttSchemaTS, SortConfig } from '../objectql.js'; -import type { ViewFilterRule } from '@objectstack/spec/ui'; +import type { ViewFilterElement } from '@objectstack/spec/ui'; const MINIMAL = { type: 'object-gantt', @@ -381,5 +381,6 @@ type Expect = T; // `object-gantt` row's `ViewFilterRule` array since objectui#6152 round 10 (was `any[]`). // eslint-disable-next-line @typescript-eslint/no-explicit-any export type _StaticDataIsDeclared = Expect>; -export type _FilterIsDeclared = Expect>; +// objectstack#28020: the row's array element is a flat rule OR an any-of group. +export type _FilterIsDeclared = Expect>; export type _SortIsDeclared = Expect>; diff --git a/ui/packages/types/src/__tests__/grid-default-filters-gantt-map-filter-round10-6152.test.ts b/ui/packages/types/src/__tests__/grid-default-filters-gantt-map-filter-round10-6152.test.ts index f81252b027e..814c4326bf0 100644 --- a/ui/packages/types/src/__tests__/grid-default-filters-gantt-map-filter-round10-6152.test.ts +++ b/ui/packages/types/src/__tests__/grid-default-filters-gantt-map-filter-round10-6152.test.ts @@ -42,6 +42,7 @@ import type { ObjectGanttProps as SpecObjectGanttProps, ObjectGridProps as SpecObjectGridProps, ObjectMapProps as SpecObjectMapProps, + ViewFilterElement, ViewFilterRule, } from '@objectstack/spec/ui'; import type { @@ -259,8 +260,9 @@ export type assertionRound10MembersAreTheRows = [ Expect['defaultFilters'], SpecObjectGridProps['defaultFilters']>>, Expect>, Expect>, - Expect>, - Expect>, + // objectstack#28020: the element is a flat rule OR an any-of group (`ViewFilterElement`). + Expect>, + Expect>, // Lit control: round 8's grid `filter` is unchanged. Expect>, ]; diff --git a/ui/packages/types/src/__tests__/grid-kanban-calendar-members-round8-6152.test.ts b/ui/packages/types/src/__tests__/grid-kanban-calendar-members-round8-6152.test.ts index c8d1dec460a..acd478e754f 100644 --- a/ui/packages/types/src/__tests__/grid-kanban-calendar-members-round8-6152.test.ts +++ b/ui/packages/types/src/__tests__/grid-kanban-calendar-members-round8-6152.test.ts @@ -42,6 +42,7 @@ import type { ObjectCalendarProps as SpecObjectCalendarProps, ObjectGridProps as SpecObjectGridProps, ObjectKanbanProps as SpecObjectKanbanProps, + ViewFilterElement, ViewFilterRule, } from '@objectstack/spec/ui'; import type { @@ -278,7 +279,8 @@ export type assertionRound8MembersAreTheRows = [ Expect>, Expect>, Expect>, - Expect>, + // objectstack#28020: the element is a flat rule OR an any-of group (`ViewFilterElement`). + Expect>, Expect>, Expect>, Expect>, diff --git a/ui/packages/types/src/__tests__/kanban-calendar-filter-sort-8174.test.ts b/ui/packages/types/src/__tests__/kanban-calendar-filter-sort-8174.test.ts index 14acb7c23a3..71aefb35892 100644 --- a/ui/packages/types/src/__tests__/kanban-calendar-filter-sort-8174.test.ts +++ b/ui/packages/types/src/__tests__/kanban-calendar-filter-sort-8174.test.ts @@ -77,7 +77,7 @@ import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { ComponentPropsMap } from '@objectstack/spec/ui'; -import type { ViewFilterRule } from '@objectstack/spec/ui'; +import type { ViewFilterElement, ViewFilterRule } from '@objectstack/spec/ui'; import { ObjectCalendarSchema, ObjectKanbanSchema, safeValidateSchema } from '../zod/index.zod'; import type { @@ -134,10 +134,11 @@ type IsOptional = Record extends Pick // either member made the indexed access fall back to `[key: string]: any`, // making `IsAny` true and the `Equal` row false; since objectui#8347 the // indexed access stops compiling instead. -export type _KanbanFilterIsArray = Expect>; +// objectstack#28020: the array element is a flat rule OR an any-of group. +export type _KanbanFilterIsArray = Expect>; export type _KanbanFilterIsNotAny = Expect, false>>; export type _KanbanFilterIsOptional = Expect>; -export type _CalendarFilterIsArray = Expect>; +export type _CalendarFilterIsArray = Expect>; export type _CalendarFilterIsNotAny = Expect, false>>; export type _CalendarFilterIsOptional = Expect>; // `sort` on the CALENDAR only: declared `SortConfig[]`, optional, not `any`. diff --git a/ui/packages/types/src/__tests__/related-list-add-picker-filter-mirror-9964.test.ts b/ui/packages/types/src/__tests__/related-list-add-picker-filter-mirror-9964.test.ts index 7aee35782e6..e8b7b5bbdda 100644 --- a/ui/packages/types/src/__tests__/related-list-add-picker-filter-mirror-9964.test.ts +++ b/ui/packages/types/src/__tests__/related-list-add-picker-filter-mirror-9964.test.ts @@ -64,7 +64,7 @@ import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import type { z } from 'zod'; import { RecordRelatedListProps } from '@objectstack/spec/ui'; -import type { ViewFilterRule } from '@objectstack/spec/ui'; +import type { ViewFilterOrGroup, ViewFilterRule } from '@objectstack/spec/ui'; import type { RecordRelatedListComponentProps } from '../record-components'; /* ── Type-level legs (compiled by `tsc -p tsconfig.test.json`, erased by vitest) ── */ @@ -94,7 +94,19 @@ type SpecPickerFilter = NonNullable['add' * ⭐ The card, in type form: the two declarations of one key agree, and the * expectation comes from the protocol rather than from this file. */ -export type _MirrorAgreesWithTheContract = Expect>; +/** + * objectstack#28020: the contract's element is now a flat rule OR an any-of + * group (`{ or: [rule, ...] }`), and the group is `[PLANNED — not yet + * rendered]` there: the related list's own reader (`@object-ui/plugin-detail`) + * types and lowers flat rules only. So this face carries the contract MINUS + * the group until the console reads it (objectstack#28370), and the two legs + * below hold exactly that gap — widen this face and the second leg's + * companion together, never one alone. + */ +type FlatRulesOf = Exclude[number], ViewFilterOrGroup>[] | undefined; +export type _MirrorAgreesWithTheContract = Expect>>; +/** …and the contract does carry the group the face withholds — so the gap is the group and nothing else. */ +export type _TheContractAlsoTakesTheGroup = Expect[number], ViewFilterRule | ViewFilterOrGroup>>; /** * …and the shape they agree on is the rule ARRAY. Stated separately from the diff --git a/ui/packages/types/src/__tests__/related-list-filter-rule-array-10199.test.ts b/ui/packages/types/src/__tests__/related-list-filter-rule-array-10199.test.ts index ee509dae557..fe8c74cc7f1 100644 --- a/ui/packages/types/src/__tests__/related-list-filter-rule-array-10199.test.ts +++ b/ui/packages/types/src/__tests__/related-list-filter-rule-array-10199.test.ts @@ -67,7 +67,7 @@ import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import type { z } from 'zod'; import { RecordRelatedListProps } from '@objectstack/spec/ui'; -import type { ViewFilterRule } from '@objectstack/spec/ui'; +import type { ViewFilterOrGroup, ViewFilterRule } from '@objectstack/spec/ui'; import type { RecordRelatedListComponentProps } from '../record-components'; /* ── Type-level legs (compiled by `tsc -p tsconfig.test.json`, erased by vitest) ── */ @@ -94,7 +94,19 @@ type MirrorFilter = RecordRelatedListComponentProps['filter']; type SpecFilter = z.input['filter']; /** ⭐ The ruling, in type form: the authoring face carries the protocol's declaration. */ -export type _MirrorAgreesWithTheContract = Expect>; +/** + * objectstack#28020: the contract's element is now a flat rule OR an any-of + * group (`{ or: [rule, ...] }`), and the group is `[PLANNED — not yet + * rendered]` there: the related list's own reader (`@object-ui/plugin-detail`) + * types and lowers flat rules only. So this face carries the contract MINUS + * the group until the console reads it (objectstack#28370), and the two legs + * below hold exactly that gap — widen this face and the second leg's + * companion together, never one alone. + */ +type FlatRulesOf = Exclude[number], ViewFilterOrGroup>[] | undefined; +export type _MirrorAgreesWithTheContract = Expect>>; +/** …and the contract does carry the group the face withholds — so the gap is the group and nothing else. */ +export type _TheContractAlsoTakesTheGroup = Expect[number], ViewFilterRule | ViewFilterOrGroup>>; /** * …and that declaration is the rule ARRAY. Stated separately so a spec that