diff --git a/.changeset/list-view-tabs-retired.md b/.changeset/list-view-tabs-retired.md new file mode 100644 index 00000000000..17f5f95d2a3 --- /dev/null +++ b/.changeset/list-view-tabs-retired.md @@ -0,0 +1,99 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: retire the list view's own `tabs` key — parsed, stored, and drawn by nothing; named presets are `listViews` entries + +**BREAKING** — `tabs` is removed from the list view (`ListViewSchema`, +`ObjectListViewSchema` — a `defineView` container's `list` / `listViews`, an +object's `listViews` — a view item record's list `config`, and the flattened +list overlay the `PUT /api/v1/meta/view` door accepts). ADR-0049 +enforce-or-remove; triage verdict RETIRE, on the rule that a capability the +mainstream has and this platform already delivers keeps ONE spelling. + +The key parsed at every list-view door and was stored, and no renderer ever +drew it. Measured before removal, each reading beside a lit control: a list +view's own `tabs` has no reader, and objectui's `TabBar` — the one component +that would draw it — has zero production mounts at the objectui commit this +repo pins (every occurrence is in its own two test files), while the saved-view +switcher (`ViewTabBar`) mounts in the object view and is fed from the object's +`listViews`. That switcher IS the tab strip above an object's records: one tab +per named list view. `userFilters.tabs` is a different key with the same +element type: it is read and rendered as a page list's preset bar, and it +stays. Zero list views in this repo's examples or platform sources authored the +key; the one published skill example that taught it is corrected here. + +### FROM → TO + +| removed | what to write instead | +| --- | --- | +| a list view's `tabs: [{ name, label, filter, … }]` | one named list view per tab, under the object's `listViews`: the tab's `name` becomes the entry's key, its `label` the entry's `label`, and its `filter` rules join the view's own `filter` on that entry (copy the view's `columns` too). A tab whose `view` already named a list view needs nothing more. | +| the tab keys `icon`, `order`, `pinned`, `isDefault`, `visible` | nothing — none of them ever had an effect. | + +**The one-line fix: delete `tabs:` from every list view, and add a `listViews` +entry for each tab you want users to switch to.** `os migrate meta --from 17` +lists the mechanical edits for existing sources; apply them by hand. + +```ts +// before — parsed clean, drew no tab bar +defineView({ + object: 'crm_ticket', + list: { + type: 'grid', columns: ['subject', 'status'], + tabs: [{ name: 'open', label: 'Open', filter: [{ field: 'status', operator: 'equals', value: 'open' }] }], + }, +}); +// after — the switcher above the records shows "Open" beside the default view +defineView({ + object: 'crm_ticket', + list: { type: 'grid', columns: ['subject', 'status'] }, + listViews: { + open: { + type: 'grid', label: 'Open', columns: ['subject', 'status'], + filter: [{ field: 'status', operator: 'equals', value: 'open' }], + }, + }, +}); +``` + +⛔ **Untouched: the page-only preset bar.** `userFilters: { element: 'tabs', +tabs: [...] }` on a page list is a different key, it renders, and +`ViewTabSchema` stays for it. + +### The retirement kit + +- **A `retiredKey()` tombstone on the list-view shape**, beside the `pageName` + tombstone on the same strict shape. Every door built from it refuses: `tsc` + types the key `never`, and the parse raises the prescription (which names the + move to `listViews`) instead of a bare unknown-key report. +- **D2 conversion `view-list-tabs-removed`** (protocol 18, retired from the load + path): strips `tabs` from every list payload in `stack.views[]`, in all three + persisted spellings, as a lossless delete — nothing ever drew the tabs — so a + stored `view` row replays clean through the rehydration seam. An object's own + `listViews` is reached by no conversion, so such an object is refused at its + door until edited by hand. +- **D3 entry `list-view-tabs-retired`** beside it, carrying the part no + conversion can decide: which tabs deserve a `listViews` entry. +- **`RETIRED_KEYS_BY_MAJOR[18]`**: `ui/ListView:tabs`, `ui/ObjectListView:tabs`; + both `authorable-surface/ui.json` rows become `[RETIRED]`. +- **The metadata form's `tabs` repeater** leaves with the key, and the + extracted form-label bundles are regenerated. +- **The liveness row stays `dead`**, re-verified, with a REMOVED note — the + tombstone keeps the key in the walked shape. +- **The published `objectstack-ui` skill** no longer teaches the key: its + list-view rules example and the "tabs win over dropdowns" rule (which + described a tab bar that never rendered) are replaced by the `listViews` + pointer. +- **Pins** (`ui/view-list-tabs-retirement.test.ts`): the refusal, its issue + code, path and prescription at seven doors, each with a lit control; the tsc + channel; the `userFilters.tabs` boundary; the conversion's reach, boundary and + idempotence; the D2/D3 registration; and a tree-scoped absence walk over the + declared radius. +- **No deprecation window**, per the project's startup-stage posture. + +⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` +is published, so this is breaking for consumers no telemetry was consulted for. + +Clause-②: no (narrowing) + + diff --git a/content/docs/protocol/objectui/layout-dsl.mdx b/content/docs/protocol/objectui/layout-dsl.mdx index c4f16f65fab..54467ed7888 100644 --- a/content/docs/protocol/objectui/layout-dsl.mdx +++ b/content/docs/protocol/objectui/layout-dsl.mdx @@ -563,10 +563,10 @@ is a **parse failure** — a loud rejection, not a silent no-op. Looking for tabs that carry their own `name`, `icon`, `filter`, `order`, -`pinned` and `isDefault`? That surface exists, but it belongs to **list** views, -not forms: `ui/ViewTab` declares exactly nine keys (`filter`, `icon`, -`isDefault`, `label`, `name`, `order`, `pinned`, `view`, `visible`), and each -tab points at a named list view. See +`pinned` and `isDefault`? That surface belongs to a **page list's** preset bar +(`userFilters.tabs`), not to forms: `ui/ViewTab` declares exactly nine keys (`filter`, +`icon`, `isDefault`, `label`, `name`, `order`, `pinned`, `view`, `visible`). A list +view has no `tabs` key — an object's tab strip lists its named `listViews`. See [View Reference → ViewTab](/docs/references/ui/view#viewtab). ## Responsive Layout Modifiers diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 0dde8d7af02..48fa1fb1dbc 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1699,7 +1699,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **exportOptions** | `Enum<'csv' \| 'xlsx' \| 'json'>[] \| { formats?: Enum<'csv' \| 'xlsx' \| 'json'>[]; maxRecords?: integer; includeHeaders?: boolean; fileNamePrefix?: string; … }` | optional | Export configuration for the list toolbar export menu: `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`. A bare format array is the legacy spelling and lifts to `{ formats: [...] }` at parse. | | **userActions** | `{ sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … }` | optional | User action toggles for the view toolbar | | **appearance** | `{ showDescription?: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | -| **tabs** | `{ name: string; label?: string \| Record; icon?: string; view?: string; … }[]` | optional | Tab definitions for multi-tab view interface | +| **tabs** | `never` | optional | [REMOVED] `view.list.tabs` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no renderer ever mounted a tab bar for it, so authoring it drew nothing: the tab strip above an object's records is the saved-view switcher (ViewTabBar), which renders one tab per named list view and never read this key. Delete the key, and move each tab you want to a named list view under the object's `listViews` instead: the tab's `name` becomes the entry's key, its `label` the entry's `label`, and its `filter` rules join the view's own `filter` on that entry (copy the view's `columns` too); a tab whose `view` already named a list view needs nothing more. Every `listViews` entry renders as a tab in the switcher. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **addRecord** | `{ enabled?: boolean; position?: Enum<'top' \| 'bottom' \| 'both'>; mode?: Enum<'inline' \| 'form' \| 'modal'>; formView?: string }` | optional | Add record entry point configuration | | **showRecordCount** | `boolean` | optional | Show record count at the bottom of the list | | **allowPrinting** | `boolean` | optional | Allow users to print the view | @@ -1784,7 +1784,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **exportOptions** | `Enum<'csv' \| 'xlsx' \| 'json'>[] \| { formats?: Enum<'csv' \| 'xlsx' \| 'json'>[]; maxRecords?: integer; includeHeaders?: boolean; fileNamePrefix?: string; … }` | optional | Export configuration for the list toolbar export menu: `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`. A bare format array is the legacy spelling and lifts to `{ formats: [...] }` at parse. | | **userActions** | `{ sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … }` | optional | User action toggles for the view toolbar | | **appearance** | `{ showDescription?: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | -| **tabs** | `{ name: string; label?: string \| Record; icon?: string; view?: string; … }[]` | optional | Tab definitions for multi-tab view interface | +| **tabs** | `never` | optional | [REMOVED] `view.list.tabs` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no renderer ever mounted a tab bar for it, so authoring it drew nothing: the tab strip above an object's records is the saved-view switcher (ViewTabBar), which renders one tab per named list view and never read this key. Delete the key, and move each tab you want to a named list view under the object's `listViews` instead: the tab's `name` becomes the entry's key, its `label` the entry's `label`, and its `filter` rules join the view's own `filter` on that entry (copy the view's `columns` too); a tab whose `view` already named a list view needs nothing more. Every `listViews` entry renders as a tab in the switcher. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **addRecord** | `{ enabled?: boolean; position?: Enum<'top' \| 'bottom' \| 'both'>; mode?: Enum<'inline' \| 'form' \| 'modal'>; formView?: string }` | optional | Add record entry point configuration | | **showRecordCount** | `boolean` | optional | Show record count at the bottom of the list | | **allowPrinting** | `boolean` | optional | Allow users to print the view | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index ae640847fcf..9140697862b 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -398,7 +398,7 @@ const result = ApiMethod.parse(data); | **exportOptions** | `Enum<'csv' \| 'xlsx' \| 'json'>[] \| { formats?: Enum<'csv' \| 'xlsx' \| 'json'>[]; maxRecords?: integer; includeHeaders?: boolean; fileNamePrefix?: string; … }` | optional | Export configuration for the list toolbar export menu: `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`. A bare format array is the legacy spelling and lifts to `{ formats: [...] }` at parse. | | **userActions** | `{ sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … }` | optional | User action toggles for the view toolbar | | **appearance** | `{ showDescription?: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | -| **tabs** | `{ name: string; label?: string \| Record; icon?: string; view?: string; … }[]` | optional | Tab definitions for multi-tab view interface | +| **tabs** | `never` | optional | [REMOVED] `view.list.tabs` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no renderer ever mounted a tab bar for it, so authoring it drew nothing: the tab strip above an object's records is the saved-view switcher (ViewTabBar), which renders one tab per named list view and never read this key. Delete the key, and move each tab you want to a named list view under the object's `listViews` instead: the tab's `name` becomes the entry's key, its `label` the entry's `label`, and its `filter` rules join the view's own `filter` on that entry (copy the view's `columns` too); a tab whose `view` already named a list view needs nothing more. Every `listViews` entry renders as a tab in the switcher. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **addRecord** | `{ enabled?: boolean; position?: Enum<'top' \| 'bottom' \| 'both'>; mode?: Enum<'inline' \| 'form' \| 'modal'>; formView?: string }` | optional | Add record entry point configuration | | **showRecordCount** | `boolean` | optional | Show record count at the bottom of the list | | **allowPrinting** | `boolean` | optional | Allow users to print the view | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 308f26173d0..53bfd0e0dec 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -826,7 +826,7 @@ Map view configuration | **exportOptions** | `Enum<'csv' \| 'xlsx' \| 'json'>[] \| { formats?: Enum<'csv' \| 'xlsx' \| 'json'>[]; maxRecords?: integer; includeHeaders?: boolean; fileNamePrefix?: string; … }` | optional | Export configuration for the list toolbar export menu: `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`. A bare format array is the legacy spelling and lifts to `{ formats: [...] }` at parse. | | **userActions** | `{ sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … }` | optional | User action toggles for the view toolbar | | **appearance** | `{ showDescription?: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | -| **tabs** | `{ name: string; label?: string \| Record; icon?: string; view?: string; … }[]` | optional | Tab definitions for multi-tab view interface | +| **tabs** | `never` | optional | [REMOVED] `view.list.tabs` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no renderer ever mounted a tab bar for it, so authoring it drew nothing: the tab strip above an object's records is the saved-view switcher (ViewTabBar), which renders one tab per named list view and never read this key. Delete the key, and move each tab you want to a named list view under the object's `listViews` instead: the tab's `name` becomes the entry's key, its `label` the entry's `label`, and its `filter` rules join the view's own `filter` on that entry (copy the view's `columns` too); a tab whose `view` already named a list view needs nothing more. Every `listViews` entry renders as a tab in the switcher. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **addRecord** | `{ enabled?: boolean; position?: Enum<'top' \| 'bottom' \| 'both'>; mode?: Enum<'inline' \| 'form' \| 'modal'>; formView?: string }` | optional | Add record entry point configuration | | **showRecordCount** | `boolean` | optional | Show record count at the bottom of the list | | **allowPrinting** | `boolean` | optional | Allow users to print the view | @@ -1115,22 +1115,6 @@ View filter rule | **showDescription** | `boolean` | optional (default: `true`) | Show the view description text | | **allowedVisualizations** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[]` | optional | Whitelist of visualization types users can switch between (e.g. ["grid", "gallery", "kanban"]) | -### Nested Shape: `ListView.tabs[number]` - -Tab configuration for multi-tab view interface - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Tab identifier (snake_case) | -| **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 | -| **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: `ListView.addRecord` | Property | Type | Required | Description | @@ -1234,7 +1218,7 @@ Tab configuration for multi-tab view interface | **exportOptions** | `Enum<'csv' \| 'xlsx' \| 'json'>[] \| { formats?: Enum<'csv' \| 'xlsx' \| 'json'>[]; maxRecords?: integer; includeHeaders?: boolean; fileNamePrefix?: string; … }` | optional | Export configuration for the list toolbar export menu: `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`. A bare format array is the legacy spelling and lifts to `{ formats: [...] }` at parse. | | **userActions** | `{ sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … }` | optional | User action toggles for the view toolbar | | **appearance** | `{ showDescription?: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | -| **tabs** | `{ name: string; label?: string \| Record; icon?: string; view?: string; … }[]` | optional | Tab definitions for multi-tab view interface | +| **tabs** | `never` | optional | [REMOVED] `view.list.tabs` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no renderer ever mounted a tab bar for it, so authoring it drew nothing: the tab strip above an object's records is the saved-view switcher (ViewTabBar), which renders one tab per named list view and never read this key. Delete the key, and move each tab you want to a named list view under the object's `listViews` instead: the tab's `name` becomes the entry's key, its `label` the entry's `label`, and its `filter` rules join the view's own `filter` on that entry (copy the view's `columns` too); a tab whose `view` already named a list view needs nothing more. Every `listViews` entry renders as a tab in the switcher. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **addRecord** | `{ enabled?: boolean; position?: Enum<'top' \| 'bottom' \| 'both'>; mode?: Enum<'inline' \| 'form' \| 'modal'>; formView?: string }` | optional | Add record entry point configuration | | **showRecordCount** | `boolean` | optional | Show record count at the bottom of the list | | **allowPrinting** | `boolean` | optional | Allow users to print the view | @@ -1514,22 +1498,6 @@ View filter rule | **showDescription** | `boolean` | optional (default: `true`) | Show the view description text | | **allowedVisualizations** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[]` | optional | Whitelist of visualization types users can switch between (e.g. ["grid", "gallery", "kanban"]) | -### Nested Shape: `ObjectListView.tabs[number]` - -Tab configuration for multi-tab view interface - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Tab identifier (snake_case) | -| **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 | -| **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: `ObjectListView.addRecord` | Property | Type | Required | Description | @@ -1833,7 +1801,7 @@ Tab configuration for multi-tab view interface | **exportOptions** | `Enum<'csv' \| 'xlsx' \| 'json'>[] \| { formats?: Enum<'csv' \| 'xlsx' \| 'json'>[]; maxRecords?: integer; includeHeaders?: boolean; fileNamePrefix?: string; … }` | optional | Export configuration for the list toolbar export menu: `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`. A bare format array is the legacy spelling and lifts to `{ formats: [...] }` at parse. | | **userActions** | `{ sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … }` | optional | User action toggles for the view toolbar | | **appearance** | `{ showDescription?: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | -| **tabs** | `{ name: string; label?: string \| Record; icon?: string; view?: string; … }[]` | optional | Tab definitions for multi-tab view interface | +| **tabs** | `never` | optional | [REMOVED] `view.list.tabs` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no renderer ever mounted a tab bar for it, so authoring it drew nothing: the tab strip above an object's records is the saved-view switcher (ViewTabBar), which renders one tab per named list view and never read this key. Delete the key, and move each tab you want to a named list view under the object's `listViews` instead: the tab's `name` becomes the entry's key, its `label` the entry's `label`, and its `filter` rules join the view's own `filter` on that entry (copy the view's `columns` too); a tab whose `view` already named a list view needs nothing more. Every `listViews` entry renders as a tab in the switcher. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **addRecord** | `{ enabled?: boolean; position?: Enum<'top' \| 'bottom' \| 'both'>; mode?: Enum<'inline' \| 'form' \| 'modal'>; formView?: string }` | optional | Add record entry point configuration | | **showRecordCount** | `boolean` | optional | Show record count at the bottom of the list | | **allowPrinting** | `boolean` | optional | Allow users to print the view | @@ -1918,7 +1886,7 @@ Tab configuration for multi-tab view interface | **exportOptions** | `Enum<'csv' \| 'xlsx' \| 'json'>[] \| { formats?: Enum<'csv' \| 'xlsx' \| 'json'>[]; maxRecords?: integer; includeHeaders?: boolean; fileNamePrefix?: string; … }` | optional | Export configuration for the list toolbar export menu: `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`. A bare format array is the legacy spelling and lifts to `{ formats: [...] }` at parse. | | **userActions** | `{ sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … }` | optional | User action toggles for the view toolbar | | **appearance** | `{ showDescription?: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | -| **tabs** | `{ name: string; label?: string \| Record; icon?: string; view?: string; … }[]` | optional | Tab definitions for multi-tab view interface | +| **tabs** | `never` | optional | [REMOVED] `view.list.tabs` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no renderer ever mounted a tab bar for it, so authoring it drew nothing: the tab strip above an object's records is the saved-view switcher (ViewTabBar), which renders one tab per named list view and never read this key. Delete the key, and move each tab you want to a named list view under the object's `listViews` instead: the tab's `name` becomes the entry's key, its `label` the entry's `label`, and its `filter` rules join the view's own `filter` on that entry (copy the view's `columns` too); a tab whose `view` already named a list view needs nothing more. Every `listViews` entry renders as a tab in the switcher. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **addRecord** | `{ enabled?: boolean; position?: Enum<'top' \| 'bottom' \| 'both'>; mode?: Enum<'inline' \| 'form' \| 'modal'>; formView?: string }` | optional | Add record entry point configuration | | **showRecordCount** | `boolean` | optional | Show record count at the bottom of the list | | **allowPrinting** | `boolean` | optional | Allow users to print the view | @@ -2159,7 +2127,7 @@ This schema accepts one of the following structures: | **exportOptions** | `Enum<'csv' \| 'xlsx' \| 'json'>[] \| { formats?: Enum<'csv' \| 'xlsx' \| 'json'>[]; maxRecords?: integer; includeHeaders?: boolean; fileNamePrefix?: string; … }` | optional | Export configuration for the list toolbar export menu: `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`. A bare format array is the legacy spelling and lifts to `{ formats: [...] }` at parse. | | **userActions** | `{ sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … }` | optional | User action toggles for the view toolbar | | **appearance** | `{ showDescription?: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | -| **tabs** | `{ name: string; label?: string \| Record; icon?: string; view?: string; … }[]` | optional | Tab definitions for multi-tab view interface | +| **tabs** | `never` | optional | [REMOVED] `view.list.tabs` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no renderer ever mounted a tab bar for it, so authoring it drew nothing: the tab strip above an object's records is the saved-view switcher (ViewTabBar), which renders one tab per named list view and never read this key. Delete the key, and move each tab you want to a named list view under the object's `listViews` instead: the tab's `name` becomes the entry's key, its `label` the entry's `label`, and its `filter` rules join the view's own `filter` on that entry (copy the view's `columns` too); a tab whose `view` already named a list view needs nothing more. Every `listViews` entry renders as a tab in the switcher. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **addRecord** | `{ enabled?: boolean; position?: Enum<'top' \| 'bottom' \| 'both'>; mode?: Enum<'inline' \| 'form' \| 'modal'>; formView?: string }` | optional | Add record entry point configuration | | **showRecordCount** | `boolean` | optional | Show record count at the bottom of the list | | **allowPrinting** | `boolean` | optional | Allow users to print the view | @@ -2335,7 +2303,7 @@ This schema accepts one of the following structures: | **exportOptions** | `Enum<'csv' \| 'xlsx' \| 'json'>[] \| { formats?: Enum<'csv' \| 'xlsx' \| 'json'>[]; maxRecords?: integer; includeHeaders?: boolean; fileNamePrefix?: string; … }` | optional | Export configuration for the list toolbar export menu: `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`. A bare format array is the legacy spelling and lifts to `{ formats: [...] }` at parse. | | **userActions** | `{ sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … }` | optional | User action toggles for the view toolbar | | **appearance** | `{ showDescription?: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | -| **tabs** | `{ name: string; label?: string \| Record; icon?: string; view?: string; … }[]` | optional | Tab definitions for multi-tab view interface | +| **tabs** | `never` | optional | [REMOVED] `view.list.tabs` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no renderer ever mounted a tab bar for it, so authoring it drew nothing: the tab strip above an object's records is the saved-view switcher (ViewTabBar), which renders one tab per named list view and never read this key. Delete the key, and move each tab you want to a named list view under the object's `listViews` instead: the tab's `name` becomes the entry's key, its `label` the entry's `label`, and its `filter` rules join the view's own `filter` on that entry (copy the view's `columns` too); a tab whose `view` already named a list view needs nothing more. Every `listViews` entry renders as a tab in the switcher. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **addRecord** | `{ enabled?: boolean; position?: Enum<'top' \| 'bottom' \| 'both'>; mode?: Enum<'inline' \| 'form' \| 'modal'>; formView?: string }` | optional | Add record entry point configuration | | **showRecordCount** | `boolean` | optional | Show record count at the bottom of the list | | **allowPrinting** | `boolean` | optional | Allow users to print the view | diff --git a/packages/metadata-protocol/src/protocol.graft-normalized-operators.test.ts b/packages/metadata-protocol/src/protocol.graft-normalized-operators.test.ts index 6912c988d01..b2a445ec8fc 100644 --- a/packages/metadata-protocol/src/protocol.graft-normalized-operators.test.ts +++ b/packages/metadata-protocol/src/protocol.graft-normalized-operators.test.ts @@ -97,17 +97,28 @@ describe('graftNormalizedOperators — through the real view metadata schema', ( expect(graftThroughSchema(authored)).toBe(authored); }); + // [#20301] The nested carrier is the page-list preset bar, `userFilters.tabs[]`: + // the list view's own `tabs` is a retired key the parse now refuses, and + // `ViewTabSchema` — whose `filter` this leg exercises — survives only there. it('normalizes a tab filter, not just the view filter', () => { const authored = view( [{ field: 'status', operator: 'eq', value: 'open' }], - { tabs: [{ name: 'mine', label: 'Mine', filter: [{ field: 'owner', operator: 'isNotNull' }] }] }, + { + userFilters: { + element: 'tabs', + tabs: [{ name: 'mine', label: 'Mine', filter: [{ field: 'owner', operator: 'isNotNull' }] }], + }, + }, ); const out = graftThroughSchema(authored) as { filter: Array<{ operator: string }>; - tabs: Array<{ filter: Array<{ operator: string }> }>; + userFilters: { element: string; tabs: Array<{ name: string; filter: Array<{ operator: string }> }> }; }; expect(out.filter[0].operator).toBe('equals'); - expect(out.tabs[0].filter[0].operator).toBe('is_not_null'); + expect(out.userFilters.tabs[0].filter[0].operator).toBe('is_not_null'); + // Only the operator moved: the preset bar's own keys ride through as authored. + expect(out.userFilters.element).toBe('tabs'); + expect(out.userFilters.tabs[0].name).toBe('mine'); }); }); diff --git a/packages/metadata-protocol/src/protocol.read-seam-empty-accumulator.test.ts b/packages/metadata-protocol/src/protocol.read-seam-empty-accumulator.test.ts index 2ffe326c067..c970a51893d 100644 --- a/packages/metadata-protocol/src/protocol.read-seam-empty-accumulator.test.ts +++ b/packages/metadata-protocol/src/protocol.read-seam-empty-accumulator.test.ts @@ -41,6 +41,7 @@ import { describe, it, expect, vi } from 'vitest'; import { ErrorCode } from '@objectstack/spec/api'; import { ObjectStackProtocolImplementation } from './protocol.js'; +import { REFERENCE_SITES } from './reference-sites.js'; import { assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core'; interface FixtureObject { @@ -318,11 +319,11 @@ describe('[#11754] searchAll — a registry that cannot ENUMERATE is not a regis describe('[#8896] findReferencesToMeta — a source type that could not be READ is not a source type with no references', () => { /** - * `view` is reachable from four source types (`app`, `object`, `page`, - * `view`), so a single failing source type leaves the others answering — - * which is exactly the pre-fix trap: a SHORT list that looks complete. - * `page` carries a real reference to `my_view`, so the healthy half is - * observable. + * `view` is reachable from several source types (`app`, `page`, `view` — + * `object` left the set with #20301, below), so a single failing source type + * leaves the others answering — which is exactly the pre-fix trap: a SHORT + * list that looks complete. `page` carries a real reference to `my_view`, so + * the healthy half is observable. * * [#9190] The fixture used to spell that reference `page.viewName`, which * `PageSchema` does not declare — it agreed with the hand-curated path @@ -367,10 +368,21 @@ describe('[#8896] findReferencesToMeta — a source type that could not be READ }, ]); // Every source type that can name a view was really consulted — this is - // what makes "one of them failed" a meaningful condition below. + // what makes "one of them failed" a meaningful condition below. The + // population is the DERIVED index's, not a remembered list, so a source + // type that gains or loses a view-reference site moves this control with it. expect(typeReads).toContain('app'); - expect(typeReads).toContain('object'); expect(typeReads).toContain('page'); + const viewSources = new Set((REFERENCE_SITES.byTarget.get('view') ?? []).map((s) => s.fromType)); + expect(viewSources.size, 'the derived index names no source type for `view`: the walk is broken').toBeGreaterThan(1); + for (const source of viewSources) expect(typeReads, `${source} can name a view`).toContain(source); + // [#20301] `object` is no longer one of them. Its one view-reference site + // was `listViews.*.tabs[].view` — a `ViewTab` naming a list view — and the + // list view's own `tabs` is now a retired key (`retiredKey()`, its input + // type `never`), so the walk finds no `view`-spelled property under + // `object` and the scan correctly stops reading `object` rows for a view. + expect(viewSources.has('object'), 'an object names no view since list-view `tabs` retired').toBe(false); + expect(typeReads).not.toContain('object'); }); it('a source type whose read FAILS fails the whole scan, envelope intact', async () => { diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index dd38be2d098..73008e9f6bf 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -967,37 +967,6 @@ export const enMetadataForms: NonNullable = { label: "User Filters", helpText: "Quick-filter bar: element style (dropdown / tabs / toggle) + exposed fields or tab presets" }, - tabs: { - label: "Tabs", - helpText: "In-view filter tabs — each tab applies its own filter rules" - }, - "tabs.name": { - label: "Name" - }, - "tabs.label": { - label: "Label" - }, - "tabs.icon": { - label: "Icon" - }, - "tabs.view": { - label: "List View" - }, - "tabs.filter": { - label: "Filter" - }, - "tabs.order": { - label: "Display Order" - }, - "tabs.pinned": { - label: "Pinned" - }, - "tabs.isDefault": { - label: "Default Tab" - }, - "tabs.visible": { - label: "Visible" - }, appearance: { label: "Appearance", helpText: "allowedVisualizations: which renderers users may switch between" diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index a4904f3f112..87ee22f414c 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -967,37 +967,6 @@ export const esESMetadataForms: NonNullable = label: "Filtros de usuario", helpText: "Barra de filtros rápidos: estilo de elemento (dropdown / tabs / toggle) + campos expuestos o preajustes de pestañas" }, - tabs: { - label: "Pestañas", - helpText: "Pestañas de filtro en la vista: cada pestaña aplica sus propias reglas de filtro" - }, - "tabs.name": { - label: "Nombre" - }, - "tabs.label": { - label: "Etiqueta" - }, - "tabs.icon": { - label: "Icono" - }, - "tabs.view": { - label: "Vista de lista" - }, - "tabs.filter": { - label: "Filtro" - }, - "tabs.order": { - label: "Orden de visualización" - }, - "tabs.pinned": { - label: "Fijada" - }, - "tabs.isDefault": { - label: "Pestaña predeterminada" - }, - "tabs.visible": { - label: "Visibilidad" - }, appearance: { label: "Apariencia", helpText: "allowedVisualizations: qué renderizadores pueden alternar los usuarios" diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index 0b4790ab6f3..c79a183f59c 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -967,37 +967,6 @@ export const jaJPMetadataForms: NonNullable = label: "ユーザーフィルター", helpText: "クイックフィルターバー:要素スタイル(dropdown / tabs / toggle)+ 公開フィールドまたはタブプリセット" }, - tabs: { - label: "タブ", - helpText: "ビュー内フィルタータブ——各タブが独自のフィルタールールを適用" - }, - "tabs.name": { - label: "名前" - }, - "tabs.label": { - label: "表示名" - }, - "tabs.icon": { - label: "アイコン" - }, - "tabs.view": { - label: "リストビュー" - }, - "tabs.filter": { - label: "フィルター" - }, - "tabs.order": { - label: "表示順" - }, - "tabs.pinned": { - label: "固定" - }, - "tabs.isDefault": { - label: "既定のタブ" - }, - "tabs.visible": { - label: "表示" - }, appearance: { label: "外観", helpText: "allowedVisualizations:ユーザーが切り替え可能なレンダラー" diff --git a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts index a8c63c6cdd8..e995a24e525 100644 --- a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts @@ -1126,7 +1126,13 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => { // `requiredWhen` and `requiredPermissions`, and the action form's // `patch`, `description`, `errorMessage`, `requiredPermissions` and // `bodyExtra` — authored in all three locales. - expect(translated.length, `${locale} positive control`).toBe(633); + // 623 since #20301: + // the list view's own `tabs` is a retired key, and its form repeater left + // `view.form.ts` with it — the repeater's own label and its nine row labels + // (`name`, `label`, `icon`, `view`, `filter`, `order`, `pinned`, + // `isDefault`, `visible`), ten leaves authored in all three locales, out of + // the catalog. + expect(translated.length, `${locale} positive control`).toBe(623); } // ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the // all-three predicate returns 0 while the per-locale one returns 1, so the diff --git a/packages/platform-objects/src/apps/translations/repeater-row-properties.test.ts b/packages/platform-objects/src/apps/translations/repeater-row-properties.test.ts index 6f03cde6c60..c4c07023e45 100644 --- a/packages/platform-objects/src/apps/translations/repeater-row-properties.test.ts +++ b/packages/platform-objects/src/apps/translations/repeater-row-properties.test.ts @@ -30,6 +30,8 @@ // enumerated tomorrow is red on the day it lands rather than a month later. // `view.columns` / `view.sort` / `view.tabs` joined it with #19955: #17507 // titled their item schemas, and `view.form.ts` now enumerates their children. +// `view.tabs` left it again with #20301: the list view's own `tabs` is a +// retired key, and its repeater left `view.form.ts` with it. import { describe, it, expect } from 'vitest'; @@ -139,12 +141,18 @@ describe('#17508 — the enumerated-repeater survey itself (controls before verd for (const type of ['action', 'app', 'dashboard', 'dataset', 'field', 'flow', 'page', 'report', 'skill', 'view']) { expect(carrying.has(type), `${type} enumerates a repeater's row properties`).toBe(true); } - // Lit — the three `view` repeaters are inside the population (#19955). - // `view.columns` is the union case: a string-array arm beside the + // Lit — the two surviving `view` repeaters are inside the population + // (#19955). `view.columns` is the union case: a string-array arm beside the // object-array arm whose properties these are. - for (const carrier of ['view:columns', 'view:sort', 'view:tabs']) { + for (const carrier of ['view:columns', 'view:sort']) { expect(CARRIERS, `${carrier} enumerates its row properties`).toContain(carrier); } + // Dark — `view:tabs` was the third, and it is GONE: the list view's own + // `tabs` is a `retiredKey()` tombstone (#20301), so a form row for it would + // be an input for a key the parse refuses. Its reappearance is a red here, + // not a silent regrowth of the catalog. + expect(CARRIERS, 'the retired `view.tabs` repeater must not come back').not.toContain('view:tabs'); + expect(ROW_PROPERTIES.some((p) => p.id.startsWith('view:tabs.')), 'no `view:tabs.*` row property').toBe(false); // Dark — forms that enumerate none contribute none. A walk that matched // everything, or nothing, cannot pass both halves. for (const type of ['agent', 'tool', 'hook', 'position']) { diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index e86f86bef74..44d3b0cd995 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -967,37 +967,6 @@ export const zhCNMetadataForms: NonNullable = label: "用户筛选器", helpText: "快速筛选栏:控件样式(dropdown / tabs / toggle)+ 暴露的字段或标签页预设" }, - tabs: { - label: "标签页", - helpText: "视图内筛选标签页——每个标签页应用各自的筛选规则" - }, - "tabs.name": { - label: "名称" - }, - "tabs.label": { - label: "显示名称" - }, - "tabs.icon": { - label: "图标" - }, - "tabs.view": { - label: "列表视图" - }, - "tabs.filter": { - label: "筛选" - }, - "tabs.order": { - label: "显示顺序" - }, - "tabs.pinned": { - label: "固定" - }, - "tabs.isDefault": { - label: "默认标签页" - }, - "tabs.visible": { - label: "可见" - }, appearance: { label: "外观", helpText: "allowedVisualizations:允许用户切换的渲染器" diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 60774929718..593727e61dc 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -693,7 +693,7 @@ "ui/ListView:showRecordCount", "ui/ListView:sort", "ui/ListView:striped [RETIRED]", - "ui/ListView:tabs", + "ui/ListView:tabs [RETIRED]", "ui/ListView:timeline", "ui/ListView:tree", "ui/ListView:type", @@ -876,7 +876,7 @@ "ui/ObjectListView:showRecordCount", "ui/ObjectListView:sort", "ui/ObjectListView:striped [RETIRED]", - "ui/ObjectListView:tabs", + "ui/ObjectListView:tabs [RETIRED]", "ui/ObjectListView:timeline", "ui/ObjectListView:tree", "ui/ObjectListView:type", diff --git a/packages/spec/liveness/view.json b/packages/spec/liveness/view.json index 438ca2fe9d2..b65e0730e0b 100644 --- a/packages/spec/liveness/view.json +++ b/packages/spec/liveness/view.json @@ -209,9 +209,9 @@ }, "tabs": { "status": "dead", - "verifiedAt": "2026-09-07", + "verifiedAt": "2026-09-27", "evidenceScope": "cross-repo", - "note": "RE-DERIVED 2026-09-07 (#16362, decision batch #60 option A — the ledger audit that precedes the #16094 authorWarn flip). VERDICT FLIPPED live to dead, and the note this replaces was wrong in BOTH directions at once, which is why the row was worth re-opening. It read: \"objectui: TabBar.tsx — icon/visible/pinned/filter wired (audit L15). Sub-key tabs[].order is NOT used for sorting (audit L20) — dead sub-surface.\" (a) The OVERSTATEMENT, and the reason the row moves: TabBar reads those sub-keys, but NOTHING MOUNTS TabBar. Measured at objectui @a472b071: every ` (a.order ?? 0) - (b.order ?? 0))`. The old note called it a dead sub-surface. Both halves of one sentence were false in opposite directions, which is the two-direction rot the README warns a ledger entry accumulates. CORROBORATION, independent of this measurement: this repo already says so in code — packages/cli/src/utils/i18n-extract.ts scopes its tab-label extractor to a page's `interfaceConfig.userFilters.tabs` and states \"`ListViewSchema.tabs` has no reader in either repo\", deliberately emitting no scaffolding keys for this one. THE hotcrm PREMISE IS UPHELD, NOT CONTRADICTED: hotcrm#1307's `test/view-tab-label-inert.test.ts` asserts the object-view switcher never reads `list.tabs`, and the switcher is plugin-view/src/ViewTabBar.tsx, which takes a `views: ViewTabItem[]` prop (saved views) and reads no `tabs` key at all. So nothing needs filing against that test. (That hotcrm reading is INHERITED from the card and #16094, not re-measured — hotcrm is out of this seat's repo scope; what IS measured here is the objectui switcher it describes.) WHY NOT `live` ON THE TWO AUTHOR-TIME READERS: packages/lint/src/validate-list-view-field-refs.ts#checkTabs and packages/metadata-protocol/src/metadata-diagnostics.ts both walk `tabs[].filter[].field` for reference integrity, and metadata-protocol normalizes filter operators inside it. None of them delivers the key's declared effect (\"Tab definitions for multi-tab view interface\"). That is the route_generation precedent verbatim — an enum validated at the door and then ignored is accept/reject, a different question from liveness — and it is the opposite of dashboard.widgets[].suppressWarnings, whose declared effect IS the lint read. The spec's own ObjectUserFiltersSchema already refuses `tabs` on object views with the prescription \"an object view's tab bar is its saved-view switcher (ViewTabBar)\"; this row now says the same thing about the list slot. ENFORCE-OR-REMOVE (ADR-0049) is a follow-up decision, not this card: the enforce route mounts TabBar and threads `list.tabs` into it, the remove route retires the key and its ViewTabSchema carrier on this slot. Not authorWarn-ed here — the authorWarn field is #16094's." + "note": "REMOVED 2026-09-27 (#20301, ADR-0049 enforce-or-remove; triage verdict RETIRE under the maintainer's #18900 criterion — mainstream named-view switching is already delivered here, by `listViews` rendered in ViewTabBar) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error at every list-view door built from the shape: ListViewSchema, ObjectListViewSchema and the flattened overlay arm) and stripped from stored rows and sources by the protocol-18 view-list-tabs-removed conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); to give end users one tab per preset, declare each tab as a named entry under the object's `listViews` — every entry renders as a tab in the saved-view switcher. RE-MEASURED 2026-09-27 before removal, with lit controls: objectui at the pinned sha f8a9d0fb — every ` + kind === 'list' ? stripKeys(payload, ['tabs'], emit, path) : payload); + }, + fixture: { + before: { + views: [ + // A container: the default `list` and one named entry carry the key, + // the other named entry does not and rides through by reference. + { + object: 'crm_ticket', + list: { + type: 'grid', + columns: ['subject'], + tabs: [{ name: 'mine', label: 'Mine', filter: [{ field: 'status', operator: 'equals', value: 'open' }] }], + }, + listViews: { + triage: { type: 'grid', columns: ['subject'], tabs: [{ name: 'urgent', label: 'Urgent' }] }, + all: { type: 'grid', columns: ['subject'] }, + }, + }, + // A ViewItem record: the payload hangs off `config`. + { + name: 'crm_ticket.queue', + object: 'crm_ticket', + viewKind: 'list', + config: { type: 'grid', columns: ['subject'], tabs: [] }, + }, + // A form payload has no `tabs` key to strip — untouched. + { name: 'crm_ticket.intake', object: 'crm_ticket', viewKind: 'form', config: { type: 'simple' } }, + ], + }, + after: { + views: [ + { + object: 'crm_ticket', + list: { type: 'grid', columns: ['subject'] }, + listViews: { + triage: { type: 'grid', columns: ['subject'] }, + all: { type: 'grid', columns: ['subject'] }, + }, + }, + { + name: 'crm_ticket.queue', + object: 'crm_ticket', + viewKind: 'list', + config: { type: 'grid', columns: ['subject'] }, + }, + { name: 'crm_ticket.intake', object: 'crm_ticket', viewKind: 'form', config: { type: 'simple' } }, + ], + }, + // One notice per key removed: `list`, `listViews.triage`, the record's `config`. + expectedNotices: 3, + }, +}; + /** * The page-component types whose `properties.filter` is a converged rule-array * door: every `ComponentPropsMap` row whose `filter` answers the record form @@ -12284,6 +12392,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly> // which is a different key on a different surface and has always rendered. D2: // `view-page-mount-removed`. 'ui/ListView:pageName', + // #20301 (ADR-0049 enforce-or-remove; triage verdict RETIRE under the + // maintainer's #18900 criterion). `ListView.tabs` declared tab definitions for + // a multi-tab view interface, and no renderer ever drew them: a list view's own + // `tabs` has no reader, and objectui's `TabBar`, the one component that would + // draw it, has no production mount. The tab strip above an object's records is + // the saved-view switcher (`ViewTabBar`), which renders one tab per `listViews` + // entry and reads no `tabs` key. `userFilters.tabs`, a different key of the + // same element type, is read and rendered, and stays. Tombstoned with + // `retiredKey()` beside the `pageName` tombstone already on this shape; + // `ViewTabSchema` stays, reused by the page-only `userFilters.tabs` preset bar. + // D2: `view-list-tabs-removed`. + 'ui/ListView:tabs', // #16885 — the list view's `navigation.view` binding, retired under ADR-0049 // enforce-or-remove by maintainer ruling 2026-09-13 (director decision batch // #126 item 4, verbatim 「同意」, option B). The key's describe promised "the @@ -20927,6 +20985,18 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // which is a different key on a different surface and has always rendered. D2: // `view-page-mount-removed`. 'ui/ObjectListView:pageName', + // #20301 (ADR-0049 enforce-or-remove; triage verdict RETIRE under the + // maintainer's #18900 criterion). `ObjectListView.tabs` declared tab + // definitions for a multi-tab view interface, and no renderer ever drew them: a + // list view's own `tabs` has no reader, and objectui's `TabBar`, the one + // component that would draw it, has no production mount. The tab strip above an + // object's records is the saved-view switcher (`ViewTabBar`), which renders one + // tab per `listViews` entry and reads no `tabs` key. `userFilters.tabs`, a + // different key of the same element type, is read and rendered, and stays. + // Tombstoned with `retiredKey()` beside the `pageName` tombstone already on + // this shape; `ViewTabSchema` stays, reused by the page-only `userFilters.tabs` + // preset bar. D2: `view-list-tabs-removed`. + 'ui/ObjectListView:tabs', // ADR-0090 D2 (no Profile concept) + ADR-0049 enforce-or-remove; maintainer // ruling 2026-09-12, decision batch #121 item 2, verbatim 「同意」. // `Page.assignedProfiles` was an authorable key named for the concept ADR-0090 D2 diff --git a/packages/spec/src/ui/view-authoring-wire-split.test.ts b/packages/spec/src/ui/view-authoring-wire-split.test.ts index 8cb59b25f03..4cb989ed9a7 100644 --- a/packages/spec/src/ui/view-authoring-wire-split.test.ts +++ b/packages/spec/src/ui/view-authoring-wire-split.test.ts @@ -244,7 +244,8 @@ describe('#5074 — the wire door accepts what the platform itself writes', () = it.each([ ['flattened overlay — `sort[]`', { ...OVERLAY_BASE, sort: [CONSOLE_SORT_ROW] }], ['flattened overlay — `filter[]`', { ...OVERLAY_BASE, filter: [CONSOLE_FILTER_ROW] }], - ['flattened overlay — `tabs[].filter[]`', { ...OVERLAY_BASE, tabs: [{ name: 'won', label: 'Won', filter: [CONSOLE_FILTER_ROW] }] }], + // [#20301] The `tabs[].filter[]` row left with the list view's own `tabs` + // (retired); the surviving `ViewTabSchema` carrier is the row below. ['flattened overlay — `userFilters.tabs[].filter[]`', { ...OVERLAY_BASE, userFilters: { element: 'tabs', tabs: [{ name: 'won', label: 'Won', filter: [CONSOLE_FILTER_ROW] }] }, @@ -316,11 +317,13 @@ describe('#5074 — `stripViewConsoleDecorations`, the write-path mirror of `str ...OVERLAY_BASE, sort: [CONSOLE_SORT_ROW], filter: [CONSOLE_FILTER_ROW], - tabs: [{ name: 'won', filter: [CONSOLE_FILTER_ROW] }], + // [#20301] The tab carrier is `userFilters.tabs` — the list view's own + // `tabs` is retired. + userFilters: { element: 'tabs', tabs: [{ name: 'won', filter: [CONSOLE_FILTER_ROW] }] }, }) as Record; expect(out.sort[0]).toEqual({ field: 'estimate_hours', order: 'desc' }); expect(out.filter[0]).toEqual({ field: 'stage', operator: 'equals', value: 'won' }); - expect(out.tabs[0].filter[0]).not.toHaveProperty('id'); + expect(out.userFilters.tabs[0].filter[0]).not.toHaveProperty('id'); }); it('leaves a TOP-LEVEL `id` alone — only builder ROWS are decorated', () => { diff --git a/packages/spec/src/ui/view-filter-rule-wire-id.test.ts b/packages/spec/src/ui/view-filter-rule-wire-id.test.ts index d45fce9b04c..67eed23fa5f 100644 --- a/packages/spec/src/ui/view-filter-rule-wire-id.test.ts +++ b/packages/spec/src/ui/view-filter-rule-wire-id.test.ts @@ -133,11 +133,13 @@ describe('#5114 — a console-written filter row, judged per door (#5074)', () = // The second carrier of `ViewFilterRuleSchema` in this file — the per-tab // filter Studio writes through the SAME builder widget, so it stamps the // same `id`. Probed at its own path because strictness does not recurse in - // either direction. + // either direction. [#20301] Carried by the `userFilters.tabs` preset bar + // since the list view's own `tabs` was retired; `ViewTabSchema` is the + // same item schema on both. expect( ViewMetadataSchema.safeParse({ ...CONSOLE_PUT_BODY, - tabs: [{ name: 'won', label: 'Won', filter: [CONSOLE_FILTER_ROW] }], + userFilters: { element: 'tabs', tabs: [{ name: 'won', label: 'Won', filter: [CONSOLE_FILTER_ROW] }] }, }).success, ).toBe(true); }); diff --git a/packages/spec/src/ui/view-list-tabs-retirement.test.ts b/packages/spec/src/ui/view-list-tabs-retirement.test.ts new file mode 100644 index 00000000000..b325384bd60 --- /dev/null +++ b/packages/spec/src/ui/view-list-tabs-retirement.test.ts @@ -0,0 +1,566 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The list view's own `tabs` RETIRED (#20301) — ADR-0049 enforce-or-remove; + * triage verdict RETIRE under the maintainer's #18900 criterion (mainstream + * named-view switching is already delivered here, by `listViews`). + * + * `ListViewSchema.tabs` parsed at every list-view door, was stored, and drew + * nothing. Measured before removal, with lit controls, and recorded on the + * ledger row (`liveness/view.json`, `/props/list/children/tabs`): a list + * view's own `tabs` has no reader, and objectui's `TabBar` — the one component + * that would draw it — has zero production mounts at the pinned sha, while + * `ViewTabBar`, the saved-view switcher, mounts in the object view and is fed + * from `listViews`. `userFilters.tabs`, a different key of the same element + * type, is read and rendered (the page preset bar) and stays — the BOUNDARY + * pinned below. + * + * Bookkeeping shapes, pinned below: + * 1. A `retiredKey()` tombstone on the SHAPE every list-view door is built + * from — `ListViewSchema`, `ObjectListViewSchema` (a container's `list` / + * `listViews`, an object's `listViews`) and the flattened overlay arm — so + * each refuses with the prescription, and `tsc` refuses at the call site. + * 2. `ViewTabSchema` is NOT retired: the page-only `userFilters.tabs` preset + * bar reuses it and renders. Pinned as a BOUNDARY. + * 3. D2 conversion `view-list-tabs-removed` (step 18) over `stack.views[]` + * in all three persisted spellings; `objects[].listViews.*` is reached by + * no conversion — pinned as a declared boundary, not discovered later. + * 4. `RETIRED_KEYS_BY_MAJOR[18]` carries `ui/ListView:tabs` and + * `ui/ObjectListView:tabs`; the D3 entry `list-view-tabs-retired` rides + * beside the D2 (ruling B on #17152), with no tracker number in any + * author-shown field. + * 5. The metadata form's `tabs` repeater left with the key. + * + * On the assertion set (the #13823 precedent): a schema refusal raises a + * `ZodError` whose issues carry `code` and `path` but no ADR-0112 `status` — + * that envelope belongs to the API error surface. So these pins assert the + * strongest set this surface has: refusal, the issue `code`, the `path` naming + * the key, and the prescription text. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { collectConversionNotices } from '../conversions/apply'; +import { applyConversionsToStoredItem } from '../conversions/stored'; +import { ObjectSchema } from '../data/object.zod'; +import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; +import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; +import { viewForm } from './view.form'; +import { + ListViewSchema, + ObjectListViewSchema, + ViewItemSchema, + ViewMetadataSchema, + ViewSchema, + ViewTabSchema, + defineView, +} from './view.zod'; + +// Unanchored, because a thrown `ZodError`'s message is the JSON of its issues; +// the key-first house convention is asserted on the issue message itself below. +const PRESCRIPTION = + /`view\.list\.tabs` was removed in @objectstack\/spec 17\.5\.0 \(ADR-0049 enforce-or-remove\).*Delete the key, and move each tab.*`listViews`.*`os migrate meta --from 17`/s; + +const TABS = [{ name: 'mine', label: 'Mine', filter: [{ field: 'status', operator: 'equals', value: 'open' }] }]; +const LIST = { type: 'grid', columns: ['subject'] } as const; + +type Issue = { code: string; path: PropertyKey[]; message: string; expected?: string; errors?: Issue[][] }; + +/** Walk `invalid_union` wrappers and return every issue, nested arms included. */ +const flatten = (issues: readonly Issue[]): Issue[] => + issues.flatMap((i) => + i.code === 'invalid_union' && Array.isArray(i.errors) ? [i, ...flatten(i.errors.flat())] : [i]); + +/** + * Every door a list-view payload is judged at, with the path its `tabs` key + * sits at there. Each builds the body around the SAME list payload, so a door + * that silently stopped carrying the tombstone is the only way a row can go + * green without a refusal. + */ +const DOORS: ReadonlyArray) => { success: boolean; error?: { issues: readonly unknown[] } }, string]> = [ + ['ListViewSchema', (list) => ListViewSchema.safeParse(list), 'tabs'], + ['ObjectListViewSchema', (list) => ObjectListViewSchema.safeParse(list), 'tabs'], + ['ViewSchema (defineView) — the default `list`', (list) => ViewSchema.safeParse({ list }), 'list.tabs'], + ['ViewSchema (defineView) — a named `listViews` entry', (list) => ViewSchema.safeParse({ list: LIST, listViews: { triage: list } }), 'listViews.triage.tabs'], + [ + 'ViewItemSchema — a record\'s `config`', + (list) => ViewItemSchema.safeParse({ name: 'crm_ticket.queue', object: 'crm_ticket', viewKind: 'list', config: list }), + 'config.tabs', + ], + [ + 'the `view` write door — a flattened list overlay (PUT /api/v1/meta/view)', + (list) => ViewMetadataSchema.safeParse({ name: 'crm_ticket.queue', object: 'crm_ticket', viewKind: 'list', ...list }), + 'tabs', + ], + [ + 'ObjectSchema — an object\'s own `listViews`', + (list) => ObjectSchema.safeParse({ name: 'crm_ticket', fields: { subject: { type: 'text' } }, listViews: { triage: list } }), + 'listViews.triage.tabs', + ], +]; + +describe('list-view tabs retirement — the tombstone, at every list-view door', () => { + it.each(DOORS)('%s refuses `tabs` at its path, with the prescription', (_label, parse, at) => { + const r = parse({ ...LIST, tabs: TABS }); + expect(r.success).toBe(false); + // Select the TOMBSTONE issue by the shape `retiredKey()` raises, not by its + // text: the union doors also lift the text onto a path-less wrapper. + const issue = flatten((r.error?.issues ?? []) as Issue[]).find((i) => i.expected === 'never'); + expect(issue, JSON.stringify(r.error?.issues)).toBeDefined(); + expect(issue!.code).toBe('invalid_type'); + expect(issue!.path.join('.')).toBe(at); + expect(issue!.message).toMatch(PRESCRIPTION); + // House convention 1: the fully-qualified key, in backticks, opens it. + expect(issue!.message.startsWith('`view.list.tabs` was removed')).toBe(true); + }); + + it.each(DOORS)('CONTROL: %s accepts the same body without `tabs`, and grows no `tabs`', (_label, parse) => { + const r = parse({ ...LIST }); + expect(r.success, JSON.stringify(r.error?.issues)).toBe(true); + }); + + it('an empty `tabs: []` is refused too — the key is gone, not merely emptied', () => { + const r = ListViewSchema.safeParse({ ...LIST, tabs: [] }); + expect(r.success).toBe(false); + }); + + it('the `view` registry binding is the union these pins exercise', () => { + // A rebinding of `saveMetaItem`'s schema to some third shape would pass + // the door pins above and still accept the key in production. + expect(getMetadataTypeSchema('view')).toBe(ViewMetadataSchema); + }); + + it('the prescription names the one-line move to `listViews`, and carries no tracker number', () => { + const r = ListViewSchema.safeParse({ ...LIST, tabs: TABS }); + const message = (r.error?.issues ?? []).map((i) => i.message).join('\n'); + expect(message).toContain("the tab's `name` becomes the entry's key"); + expect(message).toContain('Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.'); + expect(message).not.toMatch(/#\d/); + }); + + it('fails tsc at the authoring site: the input type of `tabs` is `never`', () => { + const attempt = () => + defineView({ + list: { + type: 'grid', + columns: ['subject'], + // @ts-expect-error — `tabs` is a retiredKey() tombstone: its input type is `never`. + tabs: [{ name: 'mine', label: 'Mine' }], + }, + }); + // The parse channel agrees with the type channel on the same literal. + expect(attempt).toThrow(PRESCRIPTION); + }); +}); + +describe('list-view tabs retirement — the BOUNDARY: `ViewTabSchema` stays, on the page-only preset bar', () => { + it('a page list\'s `userFilters.tabs` preset bar still parses — a different key that renders', () => { + const r = ListViewSchema.safeParse({ ...LIST, userFilters: { element: 'tabs', tabs: TABS } }); + expect(r.success, JSON.stringify(r.error?.issues)).toBe(true); + expect(ViewTabSchema.safeParse(TABS[0]).success).toBe(true); + }); + + it('the metadata form offers no `tabs` input on a list view any more', () => { + const paths: string[] = []; + const walk = (fields: unknown, prefix: string) => { + for (const f of (Array.isArray(fields) ? fields : []) as Array<{ field?: string; fields?: unknown }>) { + if (!f?.field) continue; + const p = prefix ? `${prefix}.${f.field}` : f.field; + paths.push(p); + walk(f.fields, p); + } + }; + for (const section of (viewForm as unknown as { sections: Array<{ fields: unknown }> }).sections) { + walk(section.fields, ''); + } + // Anti-vacuity: the walk reached the neighbours of the removed input. + expect(paths).toContain('userFilters'); + expect(paths).toContain('appearance'); + expect(paths).not.toContain('tabs'); + }); +}); + +describe('list-view tabs retirement — the D2 conversion', () => { + it('a STORED view container carrying the key replays clean through the rehydration seam', () => { + // `database-loader.ts` replays the chain over every stored row it loads; + // the seam wraps a `view` row as `{ views: [row] }`. + const stored = { + name: 'crm_ticket', + object: 'crm_ticket', + list: { ...LIST, tabs: TABS }, + listViews: { triage: { ...LIST, tabs: [] }, all: { ...LIST } }, + }; + const notices: { conversionId?: string; path?: string }[] = []; + const rehydrated = applyConversionsToStoredItem('view', stored, { + onNotice: (n) => notices.push(n as { conversionId?: string; path?: string }), + }) as Record; + + expect(notices.map((n) => n.conversionId)).toEqual(['view-list-tabs-removed', 'view-list-tabs-removed']); + expect(notices.map((n) => n.path)).toEqual(['views[0].list.tabs', 'views[0].listViews.triage.tabs']); + expect(rehydrated.list).not.toHaveProperty('tabs'); + expect(rehydrated.listViews.triage).not.toHaveProperty('tabs'); + // CONTROL: the live keys on the same payloads survive byte-for-byte. + expect(rehydrated.list).toEqual(LIST); + expect(rehydrated.listViews.all).toEqual(LIST); + // And the rehydrated row is exactly what the write door accepts now. + expect(ViewMetadataSchema.safeParse(rehydrated).success).toBe(true); + }); + + it('reaches a ViewItem record\'s `config` and a flattened overlay, never a form payload', () => { + const { stack, notices } = collectConversionNotices( + { + views: [ + { name: 'crm_ticket.queue', object: 'crm_ticket', viewKind: 'list', config: { ...LIST, tabs: TABS } }, + { name: 'crm_ticket.board', object: 'crm_ticket', viewKind: 'list', ...LIST, tabs: TABS }, + { name: 'crm_ticket.intake', object: 'crm_ticket', viewKind: 'form', config: { type: 'simple' } }, + ], + }, + { includeRetired: true }, + ); + const own = notices.filter((n) => n.conversionId === 'view-list-tabs-removed'); + expect(own.map((n) => n.path)).toEqual(['views[0].config.tabs', 'views[1].tabs']); + const views = stack.views as Record[]; + expect(views[0]!.config).toEqual(LIST); + expect(views[1]).not.toHaveProperty('tabs'); + expect(views[2]).toEqual({ name: 'crm_ticket.intake', object: 'crm_ticket', viewKind: 'form', config: { type: 'simple' } }); + + // Idempotence, measured: a second replay converts nothing and hands the + // input back by reference (copy-on-write). + const replay = collectConversionNotices(stack, { includeRetired: true }); + expect(replay.notices.filter((n) => n.conversionId === 'view-list-tabs-removed')).toHaveLength(0); + }); + + it('is retired from the load path — a live author is refused at parse, never silently rewritten', () => { + const { stack, notices } = collectConversionNotices({ views: [{ object: 'crm_ticket', list: { ...LIST, tabs: TABS } }] }); + expect(notices.filter((n) => n.conversionId === 'view-list-tabs-removed')).toHaveLength(0); + expect((stack.views as Record[])[0]!.list.tabs).toEqual(TABS); + }); + + it('BOUNDARY: an object\'s own `listViews` is reached by no conversion — refused at its door instead', () => { + const notices: { conversionId?: string }[] = []; + const row = { name: 'crm_ticket', fields: { subject: { type: 'text' } }, listViews: { triage: { ...LIST, tabs: TABS } } }; + const out = applyConversionsToStoredItem('object', row, { + onNotice: (n) => notices.push(n as { conversionId?: string }), + }) as Record; + expect(notices.filter((n) => n.conversionId === 'view-list-tabs-removed')).toHaveLength(0); + expect(out.listViews.triage.tabs).toEqual(TABS); + expect(ObjectSchema.safeParse(out).success).toBe(false); + }); +}); + +describe('list-view tabs retirement — ADR-0087 registration', () => { + it('declares the key on both list-view defs under major 18, with the D2 conversion in the step-18 chain', () => { + expect(RETIRED_KEYS_BY_MAJOR[18]).toContain('ui/ListView:tabs'); + expect(RETIRED_KEYS_BY_MAJOR[18]).toContain('ui/ObjectListView:tabs'); + expect(MIGRATIONS_BY_MAJOR[18]!.conversionIds).toContain('view-list-tabs-removed'); + }); + + it('carries its D3 entry beside the D2 (ruling B), with no tracker number in any author-shown field', () => { + const entry = MIGRATIONS_BY_MAJOR[18]!.semantic.find((s) => s.id === 'list-view-tabs-retired'); + expect(entry, 'the family owes one D3 entry even though its D2 is lossless').toBeDefined(); + for (const field of ['surface', 'replacement', 'reason', 'acceptanceCriteria'] as const) { + expect(entry![field], field).not.toMatch(/#\d/); + } + expect(entry!.replacement).toContain('`listViews`'); + }); +}); + +// ─── Tree-scoped absence, with a DECLARED radius ───────────────────────────── +// +// `tsc` is the primary sweeper — `retiredKey()` types the key `never` on every +// list-view input, so every typed authoring site fails to compile. The residue +// is what `tsc` never judges: JSON, YAML, MD/MDX code fences, untyped `.js`, +// and TS literals typed `unknown` (a test body handed to a raw walker). This +// walk covers that residue across the five roots already declared for +// `@objectstack/spec#test` in `scripts/cross-package-test-inputs.mjs` and +// mirrored in `turbo.json` — the same roots and extensions the view-item +// owner/hidden pin walks. +// +// ⭐ `tabs` is a common key (page slots, the `userFilters` preset bar, studio +// preview config …), so a textual matcher would be all noise. The matcher is +// STRUCTURAL: an offender is one object literal (or one YAML mapping) whose +// OWN keys include `tabs` AND a key only a list-view payload carries. The +// `userFilters` object itself (`element` / `fields` / `tabs`) carries none of +// them, so the surviving preset bar is not matched. +// +// The bound, stated: a payload assembled by SPREAD (`{ ...list, tabs }`) or +// computed keys is invisible to a text walk; `docs/**`, `.claude/**`, +// `.github/**` and the repo-root files are outside the radius. +describe('tree-scoped absence: no list-view payload inside the declared radius still carries `tabs`', () => { + const SPEC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + const REPO_ROOT = path.resolve(SPEC_ROOT, '../..'); + const THIS_FILE = path.relative(REPO_ROOT, fileURLToPath(import.meta.url)).split(path.sep).join('/'); + + /** The walked roots — declared in `scripts/cross-package-test-inputs.mjs` under `@objectstack/spec`. */ + const WALK_ROOTS = ['packages', 'examples', 'skills', 'content', 'scripts']; + const SCANNED_EXT = new Set(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx', '.yaml', '.yml']); + /** Under `examples/` only the non-code extensions are scanned AND declared. */ + const EXAMPLES_EXT = new Set(['.json', '.md', '.mdx', '.yaml', '.yml']); + const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', '.cache', '.objectstack', 'coverage', '.next', '.source']); + + /** Keys only a list-view PAYLOAD carries beside its own `tabs`. */ + const LIST_VIEW_SIBLINGS = new Set([ + 'columns', 'filter', 'sort', 'data', 'kanban', 'calendar', 'gantt', 'gallery', 'timeline', 'grouping', + 'rowColor', 'hiddenFields', 'fieldOrder', 'filterableFields', 'searchableFields', 'userFilters', + 'appearance', 'userActions', 'addRecord', 'pagination', 'selection', 'rowActions', 'bulkActions', + 'emptyState', 'showRecordCount', 'allowPrinting', 'conditionalFormatting', 'inlineEdit', 'exportOptions', + 'rowHeight', + ]); + + /** + * Structural exclusions — the retirement kit, each with its reason. ⛔ NOT an + * allowlist file (`spec-property-retirement` §4): every entry's JOB is to + * spell the retired key on a list-view payload. + */ + const EXCLUDED = new Set([ + // The tombstone itself: the list-view SHAPE literal declares `tabs` beside + // every sibling above. Schema source, not an authoring. + 'packages/spec/src/ui/view.zod.ts', + // This pin authors the key at every door to assert the refusal. + THIS_FILE, + ]); + const EXCLUDED_PREFIXES = [ + // The D2 conversion's fixture authors the pre-retirement payload on purpose. + 'packages/spec/src/conversions/', + // GITIGNORED build output reached only because this is a FILESYSTEM walk: + // `retiredKey()` emits the tombstone into the generated JSON Schema's + // `properties` beside the siblings. Its source, `view.zod.ts`, is excluded + // above for the same reason. + 'packages/spec/json-schema/', + // Release-owned prose records the removal; never edited by a code PR. + 'content/docs/releases/', + // The liveness ledger keys its rows by the SCHEMA's key names, so the list + // slot's `children` object spells `tabs` beside `columns` / `filter` — and + // the tombstone discipline requires that row to STAY (`dead`, REMOVED note). + 'packages/spec/liveness/', + ]; + /** + * RESIDUE, declared and self-expiring — not exempted. Test fixtures of the + * two author-time reference walks that still read a list view's `tabs` off + * RAW input (`packages/lint/src/validate-list-view-field-refs.ts#checkTabs`, + * and `computeViewReferenceDiagnostics` in `@objectstack/metadata-protocol`) and + * the CLI's negative i18n pin. Removing those walks is outside this + * retirement's file surface and is reported as its follow-up; each entry here + * is asserted to STILL hold an offender, so the day the follow-up deletes a + * fixture this set goes red and the entry leaves with it. + */ + const RESIDUE = new Set([ + 'packages/cli/test/i18n-tab-coverage.test.ts', + 'packages/lint/src/validate-list-view-field-refs.test.ts', + 'packages/objectql/src/metadata-diagnostics.test.ts', + ]); + /** tsup's own bundle of `tsup.config.ts`, written and deleted mid-build. */ + const TSUP_BUNDLED_CONFIG = /\.bundled_[^./]+\.mjs$/; + + const isOffendingKeySet = (keys: Set): boolean => + keys.has('tabs') && [...keys].some((k) => LIST_VIEW_SIBLINGS.has(k)); + + /** + * One pass over JS/TS/JSON text: a stack of bracket frames, each `{` frame + * collecting its OWN keys — an identifier or quoted string in key position + * (after `{` or `,`) followed by `:`. Strings and comments are skipped; a + * single- or double-quoted string never spans a line, so a mis-lexed quote + * (a regex literal) costs at most that line. Returns the 1-based line of each + * closing brace whose frame is an offender. (The view-item pin's lexer.) + */ + const lexOffenders = (text: string): number[] => { + const out: number[] = []; + const stack: { kind: string; keys: Set }[] = []; + let lastSig = ''; + let line = 1; + let i = 0; + const n = text.length; + while (i < n) { + const c = text[i]!; + if (c === '\n') { line += 1; i += 1; continue; } + if (c === '/' && text[i + 1] === '/') { while (i < n && text[i] !== '\n') i += 1; continue; } + if (c === '/' && text[i + 1] === '*') { + i += 2; + while (i < n && !(text[i] === '*' && text[i + 1] === '/')) { if (text[i] === '\n') line += 1; i += 1; } + i += 2; + continue; + } + if (c === '"' || c === "'" || c === '`') { + const start = i; + i += 1; + while (i < n && text[i] !== c) { + if (text[i] === '\\') i += 1; + else if (text[i] === '\n') { if (c !== '`') break; line += 1; } + i += 1; + } + const token = text.slice(start + 1, i); + i += 1; + let j = i; + while (j < n && (text[j] === ' ' || text[j] === '\t')) j += 1; + const top = stack[stack.length - 1]; + if (c !== '`' && text[j] === ':' && top?.kind === '{' && (lastSig === '{' || lastSig === ',')) top.keys.add(token); + lastSig = 'str'; + continue; + } + if (/[A-Za-z_$]/.test(c)) { + const start = i; + while (i < n && /[\w$]/.test(text[i]!)) i += 1; + const token = text.slice(start, i); + let j = i; + while (j < n && (text[j] === ' ' || text[j] === '\t')) j += 1; + const top = stack[stack.length - 1]; + if (text[j] === ':' && top?.kind === '{' && (lastSig === '{' || lastSig === ',')) top.keys.add(token); + lastSig = 'id'; + continue; + } + if (c === '{' || c === '[' || c === '(') stack.push({ kind: c, keys: new Set() }); + else if (c === '}' || c === ']' || c === ')') { + const frame = stack.pop(); + if (frame?.kind === '{' && c === '}' && isOffendingKeySet(frame.keys)) out.push(line); + } + if (!/\s/.test(c)) lastSig = c; + i += 1; + } + return out; + }; + + /** + * YAML: a mapping's OWN keys are the key lines at one column, bounded by a + * line at a smaller column or by a new list item at that column. Returns the + * 1-based line of each `tabs` key whose mapping is an offender. + */ + const yamlOffenders = (text: string): number[] => { + const rows = text.split('\n').map((raw, idx) => { + const m = /^(\s*)(-\s+)?([A-Za-z_][\w]*)\s*:/.exec(raw); + return m ? { idx, col: m[1]!.length + (m[2]?.length ?? 0), key: m[3]!, item: Boolean(m[2]) } : null; + }); + const out: number[] = []; + rows.forEach((row, at) => { + if (!row || row.key !== 'tabs') return; + const keys = new Set([row.key]); + if (!row.item) { + for (let k = at - 1; k >= 0; k -= 1) { + const r = rows[k]; + if (!r) continue; + if (r.col < row.col) break; + if (r.col === row.col) { keys.add(r.key); if (r.item) break; } + } + } + for (let k = at + 1; k < rows.length; k += 1) { + const r = rows[k]; + if (!r) continue; + if (r.col < row.col || (r.col === row.col && r.item)) break; + if (r.col === row.col) keys.add(r.key); + } + if (isOffendingKeySet(keys)) out.push(row.idx + 1); + }); + return out; + }; + + /** MD/MDX: only fenced code is judged — prose mentions are not authorings. */ + const markdownOffenders = (text: string): number[] => { + const out: number[] = []; + const fence = /^```([\w-]*)[^\n]*\n([\s\S]*?)^```/gm; + for (let m = fence.exec(text); m; m = fence.exec(text)) { + const lang = m[1]!.toLowerCase(); + const body = m[2]!; + const offset = text.slice(0, m.index).split('\n').length; + const hits = lang === 'yaml' || lang === 'yml' ? yamlOffenders(body) : lexOffenders(body); + for (const h of hits) out.push(offset + h); + } + return out; + }; + + const offendersIn = (ext: string, text: string): number[] => { + if (!/\btabs\b/.test(text)) return []; + if (ext === '.yaml' || ext === '.yml') return yamlOffenders(text); + if (ext === '.md' || ext === '.mdx') return markdownOffenders(text); + return lexOffenders(text); + }; + + const vanished: string[] = []; + /** Tolerates ONLY a path's disappearance mid-walk; every other fault is re-raised. */ + const readIfPresent = (full: string, rel: string): string | undefined => { + try { + return fs.readFileSync(full, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err; + vanished.push(rel); + return undefined; + } + }; + + it('the matcher finds a list-view authoring and ignores every neighbouring shape (anti-vacuity)', () => { + // Offenders — the list-view payload spelling, in each syntax the walk reads. + expect(offendersIn('.ts', "defineView({ list: { type: 'grid', columns: ['a'], tabs: [{ name: 'x' }] } })")).toEqual([1]); + expect(offendersIn('.ts', "const v = {\n columns: ['a'],\n tabs: [],\n};")).toEqual([4]); + expect(offendersIn('.json', '{ "list": { "columns": ["a"], "tabs": [ { "name": "x" } ] } }')).toEqual([1]); + expect(offendersIn('.yaml', 'list:\n type: grid\n columns: [a]\n tabs:\n - name: x\n')).toEqual([4]); + expect(offendersIn('.md', "Prose.\n\n```ts\nlist: { filter: [], tabs: [] }\n```\n")).toEqual([4]); + // Neighbours that must NOT match. + // The surviving page-only preset bar: the `userFilters` object's own keys. + expect(offendersIn('.ts', "({ columns: ['a'], userFilters: { element: 'tabs', fields: [], tabs: [{ name: 'x' }] } })")).toEqual([]); + // A record page's slots: `tabs` beside other slots. + expect(offendersIn('.ts', "({ slots: { highlights: {}, tabs: { type: 'page:tabs', properties: { items: [] } } } })")).toEqual([]); + // Studio's object preview config. + expect(offendersIn('.ts', "({ objectPreview: { tabs: [], defaultTab: 'fields', showHeader: true } })")).toEqual([]); + // A spread-assembled payload — the stated bound. + expect(offendersIn('.ts', "({ ...list, tabs: [] })")).toEqual([]); + // Prose and quoted strings are not authorings. + expect(offendersIn('.md', 'A list view once took `columns` and `tabs: []`.')).toEqual([]); + expect(offendersIn('.ts', "const s = \"{ columns: [], tabs: [] }\";")).toEqual([]); + }); + + it('a path that VANISHES mid-walk is not a finding, and every other read fault still is', () => { + const before = vanished.length; + const gone = path.join(REPO_ROOT, 'packages/spec/does-not-exist.bundled_probe.mjs'); + expect(fs.existsSync(gone)).toBe(false); + expect(readIfPresent(gone, 'probe/gone')).toBeUndefined(); + expect(vanished.slice(before)).toEqual(['probe/gone']); + expect(readIfPresent(fileURLToPath(import.meta.url), THIS_FILE)).toContain('tree-scoped absence'); + expect(() => readIfPresent(path.join(REPO_ROOT, 'packages/spec'), 'probe/dir')).toThrow(); + expect(vanished.length).toBe(before + 1); + }); + + it('no list-view payload carrying `tabs` survives inside the declared radius', () => { + const offenders: string[] = []; + const residueHits = new Map(); + let visited = 0; + let tabsBearing = 0; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); + if (entry.isDirectory()) { + if (SKIPPED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue; + walk(full); + continue; + } + if (!entry.isFile()) continue; + const ext = path.extname(entry.name); + if (!(rel.startsWith('examples/') ? EXAMPLES_EXT : SCANNED_EXT).has(ext)) continue; + if (entry.name === 'CHANGELOG.md') continue; // release prose records the removal + if (rel.startsWith('.changeset/')) continue; // the changeset names the key it retires + if (EXCLUDED.has(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; + if (TSUP_BUNDLED_CONFIG.test(entry.name)) continue; + visited += 1; + const text = readIfPresent(full, rel); + if (text === undefined) continue; + if (/\btabs\b/.test(text)) tabsBearing += 1; + const hits = offendersIn(ext, text); + if (RESIDUE.has(rel)) { residueHits.set(rel, hits.length); continue; } + for (const lineNo of hits) offenders.push(`${rel}:${lineNo}`); + } + }; + for (const root of WALK_ROOTS) walk(path.join(REPO_ROOT, root)); + // Anti-vacuity: the walk covered the tree, and the files that CAN hold the + // key were really judged. + expect(visited).toBeGreaterThan(1000); + expect(tabsBearing).toBeGreaterThan(50); + expect(offenders, 'a list-view payload carrying `tabs` means the retirement is being undone').toEqual([]); + // The residue is self-expiring: every declared file still holds an + // offender, or its entry is stale and leaves. + for (const rel of RESIDUE) { + expect(residueHits.get(rel) ?? 0, `${rel} no longer authors the key — delete its RESIDUE entry`).toBeGreaterThan(0); + } + }); +}); diff --git a/packages/spec/src/ui/view.form.ts b/packages/spec/src/ui/view.form.ts index 7dd048b82a0..aa84fb4c7de 100644 --- a/packages/spec/src/ui/view.form.ts +++ b/packages/spec/src/ui/view.form.ts @@ -166,26 +166,11 @@ export const viewForm = defineForm({ collapsed: true, fields: [ { field: 'userFilters', type: 'composite', helpText: 'Quick-filter bar: element style (dropdown / tabs / toggle) + exposed fields or tab presets' }, - { - field: 'tabs', - type: 'repeater', - helpText: 'In-view filter tabs — each tab applies its own filter rules', - // Row-property names (#17508): every authorable row property, `label` - // equal to the item schema's `.meta({ title })`, so `os i18n extract` - // emits a catalog key per column and the panel keeps its schema-derived - // widgets (no `type` here). - fields: [ - { field: 'name', label: 'Name' }, - { field: 'label', label: 'Label' }, - { field: 'icon', label: 'Icon' }, - { field: 'view', label: 'List View' }, - { field: 'filter', label: 'Filter' }, - { field: 'order', label: 'Display Order' }, - { field: 'pinned', label: 'Pinned' }, - { field: 'isDefault', label: 'Default Tab' }, - { field: 'visible', label: 'Visible' }, - ], - }, + // [#20301] The `tabs` repeater was REMOVED with the key it wrote — now a + // `retiredKey()` tombstone on `ListViewSchema` (no renderer ever mounted a + // tab bar for it). A form input for an unwritable key is the + // false-compliant UI half of a retirement; named presets are `listViews` + // entries, each a tab in the saved-view switcher. { field: 'appearance', type: 'composite', helpText: 'allowedVisualizations: which renderers users may switch between' }, { field: 'userActions', type: 'composite', helpText: 'Toolbar toggles: sort / search / filter / row height' }, { field: 'addRecord', type: 'composite' }, diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index fd7632991ec..28b77df9b37 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -3646,17 +3646,12 @@ describe('ListViewSchema — Airtable Interface parity fields', () => { expect(listView.appearance?.allowedVisualizations).toHaveLength(3); }); - it('should accept list view with tabs', () => { - const listView = ListViewSchema.parse({ - columns: ['name', 'status'], - tabs: [ - { name: 'my_customers', label: 'My Customers', isDefault: true }, - { name: 'all_records', label: 'All Records' }, - ], - }); - expect(listView.tabs).toHaveLength(2); - expect(listView.tabs![0].isDefault).toBe(true); - }); + // [#20301] "should accept list view with tabs" was REMOVED: the list view's + // own `tabs` is retired (no renderer ever drew it — the tab strip above an + // object's records lists its `listViews`). The refusal at every list-view + // door, the D2 conversion and the tree-scoped absence pin live in + // `view-list-tabs-retirement.test.ts`, the one place that authors the key on + // purpose. it('should accept list view with addRecord', () => { const listView = ListViewSchema.parse({ @@ -3714,10 +3709,6 @@ describe('ListViewSchema — Airtable Interface parity fields', () => { showDescription: true, allowedVisualizations: ['grid', 'gallery', 'kanban'], }, - tabs: [ - { name: 'my_customers', label: 'my customers', isDefault: true, pinned: true }, - { name: 'all_records', label: 'All records' }, - ], addRecord: { enabled: true, position: 'bottom', @@ -3729,7 +3720,6 @@ describe('ListViewSchema — Airtable Interface parity fields', () => { expect(listView.name).toBe('customer_list'); expect(listView.userActions?.sort).toBe(true); expect(listView.appearance?.allowedVisualizations).toHaveLength(3); - expect(listView.tabs).toHaveLength(2); expect(listView.showRecordCount).toBe(true); expect(listView.allowPrinting).toBe(true); }); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index d9ae60ae74b..5e8d81c2336 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -2769,8 +2769,41 @@ const ListViewShapeSchema = lazySchema(() => strictObject({ /** Appearance (Airtable Interface parity) */ appearance: AppearanceConfigSchema.optional().describe('Appearance and visualization configuration'), - /** Tabs (Airtable Interface parity) */ - tabs: z.array(ViewTabSchema).optional().describe('Tab definitions for multi-tab view interface'), + /** + * [#20301] REMOVED — ADR-0049 enforce-or-remove, triage verdict RETIRE by + * the maintainer's #18900 criterion (mainstream named-view switching is + * already delivered here, by `listViews`). The key parsed, was stored, and + * drew nothing: a list view's own `tabs` has no reader, and objectui's + * `TabBar` — the one component that would draw it — has zero production + * mounts. The tab strip above an object's records is the saved-view + * switcher (`ViewTabBar`), which renders one tab per named list view and + * reads no `tabs` key. (`userFilters.tabs`, a different key of the same + * element type, IS read and rendered — the page preset bar — and stays.) + * Measured with lit controls and recorded on the ledger row + * (`liveness/view.json`, `/props/list/children/tabs`). + * + * Tombstoned rather than deleted so the removal is audible in both channels + * an upgrading author hits — `tsc` (the input type is `never`) and the parse + * (the prescription, not a bare unrecognized-key report) — the `pageName` + * precedent above on this same strict shape. The tombstone reaches every + * list-view door built from this shape: `ListViewSchema`, + * `ObjectListViewSchema` (a container's `list` / `listViews`, an object's + * `listViews`) and the flattened overlay arm. + * + * ⛔ `ViewTabSchema` itself is NOT retired: `UserFiltersSchema.tabs` — the + * page-only preset bar — reuses it and renders. D2: `view-list-tabs-removed`. + */ + tabs: retiredKey( + '`view.list.tabs` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no ' + + 'renderer ever mounted a tab bar for it, so authoring it drew nothing: the tab strip above an ' + + "object's records is the saved-view switcher (ViewTabBar), which renders one tab per named list " + + 'view and never read this key. Delete the key, and move each tab you want to a named list view ' + + "under the object's `listViews` instead: the tab's `name` becomes the entry's key, its `label` " + + "the entry's `label`, and its `filter` rules join the view's own `filter` on that entry (copy the " + + "view's `columns` too); a tab whose `view` already named a list view needs nothing more. Every " + + '`listViews` entry renders as a tab in the switcher. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', + ), /** Add Record (Airtable Interface parity) */ addRecord: AddRecordConfigSchema.optional().describe('Add record entry point configuration'), diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index b9c9eee5109..445ee0f593b 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -34,5 +34,6 @@ "src/system/constants/platform-object-names.test.ts", "src/system/email-template-floor-locale-parity.pin.test.ts", "src/ui/action-requires-confirmation-docblock.pin.test.ts", - "src/ui/view-item-owner-hidden-retirement.test.ts" + "src/ui/view-item-owner-hidden-retirement.test.ts", + "src/ui/view-list-tabs-retirement.test.ts" ] diff --git a/skills/objectstack-ui/rules/list-views.md b/skills/objectstack-ui/rules/list-views.md index 4866ced2bd8..542c0a04a03 100644 --- a/skills/objectstack-ui/rules/list-views.md +++ b/skills/objectstack-ui/rules/list-views.md @@ -123,12 +123,6 @@ userFilters: { ], }, -// In-view filter tabs (presets on top of the base filter): -tabs: [ - { name: 'all', label: 'All', isDefault: true }, - { name: 'urgent', label: 'Urgent', filter: [{ field: 'priority', operator: 'equals', value: 'urgent' }] }, -], - // Runtime visualization whitelist (Airtable "Appearance → Visualizations"): appearance: { allowedVisualizations: ['grid', 'kanban', 'gallery'] }, ``` @@ -136,10 +130,9 @@ appearance: { allowedVisualizations: ['grid', 'kanban', 'gallery'] }, Rules: - Every `field` MUST exist on the source object — reference diagnostics (`_diagnostics`) flag unknown fields; treat `valid: false` as a failed write. -- **Tabs XOR dropdowns — never both on one view.** The toolbar renders ONE - filter element style (Airtable's Elements choice). If a view configures - both `tabs` and `userFilters`, tabs win and the dropdowns never render. - Want both demos? Put them on different views. +- **A list view has no `tabs` key** — it was removed (a parse error that + names the fix): nothing ever drew it. Each named preset is a `listViews` + entry, rendered as a tab in the object's saved-view switcher. - **On an object list view (`*.view.ts` `list` / `listViews`), only `element: 'dropdown'` (value chips) is allowed — `tabs` is page-only** (ADR-0047 amendment). An object view's saved-view `ViewTabBar` already owns