diff --git a/.changeset/15184-list-view-field-order-composition.md b/.changeset/15184-list-view-field-order-composition.md new file mode 100644 index 00000000000..6074260b69c --- /dev/null +++ b/.changeset/15184-list-view-field-order-composition.md @@ -0,0 +1,19 @@ +--- +'@objectstack/spec': patch +--- + +`ListViewSchema` declares the `columns` x `hiddenFields` x `fieldOrder` composition instead of leaving it to be inferred from a renderer (#15184) + +The three keys that together build a list view's field list now say how they compose, in their own `.describe()` text — the string that ships into `json-schema/` and into the generated `content/docs/references/ui/view.mdx`: + +- `columns` is the **projection**: the candidate set and the baseline order, and neither other key can add a field it omits. An **empty** `columns` declares no projection, so neither other key applies: which columns show is left to the renderer (objectui's `ListView` grid derives the object's default columns). +- `hiddenFields` **subtracts** from that projection, before any ordering runs. A name `columns` never projected subtracts nothing. +- `fieldOrder` **orders what survives** and never adds a field. A surviving column absent from `fieldOrder` sorts **last**, after every listed one, keeping its `columns`-relative order; a name listed there that did not survive orders nothing. + +**Why this is a declaration and not a precedence rule.** `fieldOrder` was proposed for retirement as a second spelling of `columns` with no contract deciding who wins. They never compete: one selects, the other sorts. Maintainer decision batch #115 (2026-09-11) kept the key and ruled the composition into the contract, which is what this change lands. + +⛔ **No accept set moves.** No key is added, removed, narrowed or widened; no parse verdict changes; the four `@objectstack/lint` list-view validators are untouched. What changes is the published description of three keys that were already there, plus one ledger row's evidence. + +**`packages/spec/liveness/view.json` — the `/props/list/children/fieldOrder` row is re-cited**, `verifiedAt: 2026-09-21`. The ledger ships in this package's `files[]`, so the pointers an upgrading reader follows are these, and both halves of the 2026-08-10 citation had rotted: its first path (`objectui packages/react/src/spec-bridge/bridges/list-view.ts`) no longer exists, and its second had drifted in range through three sets of line numbers. The row now anchors both pointers on symbols, splits the relay rung out into `producer`, and names the measurement it was taken at. + +The declaration and the accept set are held together by `packages/spec/src/ui/view-field-order-composition.pin.test.ts`: it reads the three descriptions off the live schema and parses a document carrying all three keys through the page-list, object-views, `defineView` and registered-metadata doors, asserting the arrays come back verbatim — the spec declares the composition, it does not perform it. diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 35e6fca36fa..9695b71653a 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1664,7 +1664,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { 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 | +| **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) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | @@ -1688,8 +1688,8 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | | **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) | | **rowColor** | `{ field: string; colors?: Record }` | optional | Color rows based on field value | -| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view | -| **fieldOrder** | `string[]` | optional | Explicit field display order for this view | +| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view — the SUBTRACTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: each name listed here is removed from the `columns` projection before `fieldOrder` orders the remainder. A name `columns` never projected subtracts nothing. | +| **fieldOrder** | `string[]` | optional | Explicit field display order for this view — the ORDERING of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: it sorts what survives `columns` minus `hiddenFields` and never adds a field. A surviving column absent from `fieldOrder` sorts LAST, after every listed one, keeping its `columns`-relative order; a name listed here that did not survive orders nothing. | | **rowActions** | `string[]` | optional | Actions available for individual row items | | **bulkActions** | `string[]` | optional | Actions available when multiple rows are selected | | **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time. Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | @@ -1749,7 +1749,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { 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 | +| **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) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | @@ -1773,8 +1773,8 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | | **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) | | **rowColor** | `{ field: string; colors?: Record }` | optional | Color rows based on field value | -| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view | -| **fieldOrder** | `string[]` | optional | Explicit field display order for this view | +| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view — the SUBTRACTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: each name listed here is removed from the `columns` projection before `fieldOrder` orders the remainder. A name `columns` never projected subtracts nothing. | +| **fieldOrder** | `string[]` | optional | Explicit field display order for this view — the ORDERING of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: it sorts what survives `columns` minus `hiddenFields` and never adds a field. A surviving column absent from `fieldOrder` sorts LAST, after every listed one, keeping its `columns`-relative order; a name listed here that did not survive orders nothing. | | **rowActions** | `string[]` | optional | Actions available for individual row items | | **bulkActions** | `string[]` | optional | Actions available when multiple rows are selected | | **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time. Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 985aa60d05e..4b61d98733e 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -363,7 +363,7 @@ const result = ApiMethod.parse(data); | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { 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 | +| **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) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | @@ -387,8 +387,8 @@ const result = ApiMethod.parse(data); | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | | **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) | | **rowColor** | `{ field: string; colors?: Record }` | optional | Color rows based on field value | -| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view | -| **fieldOrder** | `string[]` | optional | Explicit field display order for this view | +| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view — the SUBTRACTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: each name listed here is removed from the `columns` projection before `fieldOrder` orders the remainder. A name `columns` never projected subtracts nothing. | +| **fieldOrder** | `string[]` | optional | Explicit field display order for this view — the ORDERING of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: it sorts what survives `columns` minus `hiddenFields` and never adds a field. A surviving column absent from `fieldOrder` sorts LAST, after every listed one, keeping its `columns`-relative order; a name listed here that did not survive orders nothing. | | **rowActions** | `string[]` | optional | Actions available for individual row items | | **bulkActions** | `string[]` | optional | Actions available when multiple rows are selected | | **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time. Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index b48d558ff29..b3a8853d434 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -792,7 +792,7 @@ Map view configuration | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { 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 | +| **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) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | @@ -817,8 +817,8 @@ Map view configuration | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | | **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) | | **rowColor** | `{ field: string; colors?: Record }` | optional | Color rows based on field value | -| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view | -| **fieldOrder** | `string[]` | optional | Explicit field display order for this view | +| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view — the SUBTRACTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: each name listed here is removed from the `columns` projection before `fieldOrder` orders the remainder. A name `columns` never projected subtracts nothing. | +| **fieldOrder** | `string[]` | optional | Explicit field display order for this view — the ORDERING of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: it sorts what survives `columns` minus `hiddenFields` and never adds a field. A surviving column absent from `fieldOrder` sorts LAST, after every listed one, keeping its `columns`-relative order; a name listed here that did not survive orders nothing. | | **rowActions** | `string[]` | optional | Actions available for individual row items | | **bulkActions** | `string[]` | optional | Actions available when multiple rows are selected | | **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time. Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | @@ -1204,7 +1204,7 @@ Tab configuration for multi-tab view interface | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { 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 | +| **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) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | @@ -1228,8 +1228,8 @@ Tab configuration for multi-tab view interface | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | | **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) | | **rowColor** | `{ field: string; colors?: Record }` | optional | Color rows based on field value | -| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view | -| **fieldOrder** | `string[]` | optional | Explicit field display order for this view | +| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view — the SUBTRACTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: each name listed here is removed from the `columns` projection before `fieldOrder` orders the remainder. A name `columns` never projected subtracts nothing. | +| **fieldOrder** | `string[]` | optional | Explicit field display order for this view — the ORDERING of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: it sorts what survives `columns` minus `hiddenFields` and never adds a field. A surviving column absent from `fieldOrder` sorts LAST, after every listed one, keeping its `columns`-relative order; a name listed here that did not survive orders nothing. | | **rowActions** | `string[]` | optional | Actions available for individual row items | | **bulkActions** | `string[]` | optional | Actions available when multiple rows are selected | | **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time. Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | @@ -1807,7 +1807,7 @@ Tab configuration for multi-tab view interface | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { 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 | +| **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) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | @@ -1831,8 +1831,8 @@ Tab configuration for multi-tab view interface | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | | **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) | | **rowColor** | `{ field: string; colors?: Record }` | optional | Color rows based on field value | -| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view | -| **fieldOrder** | `string[]` | optional | Explicit field display order for this view | +| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view — the SUBTRACTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: each name listed here is removed from the `columns` projection before `fieldOrder` orders the remainder. A name `columns` never projected subtracts nothing. | +| **fieldOrder** | `string[]` | optional | Explicit field display order for this view — the ORDERING of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: it sorts what survives `columns` minus `hiddenFields` and never adds a field. A surviving column absent from `fieldOrder` sorts LAST, after every listed one, keeping its `columns`-relative order; a name listed here that did not survive orders nothing. | | **rowActions** | `string[]` | optional | Actions available for individual row items | | **bulkActions** | `string[]` | optional | Actions available when multiple rows are selected | | **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time. Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | @@ -1892,7 +1892,7 @@ Tab configuration for multi-tab view interface | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { 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 | +| **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) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | @@ -1916,8 +1916,8 @@ Tab configuration for multi-tab view interface | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | | **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) | | **rowColor** | `{ field: string; colors?: Record }` | optional | Color rows based on field value | -| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view | -| **fieldOrder** | `string[]` | optional | Explicit field display order for this view | +| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view — the SUBTRACTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: each name listed here is removed from the `columns` projection before `fieldOrder` orders the remainder. A name `columns` never projected subtracts nothing. | +| **fieldOrder** | `string[]` | optional | Explicit field display order for this view — the ORDERING of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: it sorts what survives `columns` minus `hiddenFields` and never adds a field. A surviving column absent from `fieldOrder` sorts LAST, after every listed one, keeping its `columns`-relative order; a name listed here that did not survive orders nothing. | | **rowActions** | `string[]` | optional | Actions available for individual row items | | **bulkActions** | `string[]` | optional | Actions available when multiple rows are selected | | **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time. Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | @@ -2132,7 +2132,7 @@ This schema accepts one of the following structures: | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { 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 | +| **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) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | @@ -2157,8 +2157,8 @@ This schema accepts one of the following structures: | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | | **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) | | **rowColor** | `{ field: string; colors?: Record }` | optional | Color rows based on field value | -| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view | -| **fieldOrder** | `string[]` | optional | Explicit field display order for this view | +| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view — the SUBTRACTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: each name listed here is removed from the `columns` projection before `fieldOrder` orders the remainder. A name `columns` never projected subtracts nothing. | +| **fieldOrder** | `string[]` | optional | Explicit field display order for this view — the ORDERING of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: it sorts what survives `columns` minus `hiddenFields` and never adds a field. A surviving column absent from `fieldOrder` sorts LAST, after every listed one, keeping its `columns`-relative order; a name listed here that did not survive orders nothing. | | **rowActions** | `string[]` | optional | Actions available for individual row items | | **bulkActions** | `string[]` | optional | Actions available when multiple rows are selected | | **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time. Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | @@ -2308,7 +2308,7 @@ This schema accepts one of the following structures: | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { 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 | +| **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) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | @@ -2333,8 +2333,8 @@ This schema accepts one of the following structures: | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | | **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) | | **rowColor** | `{ field: string; colors?: Record }` | optional | Color rows based on field value | -| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view | -| **fieldOrder** | `string[]` | optional | Explicit field display order for this view | +| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view — the SUBTRACTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: each name listed here is removed from the `columns` projection before `fieldOrder` orders the remainder. A name `columns` never projected subtracts nothing. | +| **fieldOrder** | `string[]` | optional | Explicit field display order for this view — the ORDERING of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: it sorts what survives `columns` minus `hiddenFields` and never adds a field. A surviving column absent from `fieldOrder` sorts LAST, after every listed one, keeping its `columns`-relative order; a name listed here that did not survive orders nothing. | | **rowActions** | `string[]` | optional | Actions available for individual row items | | **bulkActions** | `string[]` | optional | Actions available when multiple rows are selected | | **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time. Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | diff --git a/packages/spec/liveness/view.json b/packages/spec/liveness/view.json index 8b05bc9eed8..21c15486454 100644 --- a/packages/spec/liveness/view.json +++ b/packages/spec/liveness/view.json @@ -159,10 +159,11 @@ }, "fieldOrder": { "status": "live", - "verifiedAt": "2026-08-10", + "verifiedAt": "2026-09-21", "evidenceScope": "cross-repo", - "evidence": "objectui: packages/react/src/spec-bridge/bridges/list-view.ts:124 copies the authored key onto the list node; objectui: packages/plugin-list/src/ListView.tsx:1499-1500 builds an order map from schema.fieldOrder and reorders the effective column set by it, with :1509 listing it as a memo dependency — measured objectui @11c1e71e", - "note": "RE-CITED 2026-08-10 (#7132/#7142): the previous 'ObjectGrid.tsx (audit L15)' citation greps to zero at objectui @11c1e71e. Column ordering is applied in the same ListView effective-fields memo as hiddenFields, before the grid is handed its `columns` array." + "evidence": "objectui: packages/plugin-list/src/ListView.tsx#effectiveFields:2750-2784 is the applying reader — it takes schema.columns as the candidate set, subtracts the hidden-field set, then at :2774-2781 builds an order map from schema.fieldOrder and sorts the survivors by it (a survivor the map does not name scores Infinity, so it sorts last), with :2784 listing schema.fieldOrder as a memo dependency — measured objectui @87af769e, the pin this tree carries", + "producer": "objectui: packages/plugin-view/src/ObjectView.tsx#renderContent:2284 relays a named view's fieldOrder onto the list-view node that memo reads, so an authored value reaches the consumer instead of arriving undefined — view-sourced only, no host fallback, per objectui#8980 — measured objectui @87af769e", + "note": "RE-CITED 2026-09-21 (objectstack#15184 ruling B, maintainer decision batch #115 of 2026-09-11): the key STAYS, and the composition it belongs to is now declared in the contract — `columns` projects, `hiddenFields` subtracts, `fieldOrder` orders what survives, and a survivor absent from `fieldOrder` sorts last. Both halves of the 2026-08-10 citation had rotted. Its first path, objectui packages/react/src/spec-bridge/bridges/list-view.ts, is GONE: `git ls-tree -r` at objectui @87af769e returns 0 files under packages/react/src/spec-bridge/ while packages/react/ itself returns 160, so the zero belongs to the directory and not to the instrument. Its second rotted IN RANGE — the ListView memo was at :1499-1500 when measured at @11c1e71e, ~:2424-2434 when ruling B was written on 2026-09-11, and :2774-2781 here — which is why both pointers now carry symbol anchors. The spec-side declaration is the three `.describe()` strings on `columns` / `hiddenFields` / `fieldOrder` in packages/spec/src/ui/view.zod.ts, held together with the accept set by packages/spec/src/ui/view-field-order-composition.pin.test.ts. One measured gap, objectui's to close and NOT a defect in this key: the OTHER relay, objectui packages/app-shell/src/views/ObjectView.tsx, carries `hiddenFields` at :2480 but still records `fieldOrder` as a declared absence (objectui#7516) in its relay-rung census; the plugin-view rung cited under `producer` is the live one." }, "rowActions": { "status": "live", diff --git a/packages/spec/src/ui/view-field-order-composition.pin.test.ts b/packages/spec/src/ui/view-field-order-composition.pin.test.ts new file mode 100644 index 00000000000..58b21ffda8f --- /dev/null +++ b/packages/spec/src/ui/view-field-order-composition.pin.test.ts @@ -0,0 +1,314 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#15184] The `columns` x `hiddenFields` x `fieldOrder` composition is a + * DECLARED part of the list-view contract, and every authoring door accepts + * all three keys together. + * + * ## Why this pin exists + * + * `fieldOrder` was proposed for retirement (ruling of 2026-09-04) on the + * premise that it is a second spelling of `columns` with no contract deciding + * who wins. The premise was measured false: the two keys never compete, + * because one projects and the other sorts, and objectui applies all three in + * one memo. Ruling B (2026-09-11, decision batch #115) therefore KEPT the key + * and ruled the composition into the contract instead: + * + * `columns` is the projection, `hiddenFields` subtracts from it, + * `fieldOrder` orders what survives (entries absent from `fieldOrder` + * sort last). + * + * A composition stated only in prose rots the way the retired citation rotted + * — so this file holds the two halves that make it a contract rather than a + * comment: + * + * - **The declaration half** reads the three `.describe()` strings off the + * LIVE schema, not off the source text. Those strings are the published + * authoring surface: they ship into `json-schema/` and into the generated + * `content/docs/references/ui/view.mdx`. Dropping the composition from any + * one of them reds here, naming the key that lost it. So does dropping the + * EMPTY-`columns` boundary, from the `columns` description or from the + * docblock's projection step. + * - **The door half** parses documents carrying all three keys through the + * four doors a list view really arrives by, and asserts the values survive + * VERBATIM. The spec declares the composition; it does not perform it. A + * door that started reordering, deduplicating or cross-validating these + * arrays would be silently doing the renderer's job at parse time, and the + * renderer would then compose over an input it did not receive. + * + * ⛔ Scope: the RELATION, not the wording. Rewording any of the three + * descriptions is fine — what they may not do is stop naming the other two + * keys, stop stating their own role in the composition, drop the boundary an + * empty `columns` sets, or start claiming that `fieldOrder` selects fields or + * that `hiddenFields` orders them. + * + * ⛔ This pin deliberately asserts nothing about HOW a renderer sorts. That + * behaviour is objectui's, measured in the liveness ledger row + * (`packages/spec/liveness/view.json`, `/props/list/children/fieldOrder`). + * Restating it as a spec test would be a claim this package cannot falsify. + */ + +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, it, expect } from 'vitest'; + +import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; + +import { + ListViewSchema, + ObjectListViewSchema, + defineView, +} from './view.zod'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const VIEW_SOURCE = join(HERE, 'view.zod.ts'); + +/** The three composing keys, in the order they are applied. */ +const COMPOSITION = ['columns', 'hiddenFields', 'fieldOrder'] as const; + +type Shape = Record; + +const listShape = (): Shape => + (ListViewSchema as unknown as { shape: Shape }).shape; + +/** The `.describe()` text the published surface carries for `key`, lowercased. */ +function description(key: string): string { + const entry = listShape()[key]; + expect(entry, `\`${key}\` is no longer a member of ListViewSchema — re-anchor this pin`).toBeTruthy(); + const text = entry?.description; + expect(text, `\`${key}\` carries no .describe() text`).toBeTruthy(); + return String(text).toLowerCase(); +} + +/** + * The JSDoc block attached to the `hiddenFields` declaration — the one that + * states the composition once for the whole block. + */ +function compositionDocblock(): string { + const source = readFileSync(VIEW_SOURCE, 'utf8'); + const key = source.indexOf('hiddenFields: z.array(z.string()).optional().describe('); + expect(key, 'the `hiddenFields` declaration moved — re-anchor this pin').toBeGreaterThan(-1); + const open = source.lastIndexOf('/**', key); + const close = source.indexOf('*/', open); + expect(open, 'no JSDoc block precedes `hiddenFields`').toBeGreaterThan(-1); + expect(close, 'unterminated JSDoc block').toBeLessThan(key); + return source + .slice(open, close + 2) + .split('\n') + .map((line) => line.replace(/^\s*\*\s?/, '').trim()) + .join(' ') + .toLowerCase(); +} + +/** A list view exercising all three keys at once. */ +const COMPOSED_LIST = { + type: 'grid' as const, + columns: ['name', 'stage', 'amount', 'owner'], + hiddenFields: ['owner'], + // Deliberately partial and deliberately out of `columns` order: `amount` + // moves to the front, `name` and `stage` are unlisted survivors, and + // `owner` is a subtracted name this list still mentions. + fieldOrder: ['amount', 'owner'], +}; + +describe('[#15184] the list-view field composition is declared, not implied', () => { + describe('declaration — the published `.describe()` of each composing key', () => { + it.each(COMPOSITION)('`%s` names all three keys of the composition', (key) => { + const text = description(key); + for (const sibling of COMPOSITION) { + expect(text, `\`${key}\` no longer names \`${sibling}\``).toContain(sibling.toLowerCase()); + } + }); + + it('`columns` declares itself the PROJECTION, and that neither other key re-adds what it omits', () => { + const text = description('columns'); + expect(text).toContain('projection'); + // The half that makes it a contract rather than a label: the other two + // keys cannot widen the set. + expect(text).toMatch(/cannot add|not add|never add/); + }); + + it('`columns` states the EMPTY-list boundary: no projection, and neither other key applies', () => { + // Every door admits `columns: []` (pinned in the door half below), and + // the reference renderer reads it as UNAUTHORED: objectui's ListView + // hands its grid no columns and the grid derives the object's defaults, + // which neither `hiddenFields` nor `fieldOrder` reaches. A projection + // clause silent on that boundary promises a subtraction and an ordering + // that never happen. + const text = description('columns'); + expect(text).toContain('empty list declares no projection'); + expect(text).toContain('neither of them applies'); + expect(text).toContain('default columns'); + }); + + it('`hiddenFields` declares itself the SUBTRACTION, applied before the ordering', () => { + const text = description('hiddenFields'); + expect(text).toMatch(/subtract|removed from/); + expect(text).toContain('before'); + expect(text).toContain('fieldorder'); + }); + + it('`fieldOrder` declares itself the ORDERING, additive of nothing', () => { + const text = description('fieldOrder'); + expect(text).toMatch(/orders|ordering/); + expect(text).toMatch(/never adds|does not add|adds no/); + }); + + it('`fieldOrder` states where an unlisted survivor lands — LAST', () => { + // The one rule an author cannot guess and the renderer really applies + // (`?? Infinity` in objectui's effective-fields memo). Without it the + // description would describe a partial `fieldOrder` as undefined + // behaviour, which is exactly what it is not. + const text = description('fieldOrder'); + expect(text).toContain('last'); + }); + + it('the block docblock states the composition once, in application order', () => { + const doc = compositionDocblock(); + expect(doc).toContain('projection'); + expect(doc).toContain('subtract'); + expect(doc).toContain('orders what survives'); + // Application order, read positionally: projection, then subtraction, + // then ordering. + const iColumns = doc.indexOf('`columns` is the **projection**'); + const iHidden = doc.indexOf('`hiddenfields` **subtracts**'); + const iOrder = doc.indexOf('`fieldorder` **orders what survives**'); + expect(iColumns, 'the docblock no longer states the projection step').toBeGreaterThan(-1); + expect(iHidden, 'the docblock no longer states the subtraction step').toBeGreaterThan(-1); + expect(iOrder, 'the docblock no longer states the ordering step').toBeGreaterThan(-1); + expect(iColumns).toBeLessThan(iHidden); + expect(iHidden).toBeLessThan(iOrder); + }); + + it('the block docblock states the EMPTY-`columns` boundary on the projection step', () => { + const doc = compositionDocblock(); + const iColumns = doc.indexOf('`columns` is the **projection**'); + const iHidden = doc.indexOf('`hiddenfields` **subtracts**'); + expect(iColumns, 'the docblock no longer states the projection step').toBeGreaterThan(-1); + expect(iHidden, 'the docblock no longer states the subtraction step').toBeGreaterThan(iColumns); + const projectionStep = doc.slice(iColumns, iHidden); + expect(projectionStep).toContain('empty `columns` declares no projection'); + expect(projectionStep).toContain('steps 2 and 3 do not apply'); + }); + }); + + describe('doors — all three keys are accepted together, and survive verbatim', () => { + it('ListViewSchema (the page list door)', () => { + const parsed = ListViewSchema.parse({ ...COMPOSED_LIST } as never) as typeof COMPOSED_LIST; + expect(parsed.columns).toEqual(COMPOSED_LIST.columns); + expect(parsed.hiddenFields).toEqual(COMPOSED_LIST.hiddenFields); + expect(parsed.fieldOrder).toEqual(COMPOSED_LIST.fieldOrder); + }); + + it('ObjectListViewSchema (the ADR-0047 "views" mode door)', () => { + const parsed = ObjectListViewSchema.parse({ ...COMPOSED_LIST } as never) as typeof COMPOSED_LIST; + expect(parsed.columns).toEqual(COMPOSED_LIST.columns); + expect(parsed.hiddenFields).toEqual(COMPOSED_LIST.hiddenFields); + expect(parsed.fieldOrder).toEqual(COMPOSED_LIST.fieldOrder); + }); + + it('defineView (the authoring door)', () => { + const view = defineView({ name: 'crm_lead', list: { ...COMPOSED_LIST } } as never); + const list = (view as { list?: typeof COMPOSED_LIST }).list; + expect(list?.columns).toEqual(COMPOSED_LIST.columns); + expect(list?.hiddenFields).toEqual(COMPOSED_LIST.hiddenFields); + expect(list?.fieldOrder).toEqual(COMPOSED_LIST.fieldOrder); + }); + + it('the registered `view` metadata door (Studio, the API, an agent)', () => { + const schema = getMetadataTypeSchema('view'); + expect(schema, 'the `view` metadata type is no longer registered').toBeTruthy(); + const parsed = schema!.parse({ + name: 'crm_lead', + list: { ...COMPOSED_LIST }, + } as never) as { list?: typeof COMPOSED_LIST }; + expect(parsed.list?.columns).toEqual(COMPOSED_LIST.columns); + expect(parsed.list?.hiddenFields).toEqual(COMPOSED_LIST.hiddenFields); + expect(parsed.list?.fieldOrder).toEqual(COMPOSED_LIST.fieldOrder); + }); + + it('accepts all three keys with NO unrecognized-key report — they are real authorable slots', () => { + // The strict door's own verdict, not an inference from `success`: a key + // the shape did not declare would surface here by name. + const r = ListViewSchema.safeParse({ ...COMPOSED_LIST } as never); + expect(r.success).toBe(true); + expect(JSON.stringify(r.error?.issues ?? [])).not.toContain('unrecognized_keys'); + }); + + it('every door ADMITS `columns: []` verbatim — the boundary the declaration states is reachable', () => { + // This is why the `columns` description and the docblock state what an + // empty list means. If a later ruling refuses `[]` at the door, this case + // and that boundary sentence retire together. + const EMPTY = { type: 'grid' as const, columns: [] as string[], hiddenFields: ['owner'], fieldOrder: ['name'] }; + const viaList = ListViewSchema.parse({ ...EMPTY } as never) as typeof EMPTY; + const viaViews = ObjectListViewSchema.parse({ ...EMPTY } as never) as typeof EMPTY; + const viaDefine = (defineView({ name: 'crm_lead', list: { ...EMPTY } } as never) as { list?: typeof EMPTY }).list; + const viaRegistered = (getMetadataTypeSchema('view')!.parse({ + name: 'crm_lead', + list: { ...EMPTY }, + } as never) as { list?: typeof EMPTY }).list; + for (const list of [viaList, viaViews, viaDefine, viaRegistered]) { + expect(list?.columns).toEqual([]); + expect(list?.hiddenFields).toEqual(EMPTY.hiddenFields); + expect(list?.fieldOrder).toEqual(EMPTY.fieldOrder); + } + }); + }); + + describe('doors — the spec DECLARES the composition, it does not perform it', () => { + it('does not cross-validate `fieldOrder` against `columns`', () => { + // `fieldOrder` may name a field `columns` never projected. The renderer + // orders nothing by it; reference integrity is `@objectstack/lint`'s + // job (`validate-list-view-field-refs.ts`), against the object's real + // field map — a door that guessed here would reject documents the + // platform itself writes. + const r = ListViewSchema.safeParse({ + type: 'grid', + columns: ['name'], + fieldOrder: ['not_projected', 'name'], + } as never); + expect(r.success).toBe(true); + }); + + it('does not cross-validate `hiddenFields` against `columns`', () => { + const r = ListViewSchema.safeParse({ + type: 'grid', + columns: ['name'], + hiddenFields: ['never_projected'], + } as never); + expect(r.success).toBe(true); + }); + + it('does not apply the composition at parse time — `columns` comes back whole', () => { + // The failure this forbids is a door that "helpfully" subtracts or + // reorders: the renderer would then compose over an already-composed + // input, and `hiddenFields` would subtract twice from nothing. + const parsed = ListViewSchema.parse({ ...COMPOSED_LIST } as never) as typeof COMPOSED_LIST; + expect(parsed.columns).toHaveLength(COMPOSED_LIST.columns.length); + expect(parsed.columns).toContain('owner'); + expect(parsed.columns[0]).toBe('name'); + }); + }); + + describe('shape — the three keys are siblings on one schema', () => { + it('all three are declared on ListViewSchema itself', () => { + const shape = listShape(); + for (const key of COMPOSITION) { + expect(Object.keys(shape), `\`${key}\` left ListViewSchema`).toContain(key); + } + }); + + it('`columns` is required and the other two are optional', () => { + // The projection is the only one an author must write: a list view with + // no candidate set has nothing to subtract from or order. + const r = ListViewSchema.safeParse({ type: 'grid', hiddenFields: ['a'], fieldOrder: ['a'] } as never); + expect(r.success).toBe(false); + expect(JSON.stringify(r.error?.issues ?? [])).toContain('columns'); + + const ok = ListViewSchema.safeParse({ type: 'grid', columns: ['a'] } as never); + expect(ok.success).toBe(true); + }); + }); +}); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 2ecea023f2a..5a0b5cf9d29 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -2432,11 +2432,27 @@ const ListViewShapeSchema = lazySchema(() => strictObject({ /** Data Source Configuration */ data: ViewDataSchema.optional().describe('Data source configuration (defaults to "object" provider)'), - /** Shared Query Config */ + /** + * Shared Query Config + * + * `columns` is the PROJECTION member of the per-view field composition + * declared on `hiddenFields` / `fieldOrder` below: it is the candidate set + * and the baseline order, `hiddenFields` subtracts from it, and `fieldOrder` + * orders what survives. Neither of those two keys can add a field this list + * omits. An EMPTY list declares no projection, so neither of them applies + * (step 1 of the composition docblock below states the boundary). + */ columns: z.union([ z.array(z.string()), // Legacy: simple field names z.array(ListColumnSchema), // Enhanced: detailed column config - ]).describe('Fields to display as columns'), + ]).describe( + '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: z.array(ViewFilterRuleSchema).optional().describe('Filter criteria (JSON Rules)'), /** * Sort order — the structured `{ field, order }[]` array, and only that. @@ -2564,9 +2580,56 @@ const ListViewShapeSchema = lazySchema(() => strictObject({ /** Row Color (Airtable-style) */ rowColor: RowColorConfigSchema.optional().describe('Color rows based on field value'), - /** Field Visibility & Ordering per View (Airtable-style) */ - hiddenFields: z.array(z.string()).optional().describe('Fields to hide in this specific view'), - fieldOrder: z.array(z.string()).optional().describe('Explicit field display order for this view'), + /** + * Field Visibility & Ordering per View (Airtable-style) + * + * ## The composition, declared (#15184 ruling B, 2026-09-11) + * + * Three keys on this schema together build one field list, and they + * COMPOSE — they are not three ways to say the same thing, and none of them + * is a fallback for another: + * + * 1. `columns` is the **projection**: the candidate set and the baseline + * order. An EMPTY `columns` declares no projection, so steps 2 and 3 do + * not apply: which columns show is left to the renderer (objectui's + * `ListView` hands its grid no columns, and the grid derives the + * object's default ones). + * 2. `hiddenFields` **subtracts** from that projection: every name it lists + * is removed. A name it lists that `columns` never projected subtracts + * nothing. + * 3. `fieldOrder` **orders what survives**: it sorts the set left after the + * subtraction and never adds to it. A surviving column absent from + * `fieldOrder` sorts LAST, after every listed one, keeping its + * `columns`-relative order among its fellow unlisted columns; a name + * `fieldOrder` lists that did not survive orders nothing. + * + * ⛔ This is a DECLARATION of the order of application, not a precedence + * rule between rival spellings: `columns` and `fieldOrder` never contradict + * each other, because one selects and the other sorts. + * + * The composition was ruled into the contract rather than retired to one key + * because it is the shape objectui's list-view renderer already applies — + * `packages/plugin-list/src/ListView.tsx`, the `effectiveFields` memo, runs + * these three steps in this order, with one more between steps 1 and 2: it + * drops the columns field-level security denies the current user read on. + * The ledger row + * (`packages/spec/liveness/view.json`, `/props/list/children/fieldOrder`) + * carries the measured citation; `view-field-order-composition.pin.test.ts` + * holds this declaration and the accept set together. + */ + hiddenFields: z.array(z.string()).optional().describe( + 'Fields to hide in this specific view — the SUBTRACTION of the per-view field composition ' + + '`columns` x `hiddenFields` x `fieldOrder`: each name listed here is removed from the ' + + '`columns` projection before `fieldOrder` orders the remainder. A name `columns` never ' + + 'projected subtracts nothing.', + ), + fieldOrder: z.array(z.string()).optional().describe( + 'Explicit field display order for this view — the ORDERING of the per-view field composition ' + + '`columns` x `hiddenFields` x `fieldOrder`: it sorts what survives `columns` minus ' + + '`hiddenFields` and never adds a field. A surviving column absent from `fieldOrder` sorts ' + + 'LAST, after every listed one, keeping its `columns`-relative order; a name listed here ' + + 'that did not survive orders nothing.', + ), /** Row & Bulk Actions */ rowActions: z.array(z.string()).optional().describe('Actions available for individual row items'), diff --git a/skills/objectstack-ui/references/react-blocks.md b/skills/objectstack-ui/references/react-blocks.md index 8a668e8c7aa..573f51b2704 100644 --- a/skills/objectstack-ui/references/react-blocks.md +++ b/skills/objectstack-ui/references/react-blocks.md @@ -65,7 +65,7 @@ Server-connected object table with toolbar and switchable visualizations (grid/k | `filters` | `FilterArray e.g. ['status','=','active']` | controlled | | ObjectQL base filter; drive from React state for tabbed/searched lists. ([field, op, value]; ops =, !=, >, <, contains, in; compound: ["and", […], […]]). | | `onRowClick` | `(record) => void` | callback | | Called with the clicked row's record — the hook for master/detail. | | `onNavigate` | `(recordId, action: 'view' \| 'new_window') => void` | callback | | Called for page-level navigation. The second argument is a navigation-MODE token from a CLOSED vocabulary — 'view' opens the record page, 'new_window' opens it in a new browser tab; the reference implementation emits no other value and reads no other branch. | -| `columns` | `string[] \| object[]` | data | ✓ | Fields to display as columns | +| `columns` | `string[] \| object[]` | data | ✓ | 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 t… | | `type` | `'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'` | data | | | | `sort` | `object[]` | data | | | | `searchableFields` | `string[]` | data | | Fields enabled for search |