diff --git a/.changeset/23082-view-data-api-provider-retired.md b/.changeset/23082-view-data-api-provider-retired.md new file mode 100644 index 00000000000..577a50fed2b --- /dev/null +++ b/.changeset/23082-view-data-api-provider-retired.md @@ -0,0 +1,39 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: retire the view-level `api` data source — a view binds its object or static rows, and a metadata-declared API call is the page element's binding (#23082) + +**BREAKING** — `provider: 'api'` on a view's `data` (`ViewDataSchema`, with its `read` / `write` request blocks) is now refused at parse. The arm took any `url` and any `headers`, so a credential or a cross-origin call written into a view was stored as metadata, served to every client that read the view and sent by the console as written. The page element's API binding (`ElementApiDataSourceSchema`, `dataSource` on a page component) is the one door for a metadata-declared API call: same-origin, no credential key, read with the console's own signed-in fetch. One operation with two implementations that behave differently keeps the governed one — the view-level source is re-homed on the page element and removed, not aligned. + +Clause-②: no (narrowing) + +Measured before removal: no producer wrote `provider: 'api'` on a view outside documentation in objectstack (`bcebd8409`), objectui (`origin/main` `f228d710` — the console's approval-requests source builds `ApiDataSource` in code from an `ApiDataSourceConfig`, never a `ViewData`) or hotcrm (`0f84c9c`), each with a lit `provider: 'object'` control. A renderer reader existed in objectui, measured at `7578323a544e` (`ListView` routed the block to `ApiDataSource`; `ObjectGantt` built one from `read` / `write`), and is now unreachable from authored metadata. + +## FROM → TO + +| you wrote (17.7 and earlier) | write instead | +| --- | --- | +| `data: { provider: 'api', read: { url, method?, params?, headers? } }` on a list or form view | `data: { provider: 'object', object: '' }` (or `{ provider: 'value', items: [...] }`), and the read as a page component's `dataSource: { provider: 'api', read: { url: '/api/...' }, path? }` | +| `data: { provider: 'api', write: { url, method, body? } }` on a form view | nothing — a form submits to its object; a submit-to-endpoint is not a view concern | +| `read.headers: { Authorization: '...' }` | nothing — a credential never lives in metadata; the page element binding refuses a credential key and the console sends its signed-in fetch | + +**The one-line fix:** bind every `provider: 'api'` view to its object or to static rows, and move the endpoint read onto a page component's `dataSource`. + +**What an author who still writes it sees.** `tsc` fails at the authoring site (`ViewData` no longer has the arm), and the parse — `defineView()`, `defineStack({ views })`, `os validate`, `PUT /api/v1/meta/view/:name` — refuses it at `data.provider` with the prescription: + +> `view.data.provider: 'api'` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — a view-level API source took any `url` and any `headers`, so a credential or a cross-origin call written there was stored with the view, served to every client that read it and sent by the console as written; a metadata-declared API call now has one door, the page element's binding, which is same-origin and credential-free. Bind the view to its object — `data: { provider: 'object', object: '' }` — or to static rows — `data: { provider: 'value', items: [...] }` — and move the API read onto a page component: `dataSource: { provider: 'api', read: { url: '/api/...' } }` (with an optional `path` into the response) under a page's `regions[].components[]`, which the console reads with its own signed-in fetch. + +## The retirement kit + +- **A value retirement, not a key**: `data` survives one arm lighter, so there is no `retiredKey()` tombstone and no `RETIRED_KEYS_BY_MAJOR` row. The prescription rides the discriminated union's own `error` map, keyed on the input's `provider === 'api'` — the retired bare-string `sort` clause's shape — so every other unknown provider keeps zod's own report, which lists the live arms (`object`, `value`, `schema`). +- **No D2 conversion, deliberately.** The replacement lives on a different metadata type (a page element), and the only mechanical edit a conversion could make — deleting the view's `data` so the view falls back to querying its bound object — would change what the view shows without telling anyone. An authored source or a stored `sys_metadata` row still carrying the arm meets the prescription at parse instead. +- **The D3 entry `view-data-api-provider-retired`** asks the author, per such view, which page composes the endpoint it read and what the view itself should now bind. +- **`HttpRequestSchema` / `HttpMethodSubsetSchema` stay exported** from `@objectstack/spec/shared` and `@objectstack/spec/ui`, unchanged. After this change no schema in the package composes `HttpRequestSchema`; that orphan is recorded, not acted on here. +- **Ledger:** `view.list.data` and `view.form.data` stay `live` (their surviving arms are read) and carry the arm's retirement record. +- **Docs and the published skill** no longer teach a view-level `api` source; both point at the page element binding. +- **No deprecation window**, per the project's startup-stage posture. + +⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` is published, and `objectstack-ai/cloud`'s own view metadata was not readable from this change's session; a cloud view carrying an `api` source is refused at parse with the prescription above. + + diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index c8eccc648cf..afc390a52a2 100644 --- a/content/docs/references/api/metadata.mdx +++ b/content/docs/references/api/metadata.mdx @@ -965,7 +965,7 @@ Metadata query with filtering, sorting, and pagination | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | | **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. | | **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). | -| **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| … +3 more; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) | +| **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| object \| object; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) | | **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused — mirror the value onto a stored text field and declare that. | | **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules | | **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). | diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 09ae73d485c..ae267b96749 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1671,9 +1671,9 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **name** | `string` | optional | Item name — supplied by the metadata door; for an object-scoped container it is the object name. | | **label** | `string \| Record` | optional | Human-readable label shown in metadata lists. | | **object** | `string` | optional | Object this container binds to — how a stack-level `views: [...]` entry says which object its views belong to; read by `getViewsByObject()` / `GET /meta/view?object=`. | -| **list** | `{ name?: string; label?: string \| Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| … +3 more; … }` | optional | | +| **list** | `{ name?: string; label?: string \| Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| object \| object; … }` | optional | | | **form** | `{ type?: Enum<'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'>; layout?: Enum<'vertical' \| 'horizontal'>; columns?: integer; title?: string; … }` | optional | | -| **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| … +3 more; … }>` | optional | Additional named list views (views mode — dropdown userFilters allowed, no tabs; ADR-0047) | +| **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| object \| object; … }>` | optional | Additional named list views (views mode — dropdown userFilters allowed, no tabs; ADR-0047) | | **formViews** | `Record; layout?: Enum<'vertical' \| 'horizontal'>; columns?: integer; title?: string; … }>` | optional | Additional named form views | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this view. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | @@ -1691,7 +1691,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **name** | `string` | optional | Internal view name (lowercase snake_case) | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | -| **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | +| **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | @@ -1759,7 +1759,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **drawerSide** | `Enum<'top' \| 'bottom' \| 'left' \| 'right'>` | optional | Drawer side (drawer forms) | | **drawerWidth** | `string` | optional | [DEPRECATED → size buckets] Drawer width, e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it. | | **modalSize** | `Enum<'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'>` | optional | Modal size (modal forms) | -| **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | +| **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | | | **groups** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | [LEGACY ALIAS → `sections`] Accepted for back-compat and folded onto `sections` at parse; `sections` wins when both are present. Prefer `sections`. | | **subforms** | `{ childObject: string; relationshipField?: string; columns?: object[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | @@ -1777,7 +1777,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **name** | `string` | optional | Internal view name (lowercase snake_case) | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | -| **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | +| **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | @@ -1845,7 +1845,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **drawerSide** | `Enum<'top' \| 'bottom' \| 'left' \| 'right'>` | optional | Drawer side (drawer forms) | | **drawerWidth** | `string` | optional | [DEPRECATED → size buckets] Drawer width, e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it. | | **modalSize** | `Enum<'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'>` | optional | Modal size (modal forms) | -| **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | +| **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | | | **groups** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | [LEGACY ALIAS → `sections`] Accepted for back-compat and folded onto `sections` at parse; `sections` wins when both are present. Prefer `sections`. | | **subforms** | `{ childObject: string; relationshipField?: string; columns?: object[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 1f506664881..d0674701559 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -169,7 +169,7 @@ const result = ApiMethod.parse(data); | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | | **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. | | **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). | -| **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| … +3 more; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) | +| **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| object \| object; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) | | **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused — mirror the value onto a stored text field and declare that. | | **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules | | **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). | @@ -370,7 +370,7 @@ const result = ApiMethod.parse(data); | **name** | `string` | optional | Internal view name (lowercase snake_case) | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | -| **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | +| **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 01dfe5b8d24..6ba1816521d 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -350,7 +350,7 @@ Create a new object | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | | **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. | | **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). | -| **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| … +3 more; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) | +| **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| object \| object; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) | | **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused — mirror the value onto a stored text field and declare that. | | **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules | | **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). | @@ -642,7 +642,7 @@ Create a new object | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | | **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. | | **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). | -| **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| … +3 more; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) | +| **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| object \| object; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) | | **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused — mirror the value onto a stored text field and declare that. | | **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules | | **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). | diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 419714591b6..10200d18fda 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -661,7 +661,7 @@ Sort field and direction pair | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **objectName** | `string` | optional | Object this gantt binds to — the THIRD record source `resolveRecordSourceConfig` resolves, after `data` and `staticData`. Optional because the component-level `dataSource` binding can supply the object instead | -| **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source binding (ViewDataSchema — discriminated on `provider`: object \| api \| value \| schema), read FIRST by `resolveRecordSourceConfig`. Static inline rows live at `{ provider: 'value', items: [...] }` or at `staticData`; the bare-array shortcut is refused | +| **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source binding (ViewDataSchema — discriminated on `provider`: object \| value \| schema), read FIRST by `resolveRecordSourceConfig`. Static inline rows live at `{ provider: 'value', items: [...] }` or at `staticData`; the bare-array shortcut is refused | | **staticData** | `any[]` | optional | Inline records — read SECOND by `resolveRecordSourceConfig`, wrapped into a `{ provider: 'value' }` config | | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Task order for the fetched bars — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array` | @@ -685,14 +685,6 @@ Sort field and direction pair | **provider** | `'object'` | ✅ | | | **object** | `string` | ✅ | Target object name | -### Nested Shape: `ObjectGanttProps.data[provider='api']` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **provider** | `'api'` | ✅ | | -| **read** | `{ url: string; method?: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for fetching data | -| **write** | `{ url: string; method?: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for submitting data (for forms/editable tables) | - ### Nested Shape: `ObjectGanttProps.data[provider='value']` | Property | Type | Required | Description | @@ -752,7 +744,7 @@ Sort field and direction pair | **viewMode** | `Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional | Timeline granularity — one column per day/week/month/quarter/year (also the resource-view column granularity; renderer default 'day') | | **borderColorField** | `string` | optional | Field carrying a per-task alert stroke color — any CSS color or semantic palette name (red, orange, …): the bar keeps its fill and gains an outline plus halo in that color (e.g. red for overdue, orange for due-soon; typically a server-computed alert field). Empty or null means no stroke | | **lockField** | `string` | optional | Field marking a row view-only (truthy means locked): a locked bar cannot be dragged or resized, its progress cannot be dragged, no dependency can be drawn from it and its inline-edit and edit/delete menu entries are hidden — clicking it (open drawer, jump) still works. Independent of the global readOnly; freezes individual levels while siblings stay editable | -| **objectField** | `string` | optional | Field carrying the row's OWN object API name, for mixed-object trees (an api provider composing parent-object rows with child-object rows): the detail drawer and its full-page link follow each row's real object instead of the view's bound object. Empty or missing falls back to the bound object | +| **objectField** | `string` | optional | Field carrying the row's OWN object API name, for mixed-object trees (a row set composing parent-object rows with child-object rows): the detail drawer and its full-page link follow each row's real object instead of the view's bound object. Empty or missing falls back to the bound object | | **summaryExtent** | `Enum<'children' \| 'self'>` | optional | How a summary bar's span is computed. 'children' (renderer default) rolls the bar up from its children — min start, max end, duration-weighted progress — and ignores the record's own dates; 'self' renders the record's OWN start, end and progress and falls back to rollup only for records without dates (use it when the parent's schedule is authoritative, e.g. a shift plan whose work-order children are locked history) | | **defaultCollapsedDepth** | `integer` | optional | Auto-collapse tree nodes at or below this 0-indexed depth on first render (roots are depth 0): every node at that depth or deeper that has children starts folded; the user can still expand them. Omit to start fully expanded | | **dependencyTypes** | `boolean` | optional | Whether the backing store persists dependency link TYPES (fs, ss, ff, sf); renderer default true. Set false when dependencies are bare predecessor ids: the link menu hides the type switcher (a switch would be silently reverted on refetch) and drag-created links are always finish-to-start | @@ -827,7 +819,7 @@ Sort field and direction pair | **showColumnTypeIcons** | `boolean` | optional | Show field-type icons in column headers | | **exportOptions** | `{ formats?: Enum<'csv' \| 'xlsx' \| 'json'>[]; maxRecords?: integer; includeHeaders?: boolean; fileNamePrefix?: string; … }` | optional | Export config — the object `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`, the same block a list view's `exportOptions` declares, with `formats` drawn from `csv`, `xlsx` and `json`. A bare format array is refused: the grid reads `exportOptions.formats`, so write `{ formats: ['csv', 'xlsx'] }` | | **operations** | `{ create?: boolean; update?: boolean; delete?: boolean; export?: boolean }` | optional | Built-in affordance toggles `{ create?, update?, delete?, export? }` (booleans). A declared block replaces the grid's default: `update` / `delete` not named are withheld from the row menu, `create` enables the add-row affordance, and `export: false` hides the export menu | -| **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source binding (ViewDataSchema — discriminated on `provider`: object \| api \| value \| schema). Static inline rows live at `{ provider: 'value', items: [...] }`; the bare-array shortcut is refused — see migration `object-grid-data-view-data-converged` | +| **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source binding (ViewDataSchema — discriminated on `provider`: object \| value \| schema). Static inline rows live at `{ provider: 'value', items: [...] }`; the bare-array shortcut is refused — see migration `object-grid-data-view-data-converged` | | **staticData** | `any[]` | optional | Deprecated bare-array static-rows shortcut the renderer still reads. Prefer `data: { provider: 'value', items: [...] }` | ### Nested Shape: `ObjectGridProps.emptyState` @@ -966,14 +958,6 @@ Sort field and direction pair | **provider** | `'object'` | ✅ | | | **object** | `string` | ✅ | Target object name | -### Nested Shape: `ObjectGridProps.data[provider='api']` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **provider** | `'api'` | ✅ | | -| **read** | `{ url: string; method?: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for fetching data | -| **write** | `{ url: string; method?: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for submitting data (for forms/editable tables) | - ### Nested Shape: `ObjectGridProps.data[provider='value']` | Property | Type | Required | Description | @@ -1069,7 +1053,7 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **objectName** | `string` | optional | Object this map binds to — the THIRD record source `getDataConfig` resolves, after `data` and `staticData`. Optional because the component-level `dataSource` binding can supply the object instead | -| **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source binding (ViewDataSchema — discriminated on `provider`: object \| api \| value \| schema), read FIRST by `getDataConfig`. Static inline rows live at `{ provider: 'value', items: [...] }` or at `staticData`; the bare-array shortcut is refused | +| **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source binding (ViewDataSchema — discriminated on `provider`: object \| value \| schema), read FIRST by `getDataConfig`. Static inline rows live at `{ provider: 'value', items: [...] }` or at `staticData`; the bare-array shortcut is refused | | **staticData** | `any[]` | optional | Inline records — read SECOND by `getDataConfig`, wrapped into a `{ provider: 'value' }` config | | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Marker order for the fetched records — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array` | @@ -1085,14 +1069,6 @@ View filter rule | **provider** | `'object'` | ✅ | | | **object** | `string` | ✅ | Target object name | -### Nested Shape: `ObjectMapProps.data[provider='api']` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **provider** | `'api'` | ✅ | | -| **read** | `{ url: string; method?: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for fetching data | -| **write** | `{ url: string; method?: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for submitting data (for forms/editable tables) | - ### Nested Shape: `ObjectMapProps.data[provider='value']` | Property | Type | Required | Description | @@ -1377,7 +1353,7 @@ Sort field and direction pair | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **objectName** | `string` | optional | Object this tree binds to — the THIRD record source `resolveRecordSourceConfig` resolves, after `data` and `staticData`. Optional because either of the first two rungs resolves the source without it: `data` can name the object itself (`{ provider: 'object', object }`) and `staticData` needs none. ⚠️ NOT supplied by the component-level `dataSource` binding the sibling blocks name — this renderer registers no such gate | -| **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source binding (ViewDataSchema — discriminated on `provider`: object \| api \| value \| schema), read FIRST by `resolveRecordSourceConfig`. Static inline rows live at `{ provider: 'value', items: [...] }` or at `staticData`; the bare-array shortcut is refused | +| **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source binding (ViewDataSchema — discriminated on `provider`: object \| value \| schema), read FIRST by `resolveRecordSourceConfig`. Static inline rows live at `{ provider: 'value', items: [...] }` or at `staticData`; the bare-array shortcut is refused | | **staticData** | `any[]` | optional | Inline records — read SECOND by `resolveRecordSourceConfig`, wrapped into a `{ provider: 'value' }` config | | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration, the author face — the same block `ListViewSchema.tree` declares: `{ parentField?, labelField?, fields?, defaultExpandedDepth? }`. `parentField` auto-detects from the object schema when omitted | @@ -1390,14 +1366,6 @@ Sort field and direction pair | **provider** | `'object'` | ✅ | | | **object** | `string` | ✅ | Target object name | -### Nested Shape: `ObjectTreeProps.data[provider='api']` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **provider** | `'api'` | ✅ | | -| **read** | `{ url: string; method?: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for fetching data | -| **write** | `{ url: string; method?: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for submitting data (for forms/editable tables) | - ### Nested Shape: `ObjectTreeProps.data[provider='value']` | Property | Type | Required | Description | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index c38092eec5e..9d2024498b6 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -402,7 +402,7 @@ Form-view select option — the object-field option shape minus the per-option ` | **drawerSide** | `Enum<'top' \| 'bottom' \| 'left' \| 'right'>` | optional | Drawer side (drawer forms) | | **drawerWidth** | `string` | optional | [DEPRECATED → size buckets] Drawer width, e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it. | | **modalSize** | `Enum<'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'>` | optional | Modal size (modal forms) | -| **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | +| **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | | | **groups** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | [LEGACY ALIAS → `sections`] Accepted for back-compat and folded onto `sections` at parse; `sections` wins when both are present. Prefer `sections`. | | **subforms** | `{ childObject: string; relationshipField?: string; columns?: object[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | @@ -420,14 +420,6 @@ Form-view select option — the object-field option shape minus the per-option ` | **provider** | `'object'` | ✅ | | | **object** | `string` | ✅ | Target object name | -### Nested Shape: `FormView.data[provider='api']` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **provider** | `'api'` | ✅ | | -| **read** | `{ url: string; method?: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for fetching data | -| **write** | `{ url: string; method?: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for submitting data (for forms/editable tables) | - ### Nested Shape: `FormView.data[provider='value']` | Property | Type | Required | Description | @@ -563,7 +555,7 @@ Gallery/card view configuration | **viewMode** | `Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional | Timeline granularity — one column per day/week/month/quarter/year (also the resource-view column granularity; renderer default 'day') | | **borderColorField** | `string` | optional | Field carrying a per-task alert stroke color — any CSS color or semantic palette name (red, orange, …): the bar keeps its fill and gains an outline plus halo in that color (e.g. red for overdue, orange for due-soon; typically a server-computed alert field). Empty or null means no stroke | | **lockField** | `string` | optional | Field marking a row view-only (truthy means locked): a locked bar cannot be dragged or resized, its progress cannot be dragged, no dependency can be drawn from it and its inline-edit and edit/delete menu entries are hidden — clicking it (open drawer, jump) still works. Independent of the global readOnly; freezes individual levels while siblings stay editable | -| **objectField** | `string` | optional | Field carrying the row's OWN object API name, for mixed-object trees (an api provider composing parent-object rows with child-object rows): the detail drawer and its full-page link follow each row's real object instead of the view's bound object. Empty or missing falls back to the bound object | +| **objectField** | `string` | optional | Field carrying the row's OWN object API name, for mixed-object trees (a row set composing parent-object rows with child-object rows): the detail drawer and its full-page link follow each row's real object instead of the view's bound object. Empty or missing falls back to the bound object | | **summaryExtent** | `Enum<'children' \| 'self'>` | optional | How a summary bar's span is computed. 'children' (renderer default) rolls the bar up from its children — min start, max end, duration-weighted progress — and ignores the record's own dates; 'self' renders the record's OWN start, end and progress and falls back to rollup only for records without dates (use it when the parent's schedule is authoritative, e.g. a shift plan whose work-order children are locked history) | | **defaultCollapsedDepth** | `integer` | optional | Auto-collapse tree nodes at or below this 0-indexed depth on first render (roots are depth 0): every node at that depth or deeper that has children starts folded; the user can still expand them. Omit to start fully expanded | | **dependencyTypes** | `boolean` | optional | Whether the backing store persists dependency link TYPES (fs, ss, ff, sf); renderer default true. Set false when dependencies are bare predecessor ids: the link menu hides the type switcher (a switch would be silently reverted on refetch) and drag-created links are always finish-to-start | @@ -776,7 +768,7 @@ Map view configuration | **name** | `string` | optional | Internal view name (lowercase snake_case) | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | -| **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | +| **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | @@ -832,14 +824,6 @@ Map view configuration | **provider** | `'object'` | ✅ | | | **object** | `string` | ✅ | Target object name | -### Nested Shape: `ListView.data[provider='api']` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **provider** | `'api'` | ✅ | | -| **read** | `{ url: string; method?: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for fetching data | -| **write** | `{ url: string; method?: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for submitting data (for forms/editable tables) | - ### Nested Shape: `ListView.data[provider='value']` | Property | Type | Required | Description | @@ -962,7 +946,7 @@ View filter rule | **viewMode** | `Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional | Timeline granularity — one column per day/week/month/quarter/year (also the resource-view column granularity; renderer default 'day') | | **borderColorField** | `string` | optional | Field carrying a per-task alert stroke color — any CSS color or semantic palette name (red, orange, …): the bar keeps its fill and gains an outline plus halo in that color (e.g. red for overdue, orange for due-soon; typically a server-computed alert field). Empty or null means no stroke | | **lockField** | `string` | optional | Field marking a row view-only (truthy means locked): a locked bar cannot be dragged or resized, its progress cannot be dragged, no dependency can be drawn from it and its inline-edit and edit/delete menu entries are hidden — clicking it (open drawer, jump) still works. Independent of the global readOnly; freezes individual levels while siblings stay editable | -| **objectField** | `string` | optional | Field carrying the row's OWN object API name, for mixed-object trees (an api provider composing parent-object rows with child-object rows): the detail drawer and its full-page link follow each row's real object instead of the view's bound object. Empty or missing falls back to the bound object | +| **objectField** | `string` | optional | Field carrying the row's OWN object API name, for mixed-object trees (a row set composing parent-object rows with child-object rows): the detail drawer and its full-page link follow each row's real object instead of the view's bound object. Empty or missing falls back to the bound object | | **summaryExtent** | `Enum<'children' \| 'self'>` | optional | How a summary bar's span is computed. 'children' (renderer default) rolls the bar up from its children — min start, max end, duration-weighted progress — and ignores the record's own dates; 'self' renders the record's OWN start, end and progress and falls back to rollup only for records without dates (use it when the parent's schedule is authoritative, e.g. a shift plan whose work-order children are locked history) | | **defaultCollapsedDepth** | `integer` | optional | Auto-collapse tree nodes at or below this 0-indexed depth on first render (roots are depth 0): every node at that depth or deeper that has children starts folded; the user can still expand them. Omit to start fully expanded | | **dependencyTypes** | `boolean` | optional | Whether the backing store persists dependency link TYPES (fs, ss, ff, sf); renderer default true. Set false when dependencies are bare predecessor ids: the link menu hides the type switcher (a switch would be silently reverted on refetch) and drag-created links are always finish-to-start | @@ -1171,7 +1155,7 @@ View filter rule | **name** | `string` | optional | Internal view name (lowercase snake_case) | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | -| **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | +| **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | @@ -1227,14 +1211,6 @@ View filter rule | **provider** | `'object'` | ✅ | | | **object** | `string` | ✅ | Target object name | -### Nested Shape: `ObjectListView.data[provider='api']` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **provider** | `'api'` | ✅ | | -| **read** | `{ url: string; method?: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for fetching data | -| **write** | `{ url: string; method?: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for submitting data (for forms/editable tables) | - ### Nested Shape: `ObjectListView.data[provider='value']` | Property | Type | Required | Description | @@ -1347,7 +1323,7 @@ View filter rule | **viewMode** | `Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional | Timeline granularity — one column per day/week/month/quarter/year (also the resource-view column granularity; renderer default 'day') | | **borderColorField** | `string` | optional | Field carrying a per-task alert stroke color — any CSS color or semantic palette name (red, orange, …): the bar keeps its fill and gains an outline plus halo in that color (e.g. red for overdue, orange for due-soon; typically a server-computed alert field). Empty or null means no stroke | | **lockField** | `string` | optional | Field marking a row view-only (truthy means locked): a locked bar cannot be dragged or resized, its progress cannot be dragged, no dependency can be drawn from it and its inline-edit and edit/delete menu entries are hidden — clicking it (open drawer, jump) still works. Independent of the global readOnly; freezes individual levels while siblings stay editable | -| **objectField** | `string` | optional | Field carrying the row's OWN object API name, for mixed-object trees (an api provider composing parent-object rows with child-object rows): the detail drawer and its full-page link follow each row's real object instead of the view's bound object. Empty or missing falls back to the bound object | +| **objectField** | `string` | optional | Field carrying the row's OWN object API name, for mixed-object trees (a row set composing parent-object rows with child-object rows): the detail drawer and its full-page link follow each row's real object instead of the view's bound object. Empty or missing falls back to the bound object | | **summaryExtent** | `Enum<'children' \| 'self'>` | optional | How a summary bar's span is computed. 'children' (renderer default) rolls the bar up from its children — min start, max end, duration-weighted progress — and ignores the record's own dates; 'self' renders the record's OWN start, end and progress and falls back to rollup only for records without dates (use it when the parent's schedule is authoritative, e.g. a shift plan whose work-order children are locked history) | | **defaultCollapsedDepth** | `integer` | optional | Auto-collapse tree nodes at or below this 0-indexed depth on first render (roots are depth 0): every node at that depth or deeper that has children starts folded; the user can still expand them. Omit to start fully expanded | | **dependencyTypes** | `boolean` | optional | Whether the backing store persists dependency link TYPES (fs, ss, ff, sf); renderer default true. Set false when dependencies are bare predecessor ids: the link menu hides the type switcher (a switch would be silently reverted on refetch) and drag-created links are always finish-to-start | @@ -1736,9 +1712,9 @@ Tab configuration for multi-tab view interface | **name** | `string` | optional | Item name — supplied by the metadata door; for an object-scoped container it is the object name. | | **label** | `string \| Record` | optional | Human-readable label shown in metadata lists. | | **object** | `string` | optional | Object this container binds to — how a stack-level `views: [...]` entry says which object its views belong to; read by `getViewsByObject()` / `GET /meta/view?object=`. | -| **list** | `{ name?: string; label?: string \| Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| … +3 more; … }` | optional | | +| **list** | `{ name?: string; label?: string \| Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| object \| object; … }` | optional | | | **form** | `{ type?: Enum<'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'>; layout?: Enum<'vertical' \| 'horizontal'>; columns?: integer; title?: string; … }` | optional | | -| **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| … +3 more; … }>` | optional | Additional named list views (views mode — dropdown userFilters allowed, no tabs; ADR-0047) | +| **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| object \| object; … }>` | optional | Additional named list views (views mode — dropdown userFilters allowed, no tabs; ADR-0047) | | **formViews** | `Record; layout?: Enum<'vertical' \| 'horizontal'>; columns?: integer; title?: string; … }>` | optional | Additional named form views | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this view. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | @@ -1756,7 +1732,7 @@ Tab configuration for multi-tab view interface | **name** | `string` | optional | Internal view name (lowercase snake_case) | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | -| **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | +| **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | @@ -1824,7 +1800,7 @@ Tab configuration for multi-tab view interface | **drawerSide** | `Enum<'top' \| 'bottom' \| 'left' \| 'right'>` | optional | Drawer side (drawer forms) | | **drawerWidth** | `string` | optional | [DEPRECATED → size buckets] Drawer width, e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it. | | **modalSize** | `Enum<'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'>` | optional | Modal size (modal forms) | -| **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | +| **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | | | **groups** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | [LEGACY ALIAS → `sections`] Accepted for back-compat and folded onto `sections` at parse; `sections` wins when both are present. Prefer `sections`. | | **subforms** | `{ childObject: string; relationshipField?: string; columns?: object[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | @@ -1842,7 +1818,7 @@ Tab configuration for multi-tab view interface | **name** | `string` | optional | Internal view name (lowercase snake_case) | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | -| **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | +| **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | @@ -1910,7 +1886,7 @@ Tab configuration for multi-tab view interface | **drawerSide** | `Enum<'top' \| 'bottom' \| 'left' \| 'right'>` | optional | Drawer side (drawer forms) | | **drawerWidth** | `string` | optional | [DEPRECATED → size buckets] Drawer width, e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it. | | **modalSize** | `Enum<'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'>` | optional | Modal size (modal forms) | -| **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | +| **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | | | **groups** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | [LEGACY ALIAS → `sections`] Accepted for back-compat and folded onto `sections` at parse; `sections` wins when both are present. Prefer `sections`. | | **subforms** | `{ childObject: string; relationshipField?: string; columns?: object[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | @@ -1953,38 +1929,6 @@ This schema accepts one of the following structures: ### Properties -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **provider** | `'api'` | ✅ | | -| **read** | `{ url: string; method?: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for fetching data | -| **write** | `{ url: string; method?: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for submitting data (for forms/editable tables) | - -### Nested Shape: `ViewData[provider='api'].read` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **url** | `string` | ✅ | API endpoint URL | -| **method** | `Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>` | optional (default: `"GET"`) | HTTP method | -| **headers** | `Record` | optional | Custom HTTP headers | -| **params** | `Record` | optional | Query parameters | -| **body** | `any` | optional | Request body for POST/PUT/PATCH | - -### Nested Shape: `ViewData[provider='api'].write` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **url** | `string` | ✅ | API endpoint URL | -| **method** | `Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>` | optional (default: `"GET"`) | HTTP method | -| **headers** | `Record` | optional | Custom HTTP headers | -| **params** | `Record` | optional | Query parameters | -| **body** | `any` | optional | Request body for POST/PUT/PATCH | - ---- - -#### Option 3 - -### Properties - | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **provider** | `'value'` | ✅ | | @@ -1992,7 +1936,7 @@ This schema accepts one of the following structures: --- -#### Option 4 +#### Option 3 ### Properties @@ -2058,7 +2002,7 @@ This schema accepts one of the following structures: | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **viewKind** | `'list'` | ✅ | | -| **config** | `{ name?: string; label?: string \| Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| … +3 more; … }` | ✅ | List-family view configuration. | +| **config** | `{ name?: string; label?: string \| Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| object \| object; … }` | ✅ | List-family view configuration. | | **name** | `string` | ✅ | Globally-unique view id, `.`. | | **object** | `string` | ✅ | Bound object name — the foreign key used to aggregate views. | | **label** | `string \| Record` | optional | Display label (supports i18n). | @@ -2083,7 +2027,7 @@ This schema accepts one of the following structures: | **name** | `string` | optional | Internal view name (lowercase snake_case) | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | -| **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | +| **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | @@ -2186,7 +2130,7 @@ This schema accepts one of the following structures: | **drawerSide** | `Enum<'top' \| 'bottom' \| 'left' \| 'right'>` | optional | Drawer side (drawer forms) | | **drawerWidth** | `string` | optional | [DEPRECATED → size buckets] Drawer width, e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it. | | **modalSize** | `Enum<'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'>` | optional | Modal size (modal forms) | -| **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | +| **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | | | **groups** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | [LEGACY ALIAS → `sections`] Accepted for back-compat and folded onto `sections` at parse; `sections` wins when both are present. Prefer `sections`. | | **subforms** | `{ childObject: string; relationshipField?: string; columns?: object[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | @@ -2232,7 +2176,7 @@ This schema accepts one of the following structures: | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **viewKind** | `'list'` | ✅ | | -| **config** | `{ name?: string; label?: string \| Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| … +3 more; … }` | ✅ | List-family view configuration. | +| **config** | `{ name?: string; label?: string \| Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| object \| object; … }` | ✅ | List-family view configuration. | | **name** | `string` | ✅ | Globally-unique view id, `.`. | | **object** | `string` | ✅ | Bound object name — the foreign key used to aggregate views. | | **label** | `string \| Record` | optional | Display label (supports i18n). | @@ -2262,7 +2206,7 @@ This schema accepts one of the following structures: | **name** | `string` | optional | Internal view name (lowercase snake_case) | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | -| **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | +| **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | @@ -2377,7 +2321,7 @@ This schema accepts one of the following structures: | **drawerSide** | `Enum<'top' \| 'bottom' \| 'left' \| 'right'>` | optional | Drawer side (drawer forms) | | **drawerWidth** | `string` | optional | [DEPRECATED → size buckets] Drawer width, e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it. | | **modalSize** | `Enum<'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'>` | optional | Modal size (modal forms) | -| **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | +| **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | | | **groups** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | [LEGACY ALIAS → `sections`] Accepted for back-compat and folded onto `sections` at parse; `sections` wins when both are present. Prefer `sections`. | | **subforms** | `{ childObject: string; relationshipField?: string; columns?: object[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | diff --git a/content/docs/ui/views.mdx b/content/docs/ui/views.mdx index d6cc080004b..8e81e5c3bfe 100644 --- a/content/docs/ui/views.mdx +++ b/content/docs/ui/views.mdx @@ -294,18 +294,12 @@ writing it by hand would smuggle an action definition past the action registry. ### Data Source -Views can load data from several sources (`object`, `api`, `value`, and `schema`): +Views can load data from several sources (`object`, `value`, and `schema`): ```typescript // From an Object (most common) data: { provider: 'object', object: 'task' } -// From an external API -data: { - provider: 'api', - read: { url: '/api/external/tasks', method: 'GET' }, -} - // Static values data: { provider: 'value', @@ -313,6 +307,13 @@ data: { } ``` +A view does not call an API. The view-level `api` data source — a `read` / `write` +request block with its own `url` and `headers` — was retired: it let a credential or +a cross-origin call be written into metadata and served with the view. A +metadata-declared API call is a **page element's** `dataSource` binding, which is +same-origin, carries no credentials and is read with the console's signed-in fetch — +see [Element data sources](/docs/ui/pages#element-data-sources). + ### Type-Specific Configuration Each non-grid visualization reads its settings from a **nested config block diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md index a14d8dfa8d6..a95f31caf2c 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `ui/` | 214 | 203 | 4 | 0 | 7 | +| `ui/` | 213 | 202 | 4 | 0 | 7 | ## `ui/` — sites @@ -44,9 +44,9 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `report.zod.ts` | 3 | | `responsive.zod.ts` | 1 | | `sharing.zod.ts` | 1 | -| `view.zod.ts` | 60 | +| `view.zod.ts` | 59 | | `widget.zod.ts` | 1 | -| **total** | **214** | +| **total** | **213** | ## `ui/` — open @@ -54,15 +54,15 @@ Per file, how many of its sites still silently discard unknown keys. The `Class` column that decides the bucket split is hand-written in the ledger; the arithmetic over it is here. -**7 strip of 214**, in 4 file(s). +**7 strip of 213**, in 4 file(s). | File | Strip | Sites | |---|---|---| | `action-params.zod.ts` | 1 | 1 | | `app.zod.ts` | 1 | 19 | -| `view.zod.ts` | 4 | 60 | +| `view.zod.ts` | 4 | 59 | | `widget.zod.ts` | 1 | 1 | -| **total** | **7** | **214** | +| **total** | **7** | **213** | | Bucket | Sites | |---|---| diff --git a/packages/lint/src/validate-list-view-field-refs.test.ts b/packages/lint/src/validate-list-view-field-refs.test.ts index d36aa6e99d2..06f3603da7b 100644 --- a/packages/lint/src/validate-list-view-field-refs.test.ts +++ b/packages/lint/src/validate-list-view-field-refs.test.ts @@ -877,7 +877,9 @@ describe('#14107 — the skips', () => { }); it('skips a list view whose provider is not `object`', () => { - const stack = stackWith(mutate({ data: { provider: 'api', endpoint: '/x' }, columns: ['nope'] })); + // A `value` source: the one non-`object` provider a list view can still + // bind (the `api` arm is retired, and `schema` is form-only). + const stack = stackWith(mutate({ data: { provider: 'value', items: [] }, columns: ['nope'] })); expect(validateListViewFieldRefs(stack)).toEqual([]); }); diff --git a/packages/lint/src/validate-preset-comparands.test.ts b/packages/lint/src/validate-preset-comparands.test.ts index b9e4b6458f1..6c3e7e60815 100644 --- a/packages/lint/src/validate-preset-comparands.test.ts +++ b/packages/lint/src/validate-preset-comparands.test.ts @@ -435,8 +435,9 @@ describe('validatePresetComparands — arm 2, the FIELD-TYPED equality / members ], }], views: [ - // A non-`object` provider names no fields on any object graph. - { name: 'api', data: { provider: 'api', object: 'crm_opportunity' }, filter: [{ field: 'close_date', operator: 'equals', value: 'last_30_days' }] }, + // A non-`object` provider names no fields on any object graph (the + // `value` arm; the view-level `api` arm is retired). + { name: 'static', data: { provider: 'value', items: [] }, filter: [{ field: 'close_date', operator: 'equals', value: 'last_30_days' }] }, // No binding at all. { name: 'unbound', filter: [{ field: 'close_date', operator: 'in', value: ['last_30_days'] }] }, ], diff --git a/packages/spec/liveness/view.json b/packages/spec/liveness/view.json index 29385856264..b35b929ed41 100644 --- a/packages/spec/liveness/view.json +++ b/packages/spec/liveness/view.json @@ -38,7 +38,10 @@ }, "data": { "status": "live", - "note": "objectui: ListView.tsx:841-850 fetch effect supports ViewDataSchema provider modes ('value' inline, 'api'; gantt owns api-provided data at :476). Supersedes the 2026-06 audit's 'api/schema dead in list path'. The 'schema' provider remains form/metadata-admin-only — not honored on lists." + "evidenceScope": "cross-repo", + "verifiedAt": "2026-10-13", + "evidence": "objectui @7578323a544e packages/plugin-list/src/ListView.tsx and objectui @7578323a544e packages/plugin-gantt/src/ObjectGantt.tsx — the list's fetch effect reads `schema.data.provider` and charts `value` rows inline, and hands a gantt the whole `data` block, which the gantt resolves through `resolveDataSource`; framework: packages/spec/src/ui/view.zod.ts#ViewDataSchema", + "note": "objectui: ListView.tsx fetch effect supports the ViewDataSchema provider modes it is handed ('value' inline; the gantt owns its data block). Supersedes the 2026-06 audit's 'api/schema dead in list path'. The 'schema' provider remains form/metadata-admin-only — not honored on lists. RETIREMENT RECORD (2026-10-13, ADR-0049 enforce-or-remove, maintainer ruling 2026-10-12 letter C): the `api` provider VALUE left ViewDataSchema — refused by name on the union's own error map (`VIEW_DATA_API_PROVIDER_RETIRED`), no tombstone key, no D2 conversion, D3 `view-data-api-provider-retired`. A renderer reader EXISTED in objectui, measured at 7578323a544e — `.objectui-sha` has since moved to 1fb1ced36243 and the next cross-repo re-measure re-reads it there (ListView.tsx routed an `api` block to `ApiDataSource`; ObjectGantt.tsx built one from the block's `read`/`write`) — and is now unreachable from authored metadata: zero producers wrote the value outside documentation in objectstack (bcebd8409), objectui (origin/main f228d710 — the console's approval-requests source constructs `ApiDataSource` in code from an `ApiDataSourceConfig`, never a `ViewData`) and hotcrm (0f84c9c), each with a lit `provider: 'object'` control. This key stays `live` because the surviving arms are read; the arm's record sits on this row because the walk never descends a union arm." }, "columns": { "status": "live", @@ -263,7 +266,7 @@ "status": "live", "verifiedAt": "2026-07-30", "evidence": "packages/spec/src/ui/view.zod.ts defineForm (writes data.provider=schema + schemaId onto every metadata form); packages/metadata-protocol/src/protocol.ts:1580,1598 (serves schemaId with the form to the metadata-admin pipeline)", - "note": "CORRECTED 2026-07-30: the #3896 sweep attempted removal on the prior dead verdict and the build itself refuted it — defineForm authors the key on every *.form.ts. The record-form provider VALUES (object/api/value) may still lack readers; that is a value-subset audit, not a key removal." + "note": "CORRECTED 2026-07-30: the #3896 sweep attempted removal on the prior dead verdict and the build itself refuted it — defineForm authors the key on every *.form.ts. The record-form provider VALUES (object/value) may still lack readers; that is a value-subset audit, not a key removal. 2026-10-13: the `api` value is RETIRED on the union itself (see `list.data`'s retirement record) — a form's `data` no longer admits a request block; a submit-to-endpoint is not a view concern." }, "type": { "status": "live", diff --git a/packages/spec/src/migrations/entries/semantic/18.view-data-api-provider-retired.ts b/packages/spec/src/migrations/entries/semantic/18.view-data-api-provider-retired.ts new file mode 100644 index 00000000000..a2dc3958550 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.view-data-api-provider-retired.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// ADR-0049 enforce-or-remove; maintainer ruling 2026-10-12 (letter C: retire +// the view-level `api` data source, not align it to the page element's rule). +// The `provider: 'api'` arm of `ViewDataSchema` carried `read` / `write` +// request blocks of any `url` and any `headers`, so a credential or a +// cross-origin call written into a view was stored as metadata, served to +// every client that read the view and sent by the console as written. The +// page element's API binding is the one governed door for a metadata-declared +// API call (same-origin, credential-free, the console's own signed-in fetch); +// one operation with two implementations that behave differently keeps the +// governed one. No D2 conversion: the replacement lives on a different surface +// and the only mechanical edit — deleting the view's `data` — would silently +// change what the view shows, so an author or a stored row still carrying the +// arm is refused at parse with the prescription instead. +export const entry: SemanticMigration = { + id: 'view-data-api-provider-retired', + surface: "view.data (`provider: 'api'`, with its `read` / `write` request blocks) — the view-level API data source", + replacement: "A view binds its object (`provider: 'object'`) or static rows (`provider: 'value'`); the API read " + + "moves onto a page component's `dataSource` binding — `{ provider: 'api', read: { url }, path? }` under a " + + "page's `regions[].components[]` — which is same-origin, carries no credentials and is read with the " + + "console's signed-in fetch.", + reason: 'There is no mechanical rewrite. The replacement is a binding on a different metadata type (a page ' + + "element, not a view), and the one edit a conversion could make — deleting the view's `data` so the " + + 'view falls back to querying its bound object — would change what the view shows without telling ' + + 'anyone. So the parse refuses the arm by name with the prescription, for an authored source and for a ' + + 'stored row alike, and the author decides for each such view which page composes the endpoint it ' + + "read and what the view itself should now bind. The arm's `headers` block is the part with no " + + "successor at all: a key an author had written there was a credential in metadata, and the page " + + 'element binding refuses the key; the console sends its own signed-in fetch instead.', + acceptanceCriteria: "No view in `stack.views[]`, in any object's `listViews` / `formViews` map or in a stored " + + "`sys_metadata` view row carries `data.provider: 'api'`; the parse refuses one with the prescription. " + + "Every endpoint such a view read is either a page component's API binding on a same-origin path " + + 'with no credential key, or dropped on purpose; and every converted view binds its object or static ' + + 'rows and loads.', +}; diff --git a/packages/spec/src/migrations/registry.ts.template b/packages/spec/src/migrations/registry.ts.template index 25737669141..fdc2ed5bda5 100644 --- a/packages/spec/src/migrations/registry.ts.template +++ b/packages/spec/src/migrations/registry.ts.template @@ -3110,6 +3110,24 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'offered a block `chart` input until this change); it is retired from the load path, so ' + 'authors are refused at parse rather than rewritten.', }, + { + id: 'view-data-api-provider-retired', + order: 95, + text: + "It also retires the view-level `api` data source — the `provider: 'api'` arm of `ViewDataSchema`, " + + 'with its `read` / `write` request blocks (ADR-0049 enforce-or-remove; maintainer ruling 2026-10-12, ' + + 'letter C). The arm took any `url` and any `headers`, so a credential or a cross-origin call written ' + + 'into a view was stored as metadata, served to every client that read the view and sent by the ' + + "console as written, while the page element's API binding — the newer door for a metadata-declared " + + 'API call — is same-origin, credential-free and read with the console\'s own signed-in fetch. One ' + + 'operation with two implementations that behave differently keeps the governed one: a view now binds ' + + "its object or static rows, and an API read is a page component's `dataSource` binding. Measured " + + 'before removal: no producer wrote the arm outside documentation in objectstack, objectui or hotcrm. ' + + 'The arm is refused by name on the union\'s own error map, with no `retiredKey()` tombstone (a value ' + + 'left, not a key) and no D2 conversion (the replacement lives on another surface, and deleting the ' + + "view's `data` would silently change what it shows); its D3 record is the semantic entry " + + '`view-data-api-provider-retired`.', + }, { id: 'view-item-owner-hidden-retired', order: 37, diff --git a/packages/spec/src/ui/component-props-unknown-members.pin.test.ts b/packages/spec/src/ui/component-props-unknown-members.pin.test.ts index c4c152136a8..26109dc236e 100644 --- a/packages/spec/src/ui/component-props-unknown-members.pin.test.ts +++ b/packages/spec/src/ui/component-props-unknown-members.pin.test.ts @@ -202,11 +202,11 @@ const EXPRESSION_AST: Reason = { owner: 'shared/expression.zod.ts `ExpressionSchema.ast`', why: 'the engine-native AST `objectstack compile` fills beside `source` — opaque at the spec layer by design; each engine validates its own shape', }; -const HTTP_REQUEST: Reason = { - kind: 'shared', - owner: 'shared/http.zod.ts `HttpRequestSchema`', - why: 'the query parameters and request body an `api` data source sends, forwarded verbatim to the endpoint the author names; their shape is that endpoint\'s', -}; +// `HTTP_REQUEST` — the `data.read.params{}` / `data.read.body` / +// `data.write.*` members `HttpRequestSchema` carried into every record-source +// block — left this ledger with the view-level `api` data source (ADR-0049, +// maintainer ruling 2026-10-12 letter C): the `api` arm of `ViewDataSchema` is +// retired, so no `data` member reaches a request block any more. const INLINE_JSON_SCHEMA: Reason = { kind: 'shared', owner: 'ui/view.zod.ts `ViewDataSchema` (`provider: \'schema\'`)', @@ -280,7 +280,6 @@ on(['action:button', 'action:icon'], ACTION_RUNNER_PATHS, RUNNER); on(['action:group', 'action:menu'], ACTION_RUNNER_PATHS.map((p) => `actions[].${p}`), RUNNER); // The data-source binding every record-source block shares. -on(VIEW_DATA_TYPES, ['data.read.params{}', 'data.read.body', 'data.write.params{}', 'data.write.body'], HTTP_REQUEST); on(VIEW_DATA_TYPES, ['data.items[]'], RECORDS); on(VIEW_DATA_TYPES, ['data.schema{}'], INLINE_JSON_SCHEMA); @@ -418,14 +417,15 @@ describe('§2 each recorded reason holds', () => { } }); - it('a `shared` member really is the owner\'s: an `ast` beside a `source`, a request beside a `url`', () => { + it('a `shared` member really is the owner\'s: an `ast` beside a `source`', () => { for (const [key, reason] of entries) { if (reason !== EXPRESSION_AST) continue; expect(key, 'an expression-AST line names the `ast` member').toMatch(/\.ast$/); } - for (const [key, reason] of entries) { - if (reason !== HTTP_REQUEST) continue; - expect(key, 'an HTTP-request line names `params` or `body` under `read` / `write`').toMatch(/ data\.(read|write)\.(params\{\}|body)$/); + // No `data.read.*` / `data.write.*` line may come back: the request block + // left the record-source blocks with the retired view-level `api` source. + for (const [key] of entries) { + expect(key, 'a request-block member on a record-source block means the retired `api` arm is being undone').not.toMatch(/ data\.(read|write)\./); } }); }); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index eb783670960..627511db08c 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -5740,7 +5740,7 @@ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ * `object-grid-data-view-data-converged` semantic entry. */ data: ViewDataSchema.optional() - .describe("Data source binding (ViewDataSchema — discriminated on `provider`: object | api | value | schema). Static inline rows live at `{ provider: 'value', items: [...] }`; the bare-array shortcut is refused — see migration `object-grid-data-view-data-converged`"), + .describe("Data source binding (ViewDataSchema — discriminated on `provider`: object | value | schema). Static inline rows live at `{ provider: 'value', items: [...] }`; the bare-array shortcut is refused — see migration `object-grid-data-view-data-converged`"), staticData: z.array(z.unknown()).optional().describe("Deprecated bare-array static-rows shortcut the renderer still reads. Prefer `data: { provider: 'value', items: [...] }`"), })); /** Author state (ADR-0122: the bare name is the author state). */ @@ -8692,7 +8692,7 @@ export const ObjectMapPropsSchema = lazySchema(() => strictObject({ * props channel alone. */ data: ViewDataSchema.optional() - .describe("Data source binding (ViewDataSchema — discriminated on `provider`: object | api | value | schema), read FIRST by `getDataConfig`. Static inline rows live at `{ provider: 'value', items: [...] }` or at `staticData`; the bare-array shortcut is refused"), + .describe("Data source binding (ViewDataSchema — discriminated on `provider`: object | value | schema), read FIRST by `getDataConfig`. Static inline rows live at `{ provider: 'value', items: [...] }` or at `staticData`; the bare-array shortcut is refused"), staticData: z.array(z.unknown()).optional() .describe("Inline records — read SECOND by `getDataConfig`, wrapped into a `{ provider: 'value' }` config"), /** @@ -9242,7 +9242,7 @@ export const ObjectGanttPropsSchema = lazySchema(() => strictObject({ * record-source config the fetch resolves through `resolveDataSource`. */ data: ViewDataSchema.optional() - .describe("Data source binding (ViewDataSchema — discriminated on `provider`: object | api | value | schema), read FIRST by `resolveRecordSourceConfig`. Static inline rows live at `{ provider: 'value', items: [...] }` or at `staticData`; the bare-array shortcut is refused"), + .describe("Data source binding (ViewDataSchema — discriminated on `provider`: object | value | schema), read FIRST by `resolveRecordSourceConfig`. Static inline rows live at `{ provider: 'value', items: [...] }` or at `staticData`; the bare-array shortcut is refused"), staticData: z.array(z.unknown()).optional() .describe("Inline records — read SECOND by `resolveRecordSourceConfig`, wrapped into a `{ provider: 'value' }` config"), /** Base query filter — the family's one `ViewFilterRule` array orthography (#15449). */ @@ -9818,7 +9818,7 @@ export const ObjectTreePropsSchema = lazySchema(() => strictObject({ * mirror, which carries no `data` on either face at this pin. */ data: ViewDataSchema.optional() - .describe("Data source binding (ViewDataSchema — discriminated on `provider`: object | api | value | schema), read FIRST by `resolveRecordSourceConfig`. Static inline rows live at `{ provider: 'value', items: [...] }` or at `staticData`; the bare-array shortcut is refused"), + .describe("Data source binding (ViewDataSchema — discriminated on `provider`: object | value | schema), read FIRST by `resolveRecordSourceConfig`. Static inline rows live at `{ provider: 'value', items: [...] }` or at `staticData`; the bare-array shortcut is refused"), staticData: z.array(z.unknown()).optional() .describe("Inline records — read SECOND by `resolveRecordSourceConfig`, wrapped into a `{ provider: 'value' }` config"), /** Base query filter — the family's one `ViewFilterRule` array orthography (#15449). */ diff --git a/packages/spec/src/ui/view-data-api-provider-retirement.test.ts b/packages/spec/src/ui/view-data-api-provider-retirement.test.ts new file mode 100644 index 00000000000..5dd253265be --- /dev/null +++ b/packages/spec/src/ui/view-data-api-provider-retirement.test.ts @@ -0,0 +1,302 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; +import type { z } from 'zod'; + +import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; +import { FormViewSchema, ListViewSchema, ViewDataSchema, ViewSchema } from './view.zod'; + +// ─── The view-level `api` data source is RETIRED ─────────────────────────── +// +// ADR-0049 enforce-or-remove; maintainer ruling 2026-10-12 (letter C — retire, +// not align). `ViewDataSchema`'s `provider: 'api'` arm carried `read` / `write` +// request blocks (`HttpRequestSchema`) of any `url` and any `headers`, so a +// credential or a cross-origin call written into a view was stored as +// metadata, served to every client that read the view and sent by the console +// as written. The page element's API binding (`ElementApiDataSourceSchema`) is +// the governed door for a metadata-declared API call — same-origin, +// credential-free, the console's own signed-in fetch — and one operation with +// two implementations that behave differently keeps the governed one. +// +// Measured before removal, each repo with a lit `provider: 'object'` control +// in the same run: zero producers wrote the arm outside documentation in +// objectstack (bcebd8409), objectui (origin/main f228d710 — the console's +// approval-requests source constructs `ApiDataSource` in code from an +// `ApiDataSourceConfig`, never a `ViewData`) and hotcrm (0f84c9c). A renderer +// reader EXISTED at the objectui pin and is now unreachable from authored +// metadata; it leaves in objectui's own follow-up. +// +// The bookkeeping shape, pinned below: +// +// 1. A VALUE retirement, not a key (the retired bare-string `sort` clause's +// precedent): `data` survives one arm lighter, so there is no +// `retiredKey()` tombstone and no `RETIRED_KEYS_BY_MAJOR` row. The +// prescription rides the union's own `error` map, keyed on the input's +// `provider === 'api'`, so only the spelling that used to be legal gets +// the "was removed" message — every other unknown provider keeps zod's +// own report, which lists the live arms. +// 2. No D2 conversion, deliberately: the replacement lives on a different +// surface (a page component's `dataSource`), and the only mechanical edit +// a conversion could make — deleting the view's `data` so it falls back +// to its object — would silently change what the view shows. The D3 +// semantic entry `view-data-api-provider-retired` carries the judgement. +// 3. A tree-scoped absence scan over the authored spelling (§3 below), so +// the retirement cannot be undone one example at a time. +// +// On the assertion set (the #8586 precedent): a schema refusal raises a +// `ZodError` whose issues carry `code` and `path` but no ADR-0112 `status`, +// so these pins assert the strongest set this surface has — refusal, the +// issue `code`, the `path` naming WHICH site refused, and the prescription +// text (where the wording is the contract, pin the wording). + +// The bare major, the house form of every prescription added since the v18 +// line opened (Changesets pre mode: the first release carrying this removal +// is `18.0.0-next.N`, so a minted minor would name a version that never ships). +const PRESCRIPTION = /`view\.data\.provider: 'api'` was removed in @objectstack\/spec 17 \(ADR-0049/; + +/** The spelling that used to be legal, in its fullest form. */ +const RETIRED_SOURCE = { + provider: 'api', + read: { url: 'https://api.example.com/accounts', method: 'GET', headers: { Authorization: 'Bearer x' } }, + write: { url: 'https://api.example.com/accounts', method: 'POST' }, +} as const; + +const LIST_BASE = { type: 'grid', columns: ['name'] } as const; +const FORM_BASE = { type: 'simple', fields: ['name'] } as const; + +function refusalAt(result: z.ZodSafeParseResult, dotted: string) { + expect(result.success).toBe(false); + if (result.success) return undefined; // narrowing; the assertion above already failed + const issue = result.error.issues.find((i) => i.path.join('.') === dotted); + expect(issue, `the refusal must sit at \`${dotted}\`; got ${JSON.stringify(result.error.issues)}`).toBeDefined(); + return issue!; +} + +describe('ViewDataSchema — the `api` provider is retired', () => { + it("REFUSES `provider: 'api'` at `provider`, carrying the prescription", () => { + const issue = refusalAt(ViewDataSchema.safeParse(RETIRED_SOURCE), 'provider')!; + // The machine-readable half this surface has: a discriminated union with + // no arm for the value raises `invalid_union` at the discriminator. + expect(issue.code).toBe('invalid_union'); + expect(issue.path).toEqual(['provider']); + // The prescription IS the migration doc for whoever hits it — contract, + // not commentary: it names the key, says it was removed, says why the + // arm was wrong, and names the two things to write instead. + expect(issue.message).toMatch(PRESCRIPTION); + expect(issue.message).toMatch(/any `url` and any `headers`/); + expect(issue.message).toMatch(/same-origin and credential-free/); + expect(issue.message).toMatch(/`data: \{ provider: 'object', object: '' \}`/); + expect(issue.message).toMatch(/`data: \{ provider: 'value', items: \[\.\.\.\] \}`/); + expect(issue.message).toMatch(/`dataSource: \{ provider: 'api', read: \{ url: '\/api\/\.\.\.' \} \}`/); + expect(issue.message).toMatch(/signed-in fetch/); + // Customer-facing text carries the ADR, never an issue id (`check:doc-authoring`). + expect(issue.message).toMatch(/ADR-0049/); + expect(issue.message).not.toMatch(/#\d{3,}/); + // No conversion covers this surface, so the prescription must NOT promise + // the migration tool an edit it cannot make. + expect(issue.message).not.toMatch(/os migrate meta/); + }); + + it('REFUSES the bare `{ provider: "api" }` the same way — the value, not the request block, is what left', () => { + const issue = refusalAt(ViewDataSchema.safeParse({ provider: 'api' }), 'provider')!; + expect(issue.message).toMatch(PRESCRIPTION); + }); + + it('REFUSES it through `ListViewSchema.data` and `FormViewSchema.data`, at the nested path', () => { + const list = refusalAt(ListViewSchema.safeParse({ ...LIST_BASE, data: RETIRED_SOURCE }), 'data.provider')!; + expect(list.code).toBe('invalid_union'); + expect(list.message).toMatch(PRESCRIPTION); + const form = refusalAt(FormViewSchema.safeParse({ ...FORM_BASE, data: RETIRED_SOURCE }), 'data.provider')!; + expect(form.message).toMatch(PRESCRIPTION); + }); + + it('REFUSES it through a view container (`ViewSchema.list.data`) — the `defineView` door', () => { + const issue = refusalAt( + ViewSchema.safeParse({ object: 'account', list: { ...LIST_BASE, data: RETIRED_SOURCE } }), + 'list.data.provider', + )!; + expect(issue.message).toMatch(PRESCRIPTION); + }); + + it("keeps zod's default report for every OTHER unknown provider — and that report lists the three live arms", () => { + // The prescription is keyed on `provider === 'api'`: an author who wrote + // `provider: 'rest'` never had a legal spelling removed and is told the + // live vocabulary instead. + const issue = refusalAt(ViewDataSchema.safeParse({ provider: 'rest', read: { url: '/x' } }), 'provider')!; + expect(issue.message).not.toMatch(/was removed/); + expect(issue.message).toMatch(/'object'/); + expect(issue.message).toMatch(/'value'/); + expect(issue.message).toMatch(/'schema'/); + expect(issue.message).not.toMatch(/'api'/); + // A non-object input meets zod's type report, not the prescription. + const notObject = ViewDataSchema.safeParse('api'); + expect(notObject.success).toBe(false); + expect(notObject.success ? '' : notObject.error.issues[0]!.message).not.toMatch(/was removed/); + }); + + it('still ACCEPTS the three surviving arms, value-for-value (controls)', () => { + const object = ViewDataSchema.safeParse({ provider: 'object', object: 'account' }); + expect(object.success, JSON.stringify(object.success ? {} : object.error.issues)).toBe(true); + expect(object.data).toEqual({ provider: 'object', object: 'account' }); + const value = ViewDataSchema.safeParse({ provider: 'value', items: [{ id: 1 }] }); + expect(value.success).toBe(true); + expect(value.data).toEqual({ provider: 'value', items: [{ id: 1 }] }); + const schema = ViewDataSchema.safeParse({ provider: 'schema', schemaId: 'report' }); + expect(schema.success).toBe(true); + expect(schema.data).toEqual({ provider: 'schema', schemaId: 'report' }); + // …and each surviving arm is still CLOSED (an undeclared key is refused), + // so the union did not loosen while losing an arm. + const loose = ViewDataSchema.safeParse({ provider: 'object', object: 'account', read: { url: '/x' } }); + expect(loose.success).toBe(false); + expect(loose.success ? '' : loose.error.issues.map((i) => i.code).join(',')).toContain('unrecognized_keys'); + }); +}); + +describe('registration (ADR-0087 D3; a value retirement registers no key)', () => { + it('the D3 semantic entry is wired into step 18, and no D2 conversion claims the surface', () => { + const step = MIGRATIONS_BY_MAJOR[18]; + expect(step.semantic.map((m) => m.id)).toContain('view-data-api-provider-retired'); + const entry = step.semantic.find((m) => m.id === 'view-data-api-provider-retired')!; + expect(entry.surface).toMatch(/view\.data/); + expect(entry.reason).toMatch(/no mechanical rewrite/i); + // No D2 by design — a conversion id here would mean someone wired a + // transform that silently rewrites what a view shows. + expect(step.conversionIds).not.toContain('view-data-api-provider-removed'); + expect(step.conversionIds.some((id) => /view-data-api/.test(id))).toBe(false); + }); + + it('no `ui/ViewData:*` key row exists in any major — the arm left, the keys stayed', () => { + const all = Object.values(RETIRED_KEYS_BY_MAJOR).flat(); + expect(all.length).toBeGreaterThan(50); // anti-vacuity: the table is populated + expect(all.filter((k) => k.startsWith('ui/ViewData:'))).toEqual([]); + }); +}); + +// ─── §3 tree-scoped absence ──────────────────────────────────────────────── +// +// The retired spelling is a `data` block whose `provider` is `'api'`, in any +// of the three dialects the tree writes metadata in. The PAGE ELEMENT's +// `dataSource: { provider: 'api', … }` is the live replacement and must NOT +// match: the discriminating token is the key the block hangs on (`data`, not +// `dataSource`), which is why the matcher anchors on it rather than on the +// provider value alone. +// +// Radius, declared in `scripts/cross-package-test-inputs.mjs` under +// `@objectstack/spec` (the #15513 pin's rows, this file as second witness): +// `packages/` per extension (never `.tsx` — a typed `.tsx` resurrection fails +// `tsc` in its own package), `examples/` for the non-code extensions only +// (every example has its own `typecheck`), and `skills/`, `content/`, +// `scripts/` whole. +describe('tree-scoped absence: no authored view-level `api` data source survives inside the declared radius', () => { + 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('/'); + + const WALK_ROOTS = ['packages', 'examples', 'skills', 'content', 'scripts']; + const SCANNED_EXT = new Set(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx', '.yaml', '.yml']); + 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']); + /** tsup's transient bundle of `tsup.config.ts` (see the #15513 pin for the measured crash). */ + const TSUP_BUNDLED_CONFIG = /\.bundled_[^./]+\.mjs$/; + + /** + * A `data` block (TS / JS / JSON spelling) whose `provider` is `'api'`: + * `data: {` or `"data": {`, then anything but a brace at depth 0 (one nested + * block allowed, so a `read: { … }` written BEFORE `provider` still matches), + * then `provider: 'api'`. `\bdata` + optional quote + `:` cannot match + * `dataSource:` — there the next character is `S`. + */ + const BLOCK = /\bdata["']?\s*:\s*\{(?:[^{}]|\{[^{}]*\})*?\bprovider["']?\s*:\s*["']api["']/; + /** + * The YAML spelling: a `data:` mapping line, then lines indented deeper than + * it (the backreference holds the block to its own indentation), then + * `provider: api` (quoted or bare, an optional trailing comment). + */ + const YAML_BLOCK = /^([ \t]*)data:[ \t]*$(?:\n\1(?![ \t]*provider:)[ \t]+[^\n]*)*\n\1[ \t]+provider:[ \t]*["']?api["']?[ \t]*(?:#[^\n]*)?$/m; + + /** Structural exclusions, each with its reason. NOT an allowlist file: these are the retirement kit and its projections. */ + const EXCLUDED = new Set([ + // This pin spells the retired shape to assert its absence. + THIS_FILE, + ]); + const EXCLUDED_PREFIXES = [ + // Registers the retirement (the D3 entry's prose names the surface). + 'packages/spec/src/migrations/', + // Release prose records the removal (release-owned; never edited by a code PR). + 'content/docs/releases/', + '.changeset/', + ]; + + const vanished: string[] = []; + 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 recognises the retired spelling in every dialect and ignores the page element binding (anti-vacuity)', () => { + // TS, one line and multi-line; `read` before `provider`; JSON. + expect(BLOCK.test("data: { provider: 'api', read: { url: '/api/cases', method: 'GET' } }")).toBe(true); + expect(BLOCK.test("data: {\n provider: 'api',\n read: { url: '/api/external/tasks' },\n}")).toBe(true); + expect(BLOCK.test("data: { read: { url: '/x' }, provider: \"api\" }")).toBe(true); + expect(BLOCK.test('"data": { "provider": "api", "read": { "url": "/x" } }')).toBe(true); + expect(YAML_BLOCK.test('list:\n data:\n provider: api\n read:\n url: /x\n')).toBe(true); + expect(YAML_BLOCK.test("data:\n read:\n url: /x\n provider: 'api' # old\n")).toBe(true); + // The live replacement, and the surviving arms, do NOT match. + expect(BLOCK.test("dataSource: { provider: 'api', read: { url: '/api/v1/cloud/home' }, path: 'data.environments' }")).toBe(false); + expect(BLOCK.test("dataSource: {\n provider: 'api',\n read: { url: '/api/v1/cloud/home' },\n}")).toBe(false); + expect(BLOCK.test("data: { provider: 'object', object: 'task' }")).toBe(false); + expect(BLOCK.test("data: { provider: 'value', items: [] }")).toBe(false); + expect(BLOCK.test('"data": { "provider": "value", "items": [] }')).toBe(false); + expect(YAML_BLOCK.test('dataSource:\n provider: api\n read:\n url: /x\n')).toBe(false); + expect(YAML_BLOCK.test('data:\n provider: object\n object: task\n')).toBe(false); + // A sibling key at the SAME indentation ends the YAML block: `provider: api` + // under a neighbouring mapping is not this block's. + expect(YAML_BLOCK.test('data:\n provider: object\nother:\n provider: api\n')).toBe(false); + // Prose and the prescription's own spelling are not an authored block. + expect(BLOCK.test("`view.data.provider: 'api'` was removed")).toBe(false); + expect(BLOCK.test("the `api` provider on a view's `data`")).toBe(false); + }); + + it('no authored view-level `api` data source survives inside the declared radius outside the retirement kit', () => { + const offenders: string[] = []; + let visited = 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 (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; + // Both dialects on every file: a YAML example lives inside `.md` / + // `.mdx` fences too, and the line-anchored YAML matcher is inert on + // code. + const m = BLOCK.exec(text) ?? YAML_BLOCK.exec(text); + if (m) offenders.push(`${rel} authors \`${m[0].replace(/\s+/g, ' ').trim().slice(0, 120)}\``); + } + }; + for (const root of WALK_ROOTS) walk(path.join(REPO_ROOT, root)); + // Anti-vacuity: the walk really covered the tree. + expect(visited).toBeGreaterThan(1000); + expect(offenders, "a view authored with `provider: 'api'` means the retirement is being undone — a metadata-declared API call is a page element's `dataSource` binding").toEqual([]); + }); +}); diff --git a/packages/spec/src/ui/view-strictness-batch18.test.ts b/packages/spec/src/ui/view-strictness-batch18.test.ts index 0f7b6a79380..83e74333919 100644 --- a/packages/spec/src/ui/view-strictness-batch18.test.ts +++ b/packages/spec/src/ui/view-strictness-batch18.test.ts @@ -115,10 +115,12 @@ describe('批 18, unknown keys refused — the doors these shapes are reachable // 2. Every closed site, at its own path // =========================================================================== describe('批 18 — closed sites reject unknown keys where they live', () => { - describe('ViewDataSchema — all four provider arms', () => { + // Three arms, not four: the `api` arm was retired whole (ADR-0049, ruling + // 2026-10-12 letter C) — `view-data-api-provider-retirement.test.ts` pins + // that `provider: 'api'` is refused by name rather than by an unknown key. + describe('ViewDataSchema — all three provider arms', () => { it.each([ ['object', { provider: 'object', object: 'crm_lead' }], - ['api', { provider: 'api' }], ['value', { provider: 'value', items: [] }], ['schema', { provider: 'schema', schemaId: 'report' }], ])('the `%s` arm rejects an undeclared key', (_arm, base) => { diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index 76cd89a1eb6..d91bfa369ae 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -116,34 +116,18 @@ describe('ViewDataSchema', () => { expect(() => ViewDataSchema.parse(data)).toThrow(); }); - it('should accept api provider with read configuration', () => { - const data: ViewData = { - provider: 'api', - read: { - url: '/api/accounts', - method: 'GET', - params: { status: 'active' }, - }, - }; - - expect(() => ViewDataSchema.parse(data)).not.toThrow(); - }); - - it('should accept api provider with read and write configurations', () => { - const data: ViewData = { - provider: 'api', - read: { - url: '/api/accounts', - method: 'GET', - }, - write: { - url: '/api/accounts', - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - }, - }; - - expect(() => ViewDataSchema.parse(data)).not.toThrow(); + // The two accept pins for the `api` provider that lived here were REPLACED + // by its retirement pins (ADR-0049 enforce-or-remove, maintainer ruling + // 2026-10-12 letter C): `view-data-api-provider-retirement.test.ts` holds + // the refusal, its prescription, the surviving arms and the tree-scoped + // absence scan. One refusal stays here as the control that the arm is gone + // from THIS schema and not merely from that file's fixtures. + it("REFUSES the retired `api` provider at `provider`, with the prescription", () => { + const result = ViewDataSchema.safeParse({ provider: 'api', read: { url: '/api/accounts' } }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'provider'); + expect(issue, JSON.stringify(result.error!.issues)).toBeDefined(); + expect(issue!.message).toMatch(/`view\.data\.provider: 'api'` was removed/); }); it('should accept value provider with static items', () => { @@ -589,21 +573,9 @@ describe('ListViewSchema', () => { expect(() => ListViewSchema.parse(listView)).not.toThrow(); }); - it('should accept list view with api provider', () => { - const listView: ListView = { - type: 'grid', - columns: ['name', 'email', 'phone'], - data: { - provider: 'api', - read: { - url: '/api/contacts', - method: 'GET', - }, - }, - }; - - expect(() => ListViewSchema.parse(listView)).not.toThrow(); - }); + // `should accept list view with api provider` lived here until the view-level + // `api` data source was retired; `view-data-api-provider-retirement.test.ts` + // pins the refusal through `ListViewSchema.data` at its nested path. it('should accept list view with value provider', () => { const listView: ListView = { @@ -621,16 +593,16 @@ describe('ListViewSchema', () => { expect(() => ListViewSchema.parse(listView)).not.toThrow(); }); - it('should accept kanban view with custom api data source', () => { + it('should accept kanban view with a non-object (static value) data source', () => { + // Was an `api` source until that arm was retired; the pin's point — a + // kanban config beside a data source that is not the bound object — is + // kept on the surviving `value` arm. const kanbanView: ListView = { type: 'kanban', columns: ['name', 'owner', 'amount'], data: { - provider: 'api', - read: { - url: '/api/opportunities', - params: { view: 'pipeline' }, - }, + provider: 'value', + items: [{ name: 'Acme renewal', owner: 'ann', amount: 1200, stage: 'proposal' }], }, kanban: { groupByField: 'stage', diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index b61df63c595..31f279c275e 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -159,12 +159,59 @@ export type { HttpRequest } from '../shared/http.zod'; */ export type { HttpMethodSubset } from '../shared/http.zod'; +/** + * Prescription for the retired view-level `api` data source — the + * `provider: 'api'` arm of {@link ViewDataSchema} (ADR-0049 enforce-or-remove; + * maintainer ruling 2026-10-12, letter C: retire, not align). + * + * The arm carried `read` / `write` request blocks of any `url` and any + * `headers`, so a credential or a cross-origin call written into a view was + * stored as metadata, served to every client that read the view and sent by + * the console as written. The page element's API binding + * (`ElementApiDataSourceSchema`, `ui/page.zod.ts`) is the governed door for a + * metadata-declared API call — same-origin, credential-free, read with the + * console's own signed-in fetch — and one operation with two implementations + * that behave differently keeps the governed one: the view-level source is + * re-homed on the page element and removed, not aligned. + * + * Like the retired bare-string `sort` clause + * ({@link LIST_VIEW_SORT_STRING_RETIRED}) this retires a VALUE of the + * discriminator, not a key: `data` survives, one arm lighter, so there is no + * `retiredKey()` tombstone to hang the prescription on. The union's own + * `error` map carries it, keyed on `input.provider === 'api'`, so only the + * spelling that used to be legal gets the "was removed" message — every other + * invalid discriminator keeps zod's default report, which lists the live arms. + * + * No D2 conversion, deliberately: the replacement lives on a different surface + * (a page component's `dataSource`), and the only mechanical edit a conversion + * could make — deleting `data` so the view falls back to its object — would + * silently change what the view shows. An author or a stored row still carrying + * the arm is refused at parse with this prescription; the D3 semantic entry + * `view-data-api-provider-retired` carries the judgement an upgrader owes. + */ +const VIEW_DATA_API_PROVIDER_RETIRED = + "`view.data.provider: 'api'` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — " + + 'a view-level API source took any `url` and any `headers`, so a credential or a cross-origin call ' + + 'written there was stored with the view, served to every client that read it and sent by the ' + + "console as written; a metadata-declared API call now has one door, the page element's binding, " + + 'which is same-origin and credential-free. ' + + "Bind the view to its object — `data: { provider: 'object', object: '' }` — or to " + + "static rows — `data: { provider: 'value', items: [...] }` — and move the API read onto a page " + + "component: `dataSource: { provider: 'api', read: { url: '/api/...' } }` (with an optional `path` " + + 'into the response) under a page\'s `regions[].components[]`, which the console reads with its ' + + 'own signed-in fetch.'; + /** * View Data Source Configuration * Supports three modes: * 1. 'object': Standard Protocol - Auto-connects to ObjectStack Metadata and Data APIs - * 2. 'api': Custom API - Explicitly provided API URLs - * 3. 'value': Static Data - Hardcoded data array + * 2. 'value': Static Data - Hardcoded data array + * 3. 'schema': Schema-bound - a standalone form shaped by a JSON Schema + * + * The former fourth mode, `'api'` (custom API URLs in `read` / `write` + * request blocks), is RETIRED — refused by name with + * {@link VIEW_DATA_API_PROVIDER_RETIRED}. A metadata-declared API call is a + * page element's `dataSource` binding (`ui/page.zod.ts`), never a view's. */ export const ViewDataSchema = lazySchema(() => z.discriminatedUnion('provider', [ strictObject({ @@ -183,23 +230,9 @@ export const ViewDataSchema = lazySchema(() => z.discriminatedUnion('provider', provider: z.literal('object'), object: z.string().describe('Target object name'), }), - strictObject({ - surface: 'this `api` data source', - history: VIEW_HISTORY, - // The HTTP verbs are the request BLOCKS' business (`read`/`write` each hold - // an `HttpRequestSchema`), so an author who put the whole request inline is - // pointed at the block that owns it rather than at a near-miss key. - guidance: { - url: 'Set the URL inside the request block: `read: { url, method }` (or `write: { … }`) — the data source itself declares only `read` / `write`.', - method: 'Set the method inside the request block: `read: { url, method }` (or `write: { … }`).', - fetch: 'Use `read` for the fetch request and `write` for the submit request.', - submit: 'Use `write` for the submit request (and `read` for the fetch request).', - }, - }, { - provider: z.literal('api'), - read: HttpRequestSchema.optional().describe('Configuration for fetching data'), - write: HttpRequestSchema.optional().describe('Configuration for submitting data (for forms/editable tables)'), - }), + // The `api` arm — `{ provider: 'api', read?: HttpRequest, write?: HttpRequest }` + // — was REMOVED here; see `VIEW_DATA_API_PROVIDER_RETIRED` above for what + // replaces it and the union's `error` map below for how an author meets it. strictObject({ surface: 'this `value` data source', history: VIEW_HISTORY, @@ -230,7 +263,20 @@ export const ViewDataSchema = lazySchema(() => z.discriminatedUnion('provider', /** Optional inline JSON Schema; when omitted the runtime resolves schemaId from the server. */ schema: z.record(z.string(), z.unknown()).optional().describe('Inline JSON Schema (Draft 2020-12). Optional when schemaId is resolvable.'), }), -])); +], { + // The retired `api` arm's prescription, raised at `provider` for exactly the + // spelling that used to be legal (an `invalid_union` issue, path + // `[..., 'provider']`). `hasOwnProperty`-free on purpose: the lookup is a + // literal comparison, so an authored `provider: 'constructor'` meets zod's + // own report like every other unknown value. + error: (issue) => { + const input = issue.input; + return typeof input === 'object' && input !== null + && (input as { provider?: unknown }).provider === 'api' + ? VIEW_DATA_API_PROVIDER_RETIRED + : undefined; + }, +})); /** * Canonical filter operators for view filter rules. @@ -2107,7 +2153,7 @@ export const GanttConfigSchema = lazySchema(() => strictObject({ // optional; each describe says what the renderer does with the key. borderColorField: z.string().optional().describe('Field carrying a per-task alert stroke color — any CSS color or semantic palette name (red, orange, …): the bar keeps its fill and gains an outline plus halo in that color (e.g. red for overdue, orange for due-soon; typically a server-computed alert field). Empty or null means no stroke'), lockField: z.string().optional().describe('Field marking a row view-only (truthy means locked): a locked bar cannot be dragged or resized, its progress cannot be dragged, no dependency can be drawn from it and its inline-edit and edit/delete menu entries are hidden — clicking it (open drawer, jump) still works. Independent of the global readOnly; freezes individual levels while siblings stay editable'), - objectField: z.string().optional().describe("Field carrying the row's OWN object API name, for mixed-object trees (an api provider composing parent-object rows with child-object rows): the detail drawer and its full-page link follow each row's real object instead of the view's bound object. Empty or missing falls back to the bound object"), + objectField: z.string().optional().describe("Field carrying the row's OWN object API name, for mixed-object trees (a row set composing parent-object rows with child-object rows): the detail drawer and its full-page link follow each row's real object instead of the view's bound object. Empty or missing falls back to the bound object"), summaryExtent: z.enum(['children', 'self']).optional().describe("How a summary bar's span is computed. 'children' (renderer default) rolls the bar up from its children — min start, max end, duration-weighted progress — and ignores the record's own dates; 'self' renders the record's OWN start, end and progress and falls back to rollup only for records without dates (use it when the parent's schedule is authoritative, e.g. a shift plan whose work-order children are locked history)"), defaultCollapsedDepth: z.number().int().min(0).optional().describe('Auto-collapse tree nodes at or below this 0-indexed depth on first render (roots are depth 0): every node at that depth or deeper that has children starts folded; the user can still expand them. Omit to start fully expanded'), dependencyTypes: z.boolean().optional().describe('Whether the backing store persists dependency link TYPES (fs, ss, ff, sf); renderer default true. Set false when dependencies are bare predecessor ids: the link menu hides the type switcher (a switch would be silently reverted on refetch) and drag-created links are always finish-to-start'), @@ -4643,8 +4689,9 @@ export const FormViewSchema = lazySchema(() => strictObject({ * re-verified the entry: `defineForm` (below) writes * `data: { provider: 'schema', schemaId }` onto EVERY metadata form, and * `metadata-protocol` serves it to the metadata-admin pipeline. The - * record-form provider values (`object`/`api`/`value`) may still be - * unread — that is a narrower value-subset audit, not a key removal. + * record-form provider values (`object`/`value`) may still be unread — that + * is a narrower value-subset audit, not a key removal. The former `api` + * value is retired on the union itself (`VIEW_DATA_API_PROVIDER_RETIRED`). */ data: ViewDataSchema.optional().describe('Data source configuration (defaults to "object" provider)'), diff --git a/packages/spec/variant-docs.json b/packages/spec/variant-docs.json index 63aefca6c68..1c44135d2fa 100644 --- a/packages/spec/variant-docs.json +++ b/packages/spec/variant-docs.json @@ -45,7 +45,7 @@ "note": "The gate's founding case: apps.mdx claimed eight types and omitted `separator` (#4165)." }, { - "key": "provider:api|object|schema|value", + "key": "provider:object|schema|value", "label": "view data provider", "docs": [ "content/docs/ui/views.mdx", diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index 37d5c367d16..703544c3f08 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -54,6 +54,7 @@ "src/ui/master-detail-detail-sort-field-retirement.test.ts", "src/ui/object-grid-resizable-columns-retirement.test.ts", "src/ui/page-header-breadcrumb-retirement.test.ts", + "src/ui/view-data-api-provider-retirement.test.ts", "src/ui/view-item-owner-hidden-retirement.test.ts", "src/ui/view-list-tabs-retirement.test.ts" ] diff --git a/scripts/cross-package-test-inputs.mjs b/scripts/cross-package-test-inputs.mjs index 5f399bb3c9c..c991ca96325 100644 --- a/scripts/cross-package-test-inputs.mjs +++ b/scripts/cross-package-test-inputs.mjs @@ -249,21 +249,69 @@ export const CROSS_PACKAGE_TEST_INPUTS = { ], // The #15513 absence pin's walk radius (see the globs' comment above): // one witness per glob no literal path on this package's roster holds. - 'packages/**/*.mts': ['packages/spec/src/system/compliance-families-retirement.test.ts'], - 'packages/**/*.cts': ['packages/spec/src/system/compliance-families-retirement.test.ts'], - 'packages/**/*.js': ['packages/spec/src/system/compliance-families-retirement.test.ts'], - 'packages/**/*.mjs': ['packages/spec/src/system/compliance-families-retirement.test.ts'], - 'packages/**/*.cjs': ['packages/spec/src/system/compliance-families-retirement.test.ts'], - 'packages/**/*.json': ['packages/spec/src/system/compliance-families-retirement.test.ts'], - 'packages/**/*.md': ['packages/spec/src/system/compliance-families-retirement.test.ts'], - 'packages/**/*.mdx': ['packages/spec/src/system/compliance-families-retirement.test.ts'], - 'packages/**/*.yaml': ['packages/spec/src/system/compliance-families-retirement.test.ts'], - 'packages/**/*.yml': ['packages/spec/src/system/compliance-families-retirement.test.ts'], - 'examples/**/*.json': ['packages/spec/src/system/compliance-families-retirement.test.ts'], - 'examples/**/*.md': ['packages/spec/src/system/compliance-families-retirement.test.ts'], - 'examples/**/*.mdx': ['packages/spec/src/system/compliance-families-retirement.test.ts'], - 'examples/**/*.yaml': ['packages/spec/src/system/compliance-families-retirement.test.ts'], - 'examples/**/*.yml': ['packages/spec/src/system/compliance-families-retirement.test.ts'], + // src/ui/view-data-api-provider-retirement.test.ts (the view-level + // `api` data-source retirement) walks the SAME five roots with the same + // per-extension rule, so it is the second witness on every row. + 'packages/**/*.mts': [ + 'packages/spec/src/system/compliance-families-retirement.test.ts', + 'packages/spec/src/ui/view-data-api-provider-retirement.test.ts', + ], + 'packages/**/*.cts': [ + 'packages/spec/src/system/compliance-families-retirement.test.ts', + 'packages/spec/src/ui/view-data-api-provider-retirement.test.ts', + ], + 'packages/**/*.js': [ + 'packages/spec/src/system/compliance-families-retirement.test.ts', + 'packages/spec/src/ui/view-data-api-provider-retirement.test.ts', + ], + 'packages/**/*.mjs': [ + 'packages/spec/src/system/compliance-families-retirement.test.ts', + 'packages/spec/src/ui/view-data-api-provider-retirement.test.ts', + ], + 'packages/**/*.cjs': [ + 'packages/spec/src/system/compliance-families-retirement.test.ts', + 'packages/spec/src/ui/view-data-api-provider-retirement.test.ts', + ], + 'packages/**/*.json': [ + 'packages/spec/src/system/compliance-families-retirement.test.ts', + 'packages/spec/src/ui/view-data-api-provider-retirement.test.ts', + ], + 'packages/**/*.md': [ + 'packages/spec/src/system/compliance-families-retirement.test.ts', + 'packages/spec/src/ui/view-data-api-provider-retirement.test.ts', + ], + 'packages/**/*.mdx': [ + 'packages/spec/src/system/compliance-families-retirement.test.ts', + 'packages/spec/src/ui/view-data-api-provider-retirement.test.ts', + ], + 'packages/**/*.yaml': [ + 'packages/spec/src/system/compliance-families-retirement.test.ts', + 'packages/spec/src/ui/view-data-api-provider-retirement.test.ts', + ], + 'packages/**/*.yml': [ + 'packages/spec/src/system/compliance-families-retirement.test.ts', + 'packages/spec/src/ui/view-data-api-provider-retirement.test.ts', + ], + 'examples/**/*.json': [ + 'packages/spec/src/system/compliance-families-retirement.test.ts', + 'packages/spec/src/ui/view-data-api-provider-retirement.test.ts', + ], + 'examples/**/*.md': [ + 'packages/spec/src/system/compliance-families-retirement.test.ts', + 'packages/spec/src/ui/view-data-api-provider-retirement.test.ts', + ], + 'examples/**/*.mdx': [ + 'packages/spec/src/system/compliance-families-retirement.test.ts', + 'packages/spec/src/ui/view-data-api-provider-retirement.test.ts', + ], + 'examples/**/*.yaml': [ + 'packages/spec/src/system/compliance-families-retirement.test.ts', + 'packages/spec/src/ui/view-data-api-provider-retirement.test.ts', + ], + 'examples/**/*.yml': [ + 'packages/spec/src/system/compliance-families-retirement.test.ts', + 'packages/spec/src/ui/view-data-api-provider-retirement.test.ts', + ], }, }, '@objectstack/core': { diff --git a/skills/objectstack-ui/rules/list-views.md b/skills/objectstack-ui/rules/list-views.md index 55787afdb2e..59b8d261ac9 100644 --- a/skills/objectstack-ui/rules/list-views.md +++ b/skills/objectstack-ui/rules/list-views.md @@ -37,22 +37,20 @@ export const CaseViews = defineView({ ### Data Source (`data`) -Every view connects to data via one of three providers: +Every view connects to data via one of two providers: ```typescript // Auto-connect to an ObjectStack object data: { provider: 'object', object: 'support_case' } -// Custom API endpoint -data: { provider: 'api', read: { url: '/api/cases', method: 'GET' } } - // Static inline data data: { provider: 'value', items: [...] } ``` > **Best practice:** Always use `provider: 'object'` when the data source is > an ObjectStack-managed object. It enables automatic CRUD, real-time updates, -> filtering, and pagination. +> filtering, and pagination. A view never calls an API: an API read is a +> page element's `dataSource` binding. ### Columns