From bfb76d0646bb6c92207053fd945ab699d282cc69 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 03:50:05 +0000 Subject: [PATCH 1/3] feat(types,plugin-form,plugin-charts,app-shell)!: dashboard widgets bind datasets; formula -> expression; display_field legs retired (objectui#11070 round 6) - (i) objectui#11228 ruling C: the six filtered-dashboard catalog fixtures, dashboard-filters.md and the schema-reference DashboardComponentSchema fence move to the ADR-0021 dataset form; the inline dialect is no longer taught (also plugin-dashboard.mdx and the plugin-dashboard README). The strict face is unchanged and keeps refusing the nine keys by name (pinned), and every plugin-dashboard catalog entry parses on it (pinned). - (ii) FormulaFieldMetadata.formula -> expression (FieldSchema's member by reference); formula.mdx follows; sanitizeFormData drops its formula flag read. objectui#6526 ruling B's Studio arm is untouched. - (iii) deriveColumns / hydrateColumns, ObjectChart and resolveActionParams read displayField, then reference_field; the display_field legs and RuntimeField.display_field are retired. Fixtures move to displayField; the fold pins keep the snake spelling as input. - (iv) dated notes on the 7166 and lucky-donkeys-shave changesets. Claude-Session: https://claude.ai/code/session_01TdiauJaVCHuj45EzZGUxHh Co-authored-by: Claude --- .changeset/11070-dashboard-formula-round6.md | 29 +++++++++ .../7166-retire-inert-fieldmeta-copies.md | 2 + .changeset/lucky-donkeys-shave.md | 2 + content/docs/api/schema-reference.md | 47 +++++--------- content/docs/fields/formula.mdx | 51 ++++++++------- content/docs/guide/dashboard-filters.md | 64 +++++++++---------- content/docs/plugins/plugin-dashboard.mdx | 15 ++--- examples/schema-catalog/src/catalog-meta.json | 4 +- examples/schema-catalog/src/index.ts | 4 +- .../filtered-dashboard-dataset-widgets.json | 13 +--- .../filtered-dashboard-date-presets.json | 35 +++------- .../filtered-dashboard-dynamic-options.json | 26 ++------ .../filtered-dashboard-filter-types.json | 33 +++------- .../filtered-dashboard-target-widgets.json | 30 +++------ .../plugin-dashboard/filtered-dashboard.json | 33 +++------- .../plugin-dashboard-component-schema.test.ts | 22 +++++++ .../expandableFamily.identity-5874.test.ts | 6 +- ...tionParams.declaredLookupLegs-7435.test.ts | 21 ++++-- .../src/utils/resolveActionParams.test.ts | 4 +- .../src/utils/resolveActionParams.ts | 40 ++++++++---- packages/core/src/utils/reference-keys.ts | 2 +- ...hart.declaredDisplayFieldLeg-7435.test.tsx | 45 +++++++++---- packages/plugin-charts/src/ObjectChart.tsx | 42 ++++++------ packages/plugin-dashboard/README.md | 40 ++++++------ ...DashboardGridLayout.legacyRetired.test.tsx | 21 +++--- .../src/deriveMasterDetail.test.ts | 25 +++++++- .../plugin-form/src/deriveMasterDetail.ts | 25 +++++--- .../src/formArmsWritePayload-10563.test.tsx | 2 +- packages/plugin-form/src/sanitize.test.ts | 9 +++ packages/plugin-form/src/sanitize.ts | 9 ++- .../strict-face-read-keys-11070.test.ts | 33 +++++++++- packages/types/src/field-types.ts | 17 +++-- 32 files changed, 415 insertions(+), 336 deletions(-) create mode 100644 .changeset/11070-dashboard-formula-round6.md diff --git a/.changeset/11070-dashboard-formula-round6.md b/.changeset/11070-dashboard-formula-round6.md new file mode 100644 index 0000000000..507cdd3426 --- /dev/null +++ b/.changeset/11070-dashboard-formula-round6.md @@ -0,0 +1,29 @@ +--- +'@object-ui/types': minor +'@object-ui/plugin-form': minor +'@object-ui/plugin-charts': minor +'@object-ui/app-shell': minor +--- + +`FormulaFieldMetadata` declares `@objectstack/spec`'s `expression` in place of `formula`, and three readers of a lookup's display pointer read the spec's `displayField` alone (objectui#11070, round 6). Both retired spellings go at once, with no alias. + +- **Types.** `FormulaFieldMetadata.formula` is removed. `FormulaFieldMetadata.expression` is `FieldSchema`'s `expression` by reference: a CEL source string, or the spec's `{ dialect, source, … }` envelope. Nothing in ObjectUI read the removed member through the type. `FieldSchema` refuses `formula` by name on every field type, with a rename hint to `expression`. +- **Form payloads.** `sanitizeFormData` (`@object-ui/plugin-form`) no longer treats a `formula` key as a "computed" flag. Every `type: 'formula'` field is still dropped from the payload by its type, as before. Only a field of some other type that carries `formula` changes: its value is now sent like any writable field's. The spec refuses such a definition at publish, so a served one cannot carry it. +- **Display pointer.** `deriveColumns` and `hydrateColumns` (`@object-ui/plugin-form`, the master-detail grid columns), `ObjectChart`'s group-by labels (`@object-ui/plugin-charts`) and the action-param resolver (`@object-ui/app-shell`) read `displayField`, then `reference_field`. None of them reads `display_field` any more. +- **A fix for spec-spelled lookups in master-detail grids.** `deriveColumns` and `hydrateColumns` read `display_field || reference_field` before, with no `displayField` leg. A lookup that declared only `displayField`, which is the spec's spelling, got no display pointer on its grid column. It now gets one. + +A definition served through `ObjectStackAdapter.getObjectSchema` or `MetadataProvider` loses nothing: the ingestion pass (objectui#7650) stamps a stored `display_field` onto `displayField` before any of these readers sees it. Measured with a lookup carrying `display_field: 'title'` served through `ObjectStackAdapter`: the master-detail column, the chart's axis label and the action param all resolve the `title` column before and after this change. + +## ⚠️ BREAKING, priced as minor under the fixed group's version policy + +TypeScript that writes `formula` on a `FormulaFieldMetadata` no longer compiles (an excess-property error naming the key). Rename it to `expression` and write the formula in CEL against the record, for example `record.quantity * record.unit_price`. + +At runtime, a lookup whose display pointer is spelled only `display_field` loses it wherever the ingestion pass does not run first. Measured before and after this change, on a lookup with `display_field: 'title'` handed to the readers directly: + +- **`deriveColumns` / `hydrateColumns` with a `childSchema` that did not come through the ingestion pass** (an external caller, or a master-detail form whose `DataSource` is not `ObjectStackAdapter`): the column's `displayField` was `title`. It is now absent. +- **`ObjectChart` on a `DataSource` other than `ObjectStackAdapter`**, grouped by that lookup: the axis label came from the `title` column. It now comes from the `name` column, the generic fallback. +- **The action-param resolver, when a host passes its own unfolded `objects`** (for example through `RecordDetailView`'s `objects` prop): the lookup param's `displayField` was `title`. It is now absent. + +The same lookups spelled `displayField` work in all three before and after. + +**Fix:** spell the pointer `displayField`, or serve the definition through `ObjectStackAdapter`. diff --git a/.changeset/7166-retire-inert-fieldmeta-copies.md b/.changeset/7166-retire-inert-fieldmeta-copies.md index 13af8122f4..36031549a3 100644 --- a/.changeset/7166-retire-inert-fieldmeta-copies.md +++ b/.changeset/7166-retire-inert-fieldmeta-copies.md @@ -60,3 +60,5 @@ a consumer READS a key; it does not establish that a given BAG is how the consum negative assertion on a fixture that still declares it. ⚠️ **Dated note, 2026-09-30 — the lookup and user cells read `reference` alone — objectui#11070.** Later in this same release objectui#11070 (round 4) retired `reference_to` from `LookupCellRenderer` and `UserCellRenderer`, so the sets of keys those cells read, as listed above, no longer include it. The retirement of the three keys this change removed is unaffected. `.changeset/11070-reference-to-round4.md` states what ships; the text above is kept as the reading of this change. + +⚠️ **Dated note, 2026-10-01 — the lookup cell does not read `display_field` either — objectui#11070.** The list above of what `LookupCellRenderer` reads names `display_field`. That was already untrue when this note was added: objectui#7155 retired the cell's `display_field` leg, and the cell reads the display pointer as `displayField`, then `reference_field`. The retirement of the three keys this change removed is unaffected. The text above is kept as the reading of this change. diff --git a/.changeset/lucky-donkeys-shave.md b/.changeset/lucky-donkeys-shave.md index 0eb92d73fc..fc511890df 100644 --- a/.changeset/lucky-donkeys-shave.md +++ b/.changeset/lucky-donkeys-shave.md @@ -92,3 +92,5 @@ and there is nothing left to file. The fallback the PM recorded on objectui#7642 card the moment #7641 stopped being its fix) is moot — #7641 landed. The designer's snake leg survives as a read of a STORED pre-strict document, which is the ground of its KEEP above, not as one side of a competing read order. + +⚠️ **Dated note, 2026-10-01 — three of the `display_field` reads above are retired — objectui#11070 round 6.** Later in this same release objectui#11070 retired the `display_field` leg at three of the sites this census graded KEEP: `ObjectChart` (`plugin-charts`), `deriveMasterDetail`'s `deriveColumns` / `hydrateColumns` (`plugin-form`) and `resolveActionParams` (`app-shell`). Each reads `displayField`, then `reference_field`. The condition this census set for a retirement now holds for this key: the ingestion pass (objectui#7650, option A) stamps a stored `display_field` onto `displayField`, so a served definition loses nothing. `deriveMasterDetail` now has the camel leg this census found missing. The `id_field`, `description_field` and `lookup_filters` verdicts are unaffected. `.changeset/11070-dashboard-formula-round6.md` states what ships, including the break for definitions that do not pass through the ingestion pass. The text above is kept as the reading of this change. diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index 8ac771a457..1750f02eac 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -1026,41 +1026,26 @@ A widget-based dashboard with configurable grid layout and auto-refresh. "type": "metric", "title": "Total Revenue", "layout": { "x": 0, "y": 0, "w": 1, "h": 2 }, - "options": { - "value": "$48,200", - "description": "Monthly revenue", - "trend": { "value": 12, "direction": "up" } - } + "dataset": "sales", + "values": ["revenue"] }, { - "id": "chart", + "id": "sales_trend", + "type": "area", "title": "Sales Trend", "layout": { "x": 1, "y": 0, "w": 2, "h": 4 }, - "component": { - "type": "chart", - "chartType": "area", - "xAxisKey": "day", - "data": [ - { "day": "Mon", "Sales": 120 }, - { "day": "Tue", "Sales": 180 }, - { "day": "Wed", "Sales": 150 }, - { "day": "Thu", "Sales": 210 }, - { "day": "Fri", "Sales": 190 } - ], - "series": [{ "name": "Sales" }] - } + "dataset": "sales", + "dimensions": ["day"], + "values": ["revenue"] }, { - "id": "tasks", - "type": "list", - "title": "Recent Tasks", + "id": "open_tasks", + "type": "table", + "title": "Open Tasks by Owner", "layout": { "x": 3, "y": 0, "w": 1, "h": 4 }, - "options": { - "data": [ - { "task": "Renew the Acme contract", "due": "Mon" }, - { "task": "Send the Q3 forecast", "due": "Wed" } - ] - } + "dataset": "tasks", + "dimensions": ["owner"], + "values": ["open_count"] } ] } @@ -1070,12 +1055,12 @@ A widget-based dashboard with configurable grid layout and auto-refresh. |----------|------|-------------| | `columns` | `number` | Number of grid columns. | | `gap` | `number` | Gap between widgets (Tailwind spacing scale). | -| `widgets` | `(DashboardWidgetSlotComponentSchema \| DashboardWidgetSchema)[]` | **Required.** Each entry is a widget or a component node. A widget (`DashboardWidgetSchema`) names itself with `id`, `title` and `description`, sizes itself with `layout: { x, y, w, h }`, and holds its content either as a family named in `type` with that family's settings under `options`, or as a registered component node in `component`; its full key set is the spec's `DashboardWidget` plus objectui's own. A component node (`DashboardWidgetSlotComponentSchema`) sits in the slot directly: its `type` is a member of the closed `DASHBOARD_COMPONENT_WIDGET_TYPES` set, such as `metric-card`, and its other keys are that component's own props. | +| `widgets` | `(DashboardWidgetSlotComponentSchema \| DashboardWidgetSchema)[]` | **Required.** Each entry is a widget or a component node. A widget (`DashboardWidgetSchema`) names itself with `id`, `title` and `description`, sizes itself with `layout: { x, y, w, h }`, and holds its content either as a family named in `type` bound to a `dataset` (with that family's settings under `options`), or as a registered component node in `component`; its full key set is the spec's `DashboardWidget` plus objectui's own. A component node (`DashboardWidgetSlotComponentSchema`) sits in the slot directly: its `type` is a member of the closed `DASHBOARD_COMPONENT_WIDGET_TYPES` set, such as `metric-card`, and its other keys are that component's own props. | | `refreshIntervalSeconds` | `number` | Auto-refresh interval in **seconds** — the renderer multiplies by 1000. Renamed from `refreshInterval`, which this table documented as milliseconds and which it never was (objectui#7783). | -A widget's size is its `layout`: `w` and `h` are the grid columns and rows it spans, and `x` and `y` are its position on the editable `dashboard-grid`. `layout` takes all four numbers or is left out. `colSpan`, `rowSpan` and `body` are **not** widget keys: `DashboardWidgetSchema` is strict (objectui#6002) and refuses all three by name. The size is `layout.w` / `layout.h`, and the content is `type` + `options` or `component`. +A widget's size is its `layout`: `w` and `h` are the grid columns and rows it spans, and `x` and `y` are its position on the editable `dashboard-grid`. `layout` takes all four numbers or is left out. `colSpan`, `rowSpan` and `body` are **not** widget keys: `DashboardWidgetSchema` is strict (objectui#6002) and refuses all three by name. The size is `layout.w` / `layout.h`, and the content is `type` + `dataset` (with `options`) or `component`. -The family in `type` decides what `options` holds: `metric` shows `options.value`; `list` and `table` show the rows in `options.data`; a chart family (`area`, `bar`, `line`, `pie`, …) plots the rows in `options.data`, with `options.xField` naming the category key and `options.yField` the value key. Instead of a family, a widget can hold a registered component node in `component`, as the `chart` widget above does — that node's keys are the component's own props (here [`ChartSchema`](#chartschema)'s), not widget keys. The caption under a `metric` widget's number is `options.description`; the widget's own `description` is the subtitle under its `title` in the card header, which an inline `metric` does not draw. +A widget's data is a `dataset` (ADR-0021): `values` names the measures it shows and `dimensions` the dimensions it groups them by, both selected from the dataset by name. The family in `type` decides how the result is drawn: `metric` shows its one measure as a number; `table` lists a row per dimension value; a chart family (`area`, `bar`, `line`, `pie`, …) plots one series per measure over the dimension. A widget never carries rows. `options.data`, `options.xField` / `options.yField`, a metric's `options.value` / `options.description` / `options.trend`, and a `component` chart's `chartType` / `xAxisKey` / `series` are not widget keys, and `StrictAnyComponentSchema` refuses each of them by name (objectui#11228). The widget's own `description` is the subtitle under its `title` in the card header. **Related:** [GridSchema](#gridschema), [ChartSchema](#chartschema), [CardSchema](#cardschema) diff --git a/content/docs/fields/formula.mdx b/content/docs/fields/formula.mdx index 9a53cdd6a4..8d56f20551 100644 --- a/content/docs/fields/formula.mdx +++ b/content/docs/fields/formula.mdx @@ -21,9 +21,13 @@ The Formula Field component displays computed values calculated from other field A formula field is authored as `FormulaFieldMetadata` (`@object-ui/types`), which is the source of truth for the key set: it extends `BaseFieldMetadata` with the -expression and its declared return type. `returnType` is -`@objectstack/spec`'s own `FieldSchema.returnType`, typed by reference: a closed -union of `'number' | 'text' | 'boolean' | 'date'`. +expression and its declared return type. Both are `@objectstack/spec`'s own +`FieldSchema` members, typed by reference: + +- `expression` — the formula, written in CEL against the record + (`record.quantity * record.unit_price`), or the spec's expression envelope + (`{ "dialect": "cel", "source": "…" }`); +- `returnType` — a closed union of `'number' | 'text' | 'boolean' | 'date'`. ```ts import type { FormulaFieldMetadata } from '@object-ui/types'; @@ -33,11 +37,14 @@ const totalPrice: FormulaFieldMetadata = { name: 'total_price', label: 'Total Price', readonly: true, - formula: 'quantity * unit_price', + expression: 'record.quantity * record.unit_price', returnType: 'number', }; ``` +There is no `formula` key. `FieldSchema` refuses it by name and points at +`expression`, and `FormulaFieldMetadata` does not declare it. + The computed value, and the `className` a host supplies, are **not** metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). @@ -52,24 +59,25 @@ The formula field formats values by `returnType`: ## Formula Examples -Common formula patterns: +Common formula patterns, each the value of `expression` (CEL, with the record's +fields under `record.`): ```plaintext // Arithmetic -formula: 'price * quantity' -formula: '(subtotal - discount) * tax_rate' +expression: 'record.price * record.quantity' +expression: '(record.subtotal - record.discount) * record.tax_rate' // Text concatenation -formula: 'first_name + " " + last_name' -formula: 'city + ", " + state + " " + zip' +expression: 'record.first_name + " " + record.last_name' +expression: 'record.city + ", " + record.state + " " + record.zip' // Conditional -formula: 'IF(age >= 18, "Adult", "Minor")' -formula: 'IF(status == "closed", completed_at, null)' +expression: 'record.age >= 18 ? "Adult" : "Minor"' +expression: 'record.status == "closed" ? record.completed_at : null' // Date calculations -formula: 'created_at + 7 days' -formula: 'end_date - start_date' +expression: 'addDays(record.created_at, 7)' +expression: 'daysBetween(record.start_date, record.end_date)' ``` ## Cell Renderer @@ -84,19 +92,10 @@ import { FormulaCellRenderer } from '@object-ui/fields'; ## Backend Implementation -Formula fields are computed on the backend: - -```plaintext -// Example backend calculation -const calculateFormula = (formula: string, record: any) => { - // Parse and evaluate formula - if (formula === 'quantity * price') { - return record.quantity * record.price; - } - // Use expression parser for complex formulas - return evaluateExpression(formula, record); -}; -``` +Formula fields are computed on the backend: the platform evaluates the field's +`expression` against the record and returns the result as the field's value. +The widget never evaluates anything; it formats the value it is given by +`returnType`. ## Use Cases diff --git a/content/docs/guide/dashboard-filters.md b/content/docs/guide/dashboard-filters.md index c4517de847..03d995419c 100644 --- a/content/docs/guide/dashboard-filters.md +++ b/content/docs/guide/dashboard-filters.md @@ -11,20 +11,20 @@ that drives **several charts at once**. ObjectUI models this as a - Each widget declares which of **its own** fields a filter binds to via `filterBindings` — a small mapping, not a copied query. - At render time the dashboard **broadcasts** the active values into every - bound widget's inline query, `AND`-combined with the widget's own `filter`. + bound widget's own query, `AND`-combined with the widget's own `filter`. Charts stay inline and self-contained; one place owns the filter; each chart edit stays local. > **Working examples**: the schema catalog ships a > `plugin-dashboard/filtered-dashboard` example plus variants for dynamic -> options, text/number/lookup filter types, dataset widgets, the -> `targetWidgets` allow-list, and date presets with a custom range. They are -> **presentation** examples — the filter declarations are what they teach, so -> their widgets carry inline demo data (the dataset variant additionally binds -> two widgets to a `dataset`), which is what lets the docs gallery draw them -> with no application behind it. Inline static data is never filtered; see -> Known limitations at the end of this page. +> options, text/number/lookup filter types, widgets over two datasets, the +> `targetWidgets` allow-list, and date presets with a custom range. Every +> widget in them binds a `dataset`, exactly as the tutorial below does. They +> are **presentation** examples — the filter declarations are what they teach. +> The docs gallery draws them with no application behind it by answering each +> dataset query with placeholder rows that no filter reaches, so the numbers +> on the page are not real. ## Tutorial: from zero to a filtered dashboard @@ -60,14 +60,20 @@ show everything: #### Where a widget's data comes from -Filters scope a widget's **query**, so which data surface a widget uses decides -whether it can respond at all: - -| Surface | Shape | Filtered? | -| --- | --- | --- | -| Semantic-layer dataset (ADR-0021) | `"dataset": "invoices"` + `dimensions` + `values` | yes — merged into the dataset query as `runtimeFilter` | -| Inline object query | `"options": { "data": { "provider": "object", "object": "invoices", "aggregate": { "function": "count", "groupBy": "status" } } }` | yes — `AND`-merged into that query | -| Inline static data | `"options": { "data": [ … ], "xField": "status", "yField": "count" }` | no — there is no query to scope | +A widget binds a semantic-layer **dataset** (ADR-0021) — `"dataset": "invoices"` +— and selects its `dimensions` and `values` from it by name. It never carries +rows. Filters scope that dataset query: the dashboard merges the scoped filter +into it as `runtimeFilter`. + +> **Not an authoring surface: inline widget data.** `options.data` (whether an +> array of rows or a `{ "provider": "object", … }` query), `options.xField` / +> `options.yField`, a metric's `options.value` / `options.description` / +> `options.trend`, and a `component` chart's `chartType` / `xAxisKey` / +> `series` are not dashboard widget keys. `@object-ui/types`' strict authoring +> face (`StrictAnyComponentSchema`) refuses each of them by name, and +> `@objectstack/spec` requires `dataset` on every widget. The renderer still +> draws a stored widget that carries them, but that path is renderer-internal +> (ADR-0021), not something to author against: bind the widget to a dataset. > **Retired: the top-level inline analytics shape.** `object` + > `categoryField` / `valueField` / `aggregate` on the widget itself (and the @@ -75,10 +81,8 @@ whether it can respond at all: > longer reads those keys, and a stored widget still carrying them renders a > visible *"This widget uses a retired data format. Edit it to bind a dataset."* > prompt instead of a chart. Rebind such a widget to a `dataset` (select its -> `dimensions` and `values` by name), or — for a renderer-internal query with -> no semantic layer behind it — move the query under -> `options.data` with `"provider": "object"`. `@objectstack/spec` refuses the -> retired shape at publish, so this is not a soft deprecation. +> `dimensions` and `values` by name). `@objectstack/spec` refuses the retired +> shape at publish, so this is not a soft deprecation. ### Step 2 — add the built-in date range @@ -309,15 +313,13 @@ is `{ "preset": "last_30_days" }`, a custom range is ## Dataset widgets -Widgets bound to a semantic-layer `dataset` participate the same way: the -dashboard merges the scoped filter into the widget's `filter`, which the -dataset widget forwards to the dataset query as `runtimeFilter`. Dataset-bound -and inline widgets mix freely on one filtered dashboard — the -`plugin-dashboard/filtered-dashboard-dataset-widgets` catalog entry is exactly -that, two dataset-bound widgets beside an inline one. What differs is only what -each surface can answer: an inline **object query** -(`options.data` with `"provider": "object"`) is scoped like a dataset widget, -while an inline **static array** carries no query and is left untouched. +Every widget is bound to a semantic-layer `dataset`, and the dashboard scopes +each one the same way: it merges the scoped filter into the widget's `filter`, +which the dataset widget forwards to the dataset query as `runtimeFilter`. +Widgets over different datasets mix freely on one filtered dashboard — the +`plugin-dashboard/filtered-dashboard-dataset-widgets` catalog entry binds two +widgets to a `sales` dataset beside one bound to `invoices`, and each is scoped +through its own query. ## Nested variable scopes @@ -331,10 +333,6 @@ stay in sync. ## Known limitations -- **Static-data widgets are not filtered** — a widget whose `options.data` is - an inline array has no query to scope, so dashboard filters do not apply to - it. Bind the widget to a `dataset` (or give it an `options.data` object - query) if it should respond to filters. - **A binding is applied as written** — the dashboard does not know a dataset's fields, so it cannot check a binding target for you. A default binding whose field the widget's data does not have produces an empty diff --git a/content/docs/plugins/plugin-dashboard.mdx b/content/docs/plugins/plugin-dashboard.mdx index ba0c794f18..68c5bf0d15 100644 --- a/content/docs/plugins/plugin-dashboard.mdx +++ b/content/docs/plugins/plugin-dashboard.mdx @@ -293,11 +293,11 @@ the widget's own `filter`). The widgets above bind a **dataset** (ADR-0021). The pre-ADR-0021 top-level `object` + `categoryField` / `valueField` / `aggregate` shape was removed: the renderer no longer reads those keys and shows a *"This widget uses a retired -data format. Edit it to bind a dataset."* prompt instead of a chart. A widget -that needs a renderer-internal query rather than a semantic-layer one puts it -under `options.data` as `{ "provider": "object", "object": "invoices", -"aggregate": { "function": "count", "groupBy": "status" } }`; an -`options.data` **array** is fixed demo data and is not filtered. +data format. Edit it to bind a dataset."* prompt instead of a chart. Inline +widget data is not an authoring surface either: `options.data` (an array of +rows or a `{ "provider": "object", … }` query) and `options.xField` / +`options.yField` are refused by name by `@object-ui/types`' strict authoring +face (objectui#11228), and every widget binds a dataset. Binding rules, in precedence order: @@ -310,9 +310,8 @@ Binding rules, in precedence order: Date presets stay symbolic (date-macro tokens such as `{30_days_ago}`) until query time. Dataset-bound widgets receive the merged filter through the -dataset query's `runtimeFilter`. Static-data widgets (inline `data` arrays) -have no query to scope and are not filtered. Filter values are also readable -in widget expressions as `page.`. +dataset query's `runtimeFilter`. Filter values are also readable in widget +expressions as `page.`. For a step-by-step tutorial — filter types, `optionsFrom` dynamic options, `page.*` expression usage, and known limitations with workarounds — see the diff --git a/examples/schema-catalog/src/catalog-meta.json b/examples/schema-catalog/src/catalog-meta.json index a4e20b6b03..761ad1dd12 100644 --- a/examples/schema-catalog/src/catalog-meta.json +++ b/examples/schema-catalog/src/catalog-meta.json @@ -155,8 +155,8 @@ "description": "Dashboard-level date + region filters driving multiple charts over different objects" }, "plugin-dashboard/filtered-dashboard-dataset-widgets": { - "title": "Filtered Dashboard — Dataset + Inline Widgets", - "description": "Dashboard filters scoping dataset-bound widgets (via the dataset query's runtimeFilter) alongside an inline widget" + "title": "Filtered Dashboard — Widgets Over Two Datasets", + "description": "Dashboard filters scoping widgets bound to two different datasets, each through its own dataset query's runtimeFilter" }, "plugin-dashboard/filtered-dashboard-date-presets": { "title": "Filtered Dashboard — Date Presets + Custom Range", diff --git a/examples/schema-catalog/src/index.ts b/examples/schema-catalog/src/index.ts index 6c558ed165..d8a195fbd4 100644 --- a/examples/schema-catalog/src/index.ts +++ b/examples/schema-catalog/src/index.ts @@ -4053,8 +4053,8 @@ const REGISTRY: Record = { 'plugin-dashboard/filtered-dashboard-dataset-widgets': { id: 'plugin-dashboard/filtered-dashboard-dataset-widgets', meta: { - title: "Filtered Dashboard — Dataset + Inline Widgets", - description: "Dashboard filters scoping dataset-bound widgets (via the dataset query's runtimeFilter) alongside an inline widget", + title: "Filtered Dashboard — Widgets Over Two Datasets", + description: "Dashboard filters scoping widgets bound to two different datasets, each through its own dataset query's runtimeFilter", category: 'plugin-dashboard', }, schema: plugin_dashboard_filtered_dashboard_dataset_widgets, diff --git a/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-dataset-widgets.json b/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-dataset-widgets.json index 23ff9555da..3a16e186f3 100644 --- a/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-dataset-widgets.json +++ b/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-dataset-widgets.json @@ -40,16 +40,9 @@ "id": "invoices_by_status", "title": "Invoices by Status", "type": "bar", - "options": { - "xField": "status", - "yField": "count", - "data": [ - { "status": "Draft", "count": 18 }, - { "status": "Sent", "count": 31 }, - { "status": "Paid", "count": 47 }, - { "status": "Void", "count": 4 } - ] - } + "dataset": "invoices", + "dimensions": ["status"], + "values": ["count"] } ] } diff --git a/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-date-presets.json b/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-date-presets.json index 57e2330a85..5dd63a3d51 100644 --- a/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-date-presets.json +++ b/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-date-presets.json @@ -12,35 +12,17 @@ "id": "invoices_by_month", "title": "Invoices by Month", "type": "line", - "options": { - "xField": "month", - "yField": "count", - "data": [ - { "month": "Jan", "count": 24 }, - { "month": "Feb", "count": 31 }, - { "month": "Mar", "count": 28 }, - { "month": "Apr", "count": 37 }, - { "month": "May", "count": 42 }, - { "month": "Jun", "count": 39 } - ] - } + "dataset": "invoices", + "dimensions": ["created_month"], + "values": ["count"] }, { "id": "accounts_signed_by_month", "title": "Accounts Signed by Month", "type": "area", - "options": { - "xField": "month", - "yField": "count", - "data": [ - { "month": "Jan", "count": 6 }, - { "month": "Feb", "count": 9 }, - { "month": "Mar", "count": 14 }, - { "month": "Apr", "count": 11 }, - { "month": "May", "count": 17 }, - { "month": "Jun", "count": 21 } - ] - }, + "dataset": "accounts", + "dimensions": ["signed_month"], + "values": ["count"], "filterBindings": { "dateRange": "signed_at" } @@ -49,9 +31,8 @@ "id": "all_time_total", "title": "All-time Invoices", "type": "metric", - "options": { - "value": "3,912" - }, + "dataset": "invoices", + "values": ["count"], "filterBindings": { "dateRange": false } diff --git a/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-dynamic-options.json b/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-dynamic-options.json index 04538ae394..7c0665c4f1 100644 --- a/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-dynamic-options.json +++ b/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-dynamic-options.json @@ -20,31 +20,17 @@ "id": "accounts_by_industry", "title": "Accounts by Industry", "type": "bar", - "options": { - "xField": "industry", - "yField": "count", - "data": [ - { "industry": "Software", "count": 34 }, - { "industry": "Manufacturing", "count": 22 }, - { "industry": "Retail", "count": 17 }, - { "industry": "Healthcare", "count": 12 } - ] - } + "dataset": "accounts", + "dimensions": ["industry"], + "values": ["count"] }, { "id": "invoices_by_status", "title": "Invoices by Status", "type": "donut", - "options": { - "xField": "status", - "yField": "count", - "data": [ - { "status": "Draft", "count": 18 }, - { "status": "Sent", "count": 31 }, - { "status": "Paid", "count": 47 }, - { "status": "Void", "count": 4 } - ] - }, + "dataset": "invoices", + "dimensions": ["status"], + "values": ["count"], "filterBindings": { "industry": "account_industry" } diff --git a/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-filter-types.json b/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-filter-types.json index 17a393d3b0..284a67586d 100644 --- a/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-filter-types.json +++ b/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-filter-types.json @@ -32,33 +32,17 @@ "id": "invoices_by_status", "title": "Invoices by Status", "type": "bar", - "options": { - "xField": "status", - "yField": "count", - "data": [ - { "status": "Draft", "count": 18 }, - { "status": "Sent", "count": 31 }, - { "status": "Paid", "count": 47 }, - { "status": "Void", "count": 4 } - ] - } + "dataset": "invoices", + "dimensions": ["status"], + "values": ["count"] }, { "id": "invoices_by_month", "title": "Invoices by Month", "type": "line", - "options": { - "xField": "month", - "yField": "count", - "data": [ - { "month": "Jan", "count": 24 }, - { "month": "Feb", "count": 31 }, - { "month": "Mar", "count": 28 }, - { "month": "Apr", "count": 37 }, - { "month": "May", "count": 42 }, - { "month": "Jun", "count": 39 } - ] - }, + "dataset": "invoices", + "dimensions": ["created_month"], + "values": ["count"], "filterBindings": { "owner": "assigned_to" } @@ -67,9 +51,8 @@ "id": "total_invoices", "title": "Total Invoices (unfiltered)", "type": "metric", - "options": { - "value": "1,284" - }, + "dataset": "invoices", + "values": ["count"], "filterBindings": { "customer": false, "amount": false, diff --git a/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-target-widgets.json b/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-target-widgets.json index 5869a767bb..654bb98a42 100644 --- a/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-target-widgets.json +++ b/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-target-widgets.json @@ -22,30 +22,17 @@ "id": "invoices_by_region", "title": "Invoices by Region (allow-listed)", "type": "bar", - "options": { - "xField": "region", - "yField": "count", - "data": [ - { "region": "EMEA", "count": 41 }, - { "region": "APAC", "count": 27 }, - { "region": "AMER", "count": 32 } - ] - } + "dataset": "invoices", + "dimensions": ["region"], + "values": ["count"] }, { "id": "invoices_recent", "title": "Recent Invoices (allow-listed, field override)", "type": "line", - "options": { - "xField": "week", - "yField": "count", - "data": [ - { "week": "W1", "count": 7 }, - { "week": "W2", "count": 11 }, - { "week": "W3", "count": 9 }, - { "week": "W4", "count": 14 } - ] - }, + "dataset": "invoices", + "dimensions": ["created_week"], + "values": ["count"], "filterBindings": { "status": "state" } @@ -54,9 +41,8 @@ "id": "total_invoices", "title": "Total Invoices (not allow-listed)", "type": "metric", - "options": { - "value": "1,284" - } + "dataset": "invoices", + "values": ["count"] } ] } diff --git a/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard.json b/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard.json index 37343a03f4..daa7b21e4b 100644 --- a/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard.json +++ b/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard.json @@ -25,33 +25,17 @@ "id": "invoices_by_status", "title": "Invoices by Status", "type": "bar", - "options": { - "xField": "status", - "yField": "count", - "data": [ - { "status": "Draft", "count": 18 }, - { "status": "Sent", "count": 31 }, - { "status": "Paid", "count": 47 }, - { "status": "Void", "count": 4 } - ] - } + "dataset": "invoices", + "dimensions": ["status"], + "values": ["count"] }, { "id": "accounts_signed", "title": "Accounts Signed", "type": "line", - "options": { - "xField": "month", - "yField": "count", - "data": [ - { "month": "Jan", "count": 6 }, - { "month": "Feb", "count": 9 }, - { "month": "Mar", "count": 14 }, - { "month": "Apr", "count": 11 }, - { "month": "May", "count": 17 }, - { "month": "Jun", "count": 21 } - ] - }, + "dataset": "accounts", + "dimensions": ["signed_month"], + "values": ["count"], "filterBindings": { "dateRange": "signed_at", "region": "sales_region" @@ -61,9 +45,8 @@ "id": "total_invoices", "title": "Total Invoices (all regions)", "type": "metric", - "options": { - "value": "1,284" - }, + "dataset": "invoices", + "values": ["count"], "filterBindings": { "region": false } diff --git a/examples/schema-catalog/test/plugin-dashboard-component-schema.test.ts b/examples/schema-catalog/test/plugin-dashboard-component-schema.test.ts index 3ec4ee5341..a0c00e4317 100644 --- a/examples/schema-catalog/test/plugin-dashboard-component-schema.test.ts +++ b/examples/schema-catalog/test/plugin-dashboard-component-schema.test.ts @@ -87,6 +87,7 @@ import { DashboardComponentSchema, DashboardWidgetSchema, DashboardWidgetTypeSchema, + StrictAnyComponentSchema, } from '@object-ui/types/zod'; import { DASHBOARD_COMPONENT_WIDGET_TYPES } from '@object-ui/types'; import { examplesByCategory } from '../src/index.js'; @@ -355,3 +356,24 @@ describe('counter-probe — the gate refuses deliberately malformed entries', () expect(auditEntry(mutantOf(() => {}), 'unmutated')).toEqual([]); }); }); + +/** + * objectui#11228 ruling C (objectui#11070 round 6): a dashboard widget binds a + * `dataset` and never carries rows. The strict authoring face keeps refusing + * the inline dialect by name (`widgets[].options.{data, xField, yField, value, + * description, trend}` and `widgets[].component.{chartType, xAxisKey, + * series}`, pinned key by key in `packages/types`' + * `strict-face-read-keys-11070.test.ts`), and this corpus is what AI authors + * copy, so it teaches none of it: every entry parses on the published strict + * face. The `filtered-dashboard*` entries carried the dialect until that round + * moved them to the dataset form. + */ +describe('schema-catalog plugin-dashboard — every entry parses on the strict authoring face (objectui#11228 ruling C)', () => { + it.each(entries.map((example) => [example.id, example.schema] as const))( + '%s parses on StrictAnyComponentSchema', + (_id, schema) => { + const result = StrictAnyComponentSchema.safeParse(schema); + expect(result.success ? [] : result.error.issues.map((i) => `[${i.path.join('.')}] ${i.code}`)).toEqual([]); + }, + ); +}); diff --git a/packages/app-shell/src/utils/expandableFamily.identity-5874.test.ts b/packages/app-shell/src/utils/expandableFamily.identity-5874.test.ts index 7560b6e58c..74c89f5ead 100644 --- a/packages/app-shell/src/utils/expandableFamily.identity-5874.test.ts +++ b/packages/app-shell/src/utils/expandableFamily.identity-5874.test.ts @@ -115,12 +115,16 @@ const PICKER_KEYS = [ * snake leg, so the old fixture row silently produced nothing and the * `PICKER_KEYS` loop below caught it. ⛔ Do not "fix" a future failure here by * restoring a dual read: the key is camelCase-only on both sides of the seam. + * + * ⭐ `displayField` is camel for the same reason (objectui#11070 round 6): the + * resolver's `display_field` leg is retired, so the snake row would produce + * nothing. */ const field = (type: string) => ({ type, label: type, reference: 'accounts', - display_field: 'name', + displayField: 'name', id_field: 'id', description_field: 'website', title_format: '{name}', diff --git a/packages/app-shell/src/utils/resolveActionParams.declaredLookupLegs-7435.test.ts b/packages/app-shell/src/utils/resolveActionParams.declaredLookupLegs-7435.test.ts index 57460d48c7..fac96a36b2 100644 --- a/packages/app-shell/src/utils/resolveActionParams.declaredLookupLegs-7435.test.ts +++ b/packages/app-shell/src/utils/resolveActionParams.declaredLookupLegs-7435.test.ts @@ -51,6 +51,7 @@ * NOT reach the param, while the snake read keeps working. */ import { describe, it, expect } from 'vitest'; +import { normalizeSchemaReferenceKeys } from '@object-ui/core'; import { resolveActionParams, type ResolveActionParamsContext, @@ -175,12 +176,24 @@ describe('resolveActionParams lookup group — the declared spellings reach the }); }); -describe('resolveActionParams lookup group — the recorded dialect still resolves (objectui#7435)', () => { - it('a `display_field`-only def still reaches the picker', () => { - expect(resolved('snake').displayField).toBe('legacy_full_name'); - expect(widgetField('snake').displayField).toBe('legacy_full_name'); +describe('resolveActionParams lookup group — the retired `display_field` (objectui#11070 round 6)', () => { + it('a `display_field`-only def no longer reaches the picker: the leg is retired, with no alias', () => { + expect(resolved('snake').displayField).toBeUndefined(); + expect(widgetField('snake').displayField).toBeUndefined(); }); + it('the same def folded at ingestion reaches it as `displayField`: a served def loses nothing', () => { + // The snake spelling is the INPUT here. `MetadataProvider` runs this fold on + // every object it stores (objectui#7650 ruling A), which is the served path. + const folded = normalizeSchemaReferenceKeys(structuredClone(OBJECTS[0])); + const params: RawActionParam[] = [{ field: 'snake' }]; + const [def] = resolveActionParams(params, { ...ctx(), objects: [folded] }); + expect(def.displayField).toBe('legacy_full_name'); + expect((paramToField(def) as unknown as Record).displayField).toBe('legacy_full_name'); + }); +}); + +describe('resolveActionParams lookup group — the recorded dialect still resolves (objectui#7435)', () => { it('a `description_field`-only def still reaches the picker', () => { expect(resolved('snake').descriptionField).toBe('legacy_email'); expect(widgetField('snake').descriptionField).toBe('legacy_email'); diff --git a/packages/app-shell/src/utils/resolveActionParams.test.ts b/packages/app-shell/src/utils/resolveActionParams.test.ts index 3292db57dc..ef3d35194d 100644 --- a/packages/app-shell/src/utils/resolveActionParams.test.ts +++ b/packages/app-shell/src/utils/resolveActionParams.test.ts @@ -156,7 +156,7 @@ describe('resolveActionParams — inline lookup reference target (#3405)', () => name: 'quality_dispatch', fields: { inspector: { type: 'lookup', label: '质检人', reference: 'sys_user' }, - reviewer: { type: 'lookup', label: 'Reviewer', reference: 'sys_user', display_field: 'name' }, + reviewer: { type: 'lookup', label: 'Reviewer', reference: 'sys_user', displayField: 'name' }, }, }, ], @@ -232,7 +232,7 @@ describe('resolveActionParams — authored through the public ActionParam type ( type: 'lookup', label: 'Inspector', reference: 'sys_user', - display_field: 'name', + displayField: 'name', id_field: 'id', }, }, diff --git a/packages/app-shell/src/utils/resolveActionParams.ts b/packages/app-shell/src/utils/resolveActionParams.ts index 17aa514a05..832df9b3ad 100644 --- a/packages/app-shell/src/utils/resolveActionParams.ts +++ b/packages/app-shell/src/utils/resolveActionParams.ts @@ -346,9 +346,15 @@ interface RuntimeField { // ⭐ objectui#11070 round 4 removed the `reference_to` member: nothing here // read it (the target is read as `reference` below), and ObjectUI no longer // writes or reads that spelling anywhere. + // + // ⭐ objectui#11070 round 6 removed the `display_field` member the same way: + // `displayField` is now the only display spelling read below. A served def + // loses nothing, because the ingestion fold (objectui#7650 ruling A) stamps a + // stored `display_field` onto `displayField` on every object + // `MetadataProvider` stores; only a host that hands this resolver its own, + // unfolded `objects` loses the snake value. reference?: string; displayField?: string; - display_field?: string; reference_field?: string; id_field?: string; descriptionField?: string; @@ -638,6 +644,8 @@ export function resolveActionParam( // document stored before the key was tightened (the serve path runs no // parse — objectui#7650) and a host adapter outside this repo. Dropping // a leg is a retirement with its own evidence, not a side effect here. + // (`display_field` has since had that retirement — objectui#11070 + // round 6, below.) // // ⭐ objectui#7435 second slice — `lookupColumns` and `lookupPageSize` // get the same treatment, on the same re-taken measurement. Both are @@ -665,17 +673,27 @@ export function resolveActionParam( // regression, and this comment is the reason they may not be split // again. // - // ⛔ The snake read is GONE rather than demoted, which is the one place - // this key departs from its five siblings above. Their snake legs are - // kept because a pre-tightening document or an out-of-repo host adapter - // could still emit them. `depends_on` has no such producer to protect: - // `FieldSchema` refuses it BY NAME (suggesting `dependsOn`), so no - // document that parses can carry it, and objectui#7357 already retired - // the renderer-side twin in `LookupField`. Keeping it would leave this - // resolver the last reader of a spelling the protocol rejects. + // ⛔ The snake read is GONE rather than demoted, which is where this + // key departs from the siblings above that keep one. Their snake legs + // are kept because a pre-tightening document or an out-of-repo host + // adapter could still emit them. `depends_on` has no such producer to + // protect: `FieldSchema` refuses it BY NAME (suggesting `dependsOn`), so + // no document that parses can carry it, and objectui#7357 already + // retired the renderer-side twin in `LookupField`. Keeping it would + // leave this resolver the last reader of a spelling the protocol rejects. + // + // ⭐ objectui#11070 round 6 — `displayField` reads its declared spelling + // alone too: the `display_field` leg is retired, with no alias. The + // pre-tightening document that leg was kept for is now covered by the + // ingestion fold (objectui#7650 ruling A stamps a stored + // `display_field` onto `displayField` on every object `MetadataProvider` + // stores), so a served def loses nothing. A host that hands this + // resolver its own, unfolded `objects` does lose the snake value; the + // round's changeset states that break. `reference_field` keeps its + // place behind it: `FieldSchema` declares no twin the fold could stamp + // it onto, so the fold leaves it on the def as served. referenceTo: param.reference ?? field.reference, - displayField: - field.displayField ?? field.display_field ?? field.reference_field, + displayField: field.displayField ?? field.reference_field, idField: field.id_field, descriptionField: field.descriptionField ?? field.description_field, titleFormat: field.title_format, diff --git a/packages/core/src/utils/reference-keys.ts b/packages/core/src/utils/reference-keys.ts index a78124a7f8..de7f39a3f5 100644 --- a/packages/core/src/utils/reference-keys.ts +++ b/packages/core/src/utils/reference-keys.ts @@ -294,7 +294,7 @@ type UnfoldableReason = 'no-declared-twin' | 'ambiguous-probe' | 'canonical-occu * `resolveGroupByLabels` in `@object-ui/plugin-charts` reads * `fieldDef.id_field || 'id'`. Those two are named, not counted: other kept * snake reads exist (`deriveColumns` in `@object-ui/plugin-form` reads - * `display_field`, for one), and nothing re-derives a complete list. So the + * `reference_field`, for one), and nothing re-derives a complete list. So the * line claims only what holds for every refusal: a consumer that reads only * declared spellings will not see the value. Retiring any of those reads is a * separate decision, not taken here. diff --git a/packages/plugin-charts/src/ObjectChart.declaredDisplayFieldLeg-7435.test.tsx b/packages/plugin-charts/src/ObjectChart.declaredDisplayFieldLeg-7435.test.tsx index da07c7b9d8..ff82a9560f 100644 --- a/packages/plugin-charts/src/ObjectChart.declaredDisplayFieldLeg-7435.test.tsx +++ b/packages/plugin-charts/src/ObjectChart.declaredDisplayFieldLeg-7435.test.tsx @@ -33,7 +33,8 @@ * leg. * * `title` → only a `displayField` read finds it - * `legacy_title` → only a `reference_field` / `display_field` read finds it + * `legacy_title` → only a `reference_field` read, or a `display_field` the + * ingestion fold stamped onto `displayField`, finds it * `name` → only the generic heuristic at the end of the chain * * ## Measured, on the pin this tree resolves @@ -45,20 +46,28 @@ * controls lit in the same run: the minimal def ACCEPTED, `zzz_not_a_real_key` * REJECTED. * - * ## Why the snake legs are still asserted rather than removed + * ## Why the snake legs were kept, and why `display_field` no longer is * * A per-site producer sweep found no in-repo producer of either snake spelling * (every occurrence in this repo is a test fixture) and zero key-position - * occurrences in the producer repo, control lit. They are nevertheless KEPT, - * because two producers that can still emit them lie outside what that sweep - * measures: a document stored before the key was tightened (the serve path runs - * no parse — objectui#7650) and a host `DataSource` whose `getObjectSchema` is - * not `ObjectStackAdapter`'s and so never passes through - * `normalizeSchemaReferenceKeys`. Dropping a leg would be a silent regression - * for existing authored data — strictly worse than the wrong-label bug this - * fixes — so the fallback gets its own cases here. + * occurrences in the producer repo, control lit. objectui#7435 nevertheless + * KEPT both, because two producers that can still emit them lie outside what + * that sweep measures: a document stored before the key was tightened (the + * serve path runs no parse — objectui#7650) and a host `DataSource` whose + * `getObjectSchema` is not `ObjectStackAdapter`'s and so never passes through + * `normalizeSchemaReferenceKeys`. + * + * ⭐ objectui#11070 round 6 retired the `display_field` leg. The stored + * document is now covered by the ingestion fold: objectui#7650 ruling A has + * `normalizeSchemaReferenceKeys` stamp a stored `display_field` onto + * `displayField`, so a served def reaches this chain under the declared key + * (the folded case below keeps the snake spelling as its INPUT). The host + * `DataSource` is the break the round's changeset states. `reference_field` + * keeps its fallback case: `FieldSchema` declares no twin the fold could stamp + * it onto. */ import { describe, it, expect, vi } from 'vitest'; +import { normalizeSchemaReferenceKeys } from '@object-ui/core'; import { resolveGroupByLabels } from './ObjectChart'; /** @@ -120,8 +129,20 @@ describe('ObjectChart display-field chain — the declared leg is read, and rank ).toBe('Apollo'); }); - it('a `display_field`-only def still resolves — the fallback is intact', async () => { - expect(await axisLabel({ display_field: 'legacy_title' })).toBe('Apollo (legacy dialect)'); + it('a `display_field`-only def no longer resolves it — the leg is retired, with no alias (objectui#11070 round 6)', async () => { + expect(await axisLabel({ display_field: 'legacy_title' })).toBe('Apollo (generic heuristic)'); + }); + + it('the same def folded at ingestion resolves it as `displayField` — a served def loses nothing (objectui#11070 round 6)', async () => { + // The snake spelling is the INPUT. `ObjectStackAdapter.getObjectSchema` + // runs this fold on every def it serves (objectui#7650 ruling A). + const folded = normalizeSchemaReferenceKeys({ + name: 'crm_opportunity', + fields: { stage: { type: 'lookup', reference: 'projects', display_field: 'legacy_title' } }, + }); + const ds = { find: vi.fn(async () => ({ data: PROJECTS, total: PROJECTS.length })) }; + const out = await resolveGroupByLabels(ROWS, 'stage', folded, ds); + expect(String(out[0].stage)).toBe('Apollo (legacy dialect)'); }); it('a `reference_field`-only def still resolves — the fallback is intact', async () => { diff --git a/packages/plugin-charts/src/ObjectChart.tsx b/packages/plugin-charts/src/ObjectChart.tsx index 4610507d4d..869c0de5fa 100644 --- a/packages/plugin-charts/src/ObjectChart.tsx +++ b/packages/plugin-charts/src/ObjectChart.tsx @@ -350,44 +350,38 @@ export async function resolveGroupByLabels( // Build id→label map using display field from metadata with sensible fallbacks. // - // ⭐ objectui#7435 — the DECLARED spelling is ranked FIRST. Until this + // ⭐ objectui#7435 — the DECLARED spelling is ranked FIRST. Until that // change the chain had no `FieldSchema` leg at all, so `displayField` — // the only display spelling a spec-compliant author can emit, and the one // `getObjectSchema` serves — could not reach this reader in any shape. The // chart fell through to the generic `'name'` heuristic and drew the wrong - // axis label. This is the shape objectui#7155 established (declared leg - // first, recorded dialect behind it), not a new lenient alias: the two - // snake legs below are PRE-EXISTING reads, kept in their pre-existing - // relative order, and this change only puts the contract ahead of them. + // axis label. // - // MEASURED on the pin resolved here, `@objectstack/spec@17.4.0`: + // MEASURED on the pin resolved then, `@objectstack/spec@17.4.0`: // `FieldSchema.safeParse` ACCEPTS `displayField` and REFUSES // `reference_field` / `display_field` with `unrecognized_keys` (controls // lit in the same run — a minimal lookup def ACCEPTED, `zzz_not_a_real_key` // REJECTED). // - // ⚠️ Why the two snake legs STAY. A producer sweep for this site found no - // in-repo producer of either spelling (every occurrence in this repo is a - // test fixture) and zero key-position occurrences in the producer repo - // (control: `displayField`, 23 files). They are kept anyway, because - // neither measurement covers the two producers that can still emit them: - // a document stored before the key was tightened (the serve path runs no - // parse — objectui#7650), and a HOST `DataSource` whose `getObjectSchema` - // is not `ObjectStackAdapter`'s and therefore never passes through - // `normalizeSchemaReferenceKeys`. Dropping a leg here would be a silent - // regression for existing authored data; that is a retirement decision - // with its own evidence, not a side effect of adding the declared leg. + // ⭐ objectui#11070 round 6 — the `display_field` leg is RETIRED, with no + // alias. objectui#7435 kept it for two producers its sweep did not cover. + // The first, a document stored before the key was tightened, is now + // covered by the ingestion fold: `ObjectStackAdapter.getObjectSchema` + // runs `normalizeSchemaReferenceKeys`, which stamps a stored + // `display_field` onto `displayField` (objectui#7650 ruling A), so a + // served def reaches this read under the declared key. The second, a + // HOST `DataSource` whose `getObjectSchema` is not `ObjectStackAdapter`'s, + // is not folded, and its `display_field` now falls through to `'name'`; + // the round's changeset states that break. // - // ⚠️ `reference_field` in particular is graded `no-producer` by this - // repo's own register (`plugin-grid/src/relationalMetaKeys.ts`), and the - // verdict was re-derived for this change and HOLDS. It keeps its place - // relative to `display_field` on purpose — reordering two legs nothing - // produces would be an unmeasured behaviour change on top of a measured - // one. What this change does fix is that it is no longer read FIRST. + // ⚠️ `reference_field` STAYS. `FieldSchema` declares no twin the fold + // could stamp it onto, so the fold leaves it on the def as served, and + // retiring it here is a decision with its own evidence, not this one. + // It is graded `no-producer` by this repo's own register + // (`plugin-grid/src/relationalMetaKeys.ts`). const displayField: string = fieldDef.displayField || fieldDef.reference_field - || fieldDef.display_field || 'name'; const idToName: Record = {}; for (const rec of records) { diff --git a/packages/plugin-dashboard/README.md b/packages/plugin-dashboard/README.md index 244a7d0cab..9a265e423a 100644 --- a/packages/plugin-dashboard/README.md +++ b/packages/plugin-dashboard/README.md @@ -257,33 +257,34 @@ const schema = { value: '$123,456' }, { + id: 'sales_trend', type: 'line', title: 'Sales Trend', - options: { - data: [/* [{ name: 'Jan', value: 1200 }, …] */], - xField: 'name', - yField: 'value' - } + dataset: 'sales', + dimensions: ['month'], + values: ['revenue'] }, { + id: 'revenue_by_category', type: 'pie', title: 'Category Distribution', - options: { - data: [/* [{ name: 'Hardware', value: 40 }, …] */], - xField: 'name', - yField: 'value' - } + dataset: 'sales', + dimensions: ['category'], + values: ['revenue'] } ] }; ``` A chart widget names its family in `type` — one of the spec's chart families, -the closed vocabulary `DashboardWidgetTypeName` declares — and carries its -inline rows under `options.data`, with `options.xField` / `options.yField` -naming the category and value keys. There is no `card` widget family and no -nested `body` slot: a widget whose `type` is outside that vocabulary is refused -at validation, by `@object-ui/types/zod`'s `DashboardComponentSchema`. +the closed vocabulary `DashboardWidgetTypeName` declares — and binds a +`dataset` (ADR-0021), selecting the dimension it plots in `dimensions` and its +measures in `values`. It never carries rows: `options.data`, +`options.xField` and `options.yField` are not widget keys, and +`@object-ui/types/zod`'s `StrictAnyComponentSchema` refuses them by name +(objectui#11228). There is no `card` widget family and no nested `body` slot: a +widget whose `type` is outside that vocabulary is refused at validation, by +`@object-ui/types/zod`'s `DashboardComponentSchema`. ### Responsive Dashboard @@ -406,9 +407,10 @@ into each bound widget's inline query (`AND`-combined with the widget's own // dimensions/measures by name. The pre-ADR-0021 top-level `object` + // `categoryField`/`valueField`/`aggregate` shape was REMOVED — a widget // still carrying it renders "This widget uses a retired data format. - // Edit it to bind a dataset." instead of a chart. A renderer-internal - // query lives under `options.data` as `{ provider: 'object', object, - // aggregate }`; an `options.data` array is fixed demo data. + // Edit it to bind a dataset." instead of a chart. Inline widget data + // (`options.data`, `options.xField` / `options.yField`) is not an + // authoring surface either: the strict authoring face refuses those keys + // by name (objectui#11228). // // Default binding: the filter's own `field` (dateRange → created_at). { "id": "w1", "type": "bar", "dataset": "invoices", "dimensions": ["status"], "values": ["count"] }, @@ -441,8 +443,6 @@ Notes: query time, so widgets resolve them exactly like hand-authored filters. - Dataset-bound widgets receive the merged filter through the dataset query's `runtimeFilter`. -- Static-data widgets (inline `data` arrays) have no query to scope and are - not filtered. - Filter values are also readable in widget expressions as `page.` (e.g. `page.region`), since they are hosted as dashboard variables. - `optionsFrom` resolves distinct option values server-side (a dataset diff --git a/packages/plugin-dashboard/src/__tests__/DashboardGridLayout.legacyRetired.test.tsx b/packages/plugin-dashboard/src/__tests__/DashboardGridLayout.legacyRetired.test.tsx index 7b7dad01ce..2cd98a5576 100644 --- a/packages/plugin-dashboard/src/__tests__/DashboardGridLayout.legacyRetired.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/DashboardGridLayout.legacyRetired.test.tsx @@ -69,8 +69,11 @@ describe('DashboardGridLayout retired legacy widgets (#4612)', () => { // stood at `8640cec19`, the commit that added this file. It was byte-for-byte // then — same keys, same order, same values — and it is NOT byte-for-byte // now: `e028dfcd8` (objectui#4600, PR #4615) migrated the `filtered-*` - // entries off the retired shape 66 minutes later, so `widgets[0]` on disk is - // `{ id, title, type, options: { xField, yField, data } }` today. + // entries off the retired shape 66 minutes later, onto inline + // `options: { xField, yField, data }`, and objectui#11070 round 6 + // (objectui#11228 ruling C) then moved them to the dataset form, so + // `widgets[0]` on disk is `{ id, title, type, dataset, dimensions, values }` + // today. // // ⚠️ This row therefore does NOT carry the independent-corpus property the // original annotation claimed for it. It is a HISTORICAL specimen of stored @@ -86,9 +89,9 @@ describe('DashboardGridLayout retired legacy widgets (#4612)', () => { // than the row above ever made: its retired binding verbatim (`type`, // `object`, `aggregate`), with `id` genericised to `w1` and `title` / // `filterBindings` dropped. So this row was never byte-for-byte and never - // said it was. `e028dfcd8` retired that widget's shape too — `widgets[2]` is - // `{ id, title, type, options: { value }, filterBindings }` today — so the - // same ⚠️ above applies to it. + // said it was. `e028dfcd8` retired that widget's shape too, and round 6 + // moved it on again — `widgets[2]` is `{ id, title, type, dataset, values, + // filterBindings }` today — so the same ⚠️ above applies to it. ['metric', { id: 'w1', type: 'metric', object: 'invoices', aggregate: 'count' }], ])('renders the visible placeholder for a legacy %s widget', (_kind, widget) => { render(); @@ -169,9 +172,11 @@ describe('DashboardGridLayout retired legacy widgets (#4612)', () => { * * What this block deliberately does NOT do is restore the independent-corpus * property the annotations above lost, because that property is no longer - * obtainable. Measured across every JSON document in the repo: 28 dashboard - * widgets, ZERO carrying the retired top-level binding, against live controls - * of 15 `options`-shaped and 2 `dataset`-shaped widgets. That zero is by + * obtainable. Measured across every JSON document in the repo when this block + * was written (objectui#7151): 28 dashboard widgets, ZERO carrying the retired + * top-level binding, against live controls of 15 `options`-shaped and 2 + * `dataset`-shaped widgets — a historical reading; objectui#11070 round 6 + * later moved those 15 to the dataset form. That zero is by * DESIGN, not by accident — the retirement's whole content is that no * authoring surface emits the shape (`WidgetConfigPanel` scrubs it on save via * `LEGACY_ANALYTICS_KEYS`), and the catalog is an authoring corpus. A specimen diff --git a/packages/plugin-form/src/deriveMasterDetail.test.ts b/packages/plugin-form/src/deriveMasterDetail.test.ts index 8e41332fc4..d031d7b430 100644 --- a/packages/plugin-form/src/deriveMasterDetail.test.ts +++ b/packages/plugin-form/src/deriveMasterDetail.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest'; +import { normalizeSchemaReferenceKeys } from '@object-ui/core'; import { findRelationshipField, deriveColumns, deriveDetail, deriveFormFields, resolveInlineMode, fieldTypeToColumnType, hydrateColumns } from './deriveMasterDetail'; const taskSchema = { @@ -10,7 +11,7 @@ const taskSchema = { estimate_hours: { type: 'number', label: 'Estimate (h)' }, budget: { type: 'currency', label: 'Budget' }, due_date: { type: 'date', label: 'Due Date' }, - assignee: { type: 'lookup', label: 'Assignee', reference: 'user', display_field: 'name' }, + assignee: { type: 'lookup', label: 'Assignee', reference: 'user', displayField: 'name' }, project: { type: 'master_detail', label: 'Project', reference: 'showcase_project', required: true }, health: { type: 'formula', label: 'Health', expression: 'x' }, created_at: { type: 'datetime' }, @@ -89,6 +90,28 @@ describe('deriveColumns', () => { expect(byName.assignee).toMatchObject({ type: 'lookup', reference: 'user', displayField: 'name' }); }); + /** + * objectui#11070 round 6: the display pointer is read in the spec's spelling + * alone. The retired `display_field` sets nothing on a def handed straight + * in, and the same def after the ingestion fold (objectui#7650 ruling A, + * which `ObjectStackAdapter.getObjectSchema` runs) carries it as + * `displayField`. The snake spelling is the INPUT of both cases. + */ + it('reads `displayField` alone: a `display_field` def sets nothing raw, and keeps its value once folded', () => { + const snakeSchema = () => ({ + name: 'line', + fields: { assignee: { type: 'lookup', label: 'Assignee', reference: 'user', display_field: 'full_name' } }, + }); + const raw = deriveColumns(snakeSchema()).find((c) => c.name === 'assignee'); + expect(raw).toMatchObject({ type: 'lookup', reference: 'user' }); + expect(raw?.displayField).toBeUndefined(); + expect(hydrateColumns([{ name: 'assignee' }], snakeSchema())[0].displayField).toBeUndefined(); + + const folded = normalizeSchemaReferenceKeys(snakeSchema()); + expect(deriveColumns(folded).find((c) => c.name === 'assignee')?.displayField).toBe('full_name'); + expect(hydrateColumns([{ name: 'assignee' }], folded)[0].displayField).toBe('full_name'); + }); + it('carries field-level CEL conditional rules (readonlyWhen / requiredWhen) onto columns', () => { const schema = { name: 'line', diff --git a/packages/plugin-form/src/deriveMasterDetail.ts b/packages/plugin-form/src/deriveMasterDetail.ts index 417d752c36..90aa44c9e6 100644 --- a/packages/plugin-form/src/deriveMasterDetail.ts +++ b/packages/plugin-form/src/deriveMasterDetail.ts @@ -248,13 +248,19 @@ export function deriveColumns( if (col.type === 'select' && options) col.options = options; if (col.type === 'lookup') { col.reference = d?.reference; - // objectui#7642 CENSUS — verdict KEEP. In-repo the bag is the object-schema - // def (`MasterDetailForm` passes `dataSource.getObjectSchema(d.childObject)`), - // but `deriveColumns` is a PUBLIC export of `@object-ui/plugin-form`, so an - // external caller's `childSchema` cannot be traced from here. There is also no - // camel `d?.displayField` leg: retiring this read deletes the only read. - // The same read recurs in `hydrateColumns` below; this verdict covers both. - col.displayField = d?.display_field || d?.reference_field; + // The display pointer is read in `@objectstack/spec`'s spelling, + // `displayField` (objectui#11070 round 6). This read used to be + // `display_field || reference_field` with no camel leg, so a spec-valid + // def's `displayField` never reached the column. The `display_field` leg + // is retired, with no alias: a def served through `ObjectStackAdapter` + // reaches here with a stored `display_field` already stamped onto + // `displayField` by the ingestion fold (objectui#7650 ruling A). An + // external caller of this PUBLIC export that passes an unfolded + // `childSchema` loses the snake value; the round's changeset states that + // break. `reference_field` stays behind it: `FieldSchema` declares no + // twin the fold could stamp it onto. The same read recurs in + // `hydrateColumns` below. + col.displayField = d?.displayField || d?.reference_field; } if (col.type === 'file') applyFileColumnProps(col, d); // Field-level CEL conditional rules (B2 in grids). Carried through verbatim @@ -365,9 +371,8 @@ export function hydrateColumns( if (type === 'select' && options && !next.options) next.options = options; if (type === 'lookup') { if (next.reference == null) next.reference = d?.reference; - // objectui#7642 CENSUS — verdict KEEP, same bag and same missing camel leg as - // `deriveColumns` above. - if (next.displayField == null) next.displayField = d?.display_field || d?.reference_field; + // The same read as `deriveColumns` above (objectui#11070 round 6). + if (next.displayField == null) next.displayField = d?.displayField || d?.reference_field; } if (type === 'file') applyFileColumnProps(next, d); if (next.readonlyWhen == null && d?.readonlyWhen) next.readonlyWhen = d.readonlyWhen; diff --git a/packages/plugin-form/src/formArmsWritePayload-10563.test.tsx b/packages/plugin-form/src/formArmsWritePayload-10563.test.tsx index 92bfaf07e7..9e1594f0de 100644 --- a/packages/plugin-form/src/formArmsWritePayload-10563.test.tsx +++ b/packages/plugin-form/src/formArmsWritePayload-10563.test.tsx @@ -78,7 +78,7 @@ const DEAL_SCHEMA = { fields: { name: { type: 'text', label: 'Name' }, stage: { type: 'text', label: 'Stage' }, - total: { type: 'formula', label: 'Total', formula: 'qty * price' }, + total: { type: 'formula', label: 'Total', expression: 'record.qty * record.price' }, score: { type: 'number', label: 'Score' }, owner_id: { type: 'lookup', label: 'Owner', system: true }, created_by: { type: 'lookup', label: 'Created by', system: true }, diff --git a/packages/plugin-form/src/sanitize.test.ts b/packages/plugin-form/src/sanitize.test.ts index 6bdc4f602f..f642735cc6 100644 --- a/packages/plugin-form/src/sanitize.test.ts +++ b/packages/plugin-form/src/sanitize.test.ts @@ -47,6 +47,15 @@ describe('sanitizeFormData', () => { expect(out).toEqual({ name: 'Website', budget: 1000 }); }); + it('reads no `formula` flag: the retired key marks no other type computed (objectui#11070 round 6)', () => { + // A `type: 'formula'` field is dropped by its type, above. `FieldSchema` + // refuses `formula` by name on every field type (renaming it to + // `expression`), so a `formula` key on any other type is read by nothing + // here, and the value goes out like any writable field's. + const schema = { fields: { note: { type: 'text', formula: 'record.x' } } }; + expect(sanitizeFormData({ note: 'kept' }, schema)).toEqual({ note: 'kept' }); + }); + it('drops keys absent from the schema (flattened/projected fields)', () => { const schema = { fields: { name: { type: 'text' } } }; const out = sanitizeFormData({ name: 'keep', account__name: 'drop' }, schema); diff --git a/packages/plugin-form/src/sanitize.ts b/packages/plugin-form/src/sanitize.ts index 754695ce15..cbd29b0959 100644 --- a/packages/plugin-form/src/sanitize.ts +++ b/packages/plugin-form/src/sanitize.ts @@ -94,8 +94,12 @@ const COMPUTED_FIELD_TYPES = new Set([ * column added to the platform after this module was written is refused * without anyone editing the roster. * - When `objectSchema` is provided, drops fields that are flagged as - * `computed` / `formula` / `readOnly` or whose type is in - * {@link COMPUTED_FIELD_TYPES}. + * `computed` / `readOnly` or whose type is in {@link COMPUTED_FIELD_TYPES}. + * A `formula` key is NOT a flag this reads (objectui#11070 round 6): every + * `type: 'formula'` field is already dropped by its type, and + * `@objectstack/spec`'s `FieldSchema` refuses `formula` by name on every + * field type (renaming it to `expression`), so no served definition can use + * it to mark some other type computed. * - When `objectSchema` is provided, also drops keys that don't appear in * `objectSchema.fields` at all (these are typically server-projected * relationships or flattened lookups like `full_name`). @@ -151,7 +155,6 @@ export function sanitizeFormData( const t = String(fieldDef.type || '').toLowerCase(); if (COMPUTED_FIELD_TYPES.has(t)) continue; if (fieldDef.computed === true) continue; - if (fieldDef.formula) continue; if (fieldDef.readOnly === true || fieldDef.readonly === true) continue; } diff --git a/packages/types/src/__tests__/strict-face-read-keys-11070.test.ts b/packages/types/src/__tests__/strict-face-read-keys-11070.test.ts index c05212f17d..7c0ae5152e 100644 --- a/packages/types/src/__tests__/strict-face-read-keys-11070.test.ts +++ b/packages/types/src/__tests__/strict-face-read-keys-11070.test.ts @@ -234,6 +234,32 @@ describe('objectui#11070 — the read keys left undeclared pending a ruling stay expect(keys).not.toContain('reference_to'); }); + // Round 6 (objectui#11228 ruling C): the inline dashboard dialect is RETIRED, + // not declared. A widget binds a `dataset` and never carries rows, so these + // nine keys stay refused by name, and the catalog and docs no longer write + // them. ⛔ Declaring one reopens that ruling; it is not a fix to this list. + const DASHBOARD_DIALECT: ReadonlyArray]> = [ + ['options.data', { options: { data: [{ status: 'Paid', count: 47 }] } }], + ['options.xField', { options: { xField: 'status' } }], + ['options.yField', { options: { yField: 'count' } }], + ['options.value', { options: { value: '1,284' } }], + ['options.description', { options: { description: 'Monthly revenue' } }], + ['options.trend', { options: { trend: { value: 12, direction: 'up' } } }], + ['component.chartType', { component: { type: 'chart', chartType: 'area' } }], + ['component.xAxisKey', { component: { type: 'chart', xAxisKey: 'day' } }], + ['component.series', { component: { type: 'chart', series: [{ name: 'Sales' }] } }], + ]; + const datasetWidget = { id: 'w', type: 'bar', dataset: 'invoices', dimensions: ['status'], values: ['count'] }; + + it('a dataset-bound widget parses on the strict face (the control for the dialect rows below)', () => { + expect(issuesOf(StrictAnyComponentSchema, { type: 'dashboard', widgets: [datasetWidget] })).toBeNull(); + }); + + it.each(DASHBOARD_DIALECT)('the inline dashboard dialect `widgets[].%s` is refused by name (objectui#11228 ruling C)', (key, extra) => { + const doc = { type: 'dashboard', widgets: [{ ...datasetWidget, ...extra }] }; + expect(undeclared(issuesOf(StrictAnyComponentSchema, doc))).toContain(`widgets.0.${key}`); + }); + it('`object-chart.dataSource` is refused by name until it is declared (the react-page wrapper no longer puns the adapter into that key; the declaration and the objectui#10770 node pin move together)', () => { const doc = { type: 'object-chart', objectName: 'task', chartType: 'bar', dataSource: { object: 'task' } }; expect(undeclared(issuesOf(StrictAnyComponentSchema, doc))).toEqual(['dataSource']); @@ -266,6 +292,8 @@ export type assertionSpecMembersByReference = [ Expect>, Expect>, Expect>, + // Round 6: the formula itself is the spec's `expression`, by reference. + Expect>, ]; /** * Round 5: the text family carries the spec's length members BY REFERENCE — @@ -286,11 +314,12 @@ export type assertionTextFamilyLengthByReference = [ * Rounds 3 and 5: the retired snake_case members are gone from the field * metadata types — no second spelling — and so are the two switches round 5 * retired under ADR-0049 because nothing read them (`auto_compute`, - * `auto_update`). + * `auto_update`). Round 6: so is `formula`, which `FieldSchema` refuses by + * name in favour of `expression` (above). */ type Retired = | 'return_type' | 'summary_type' | 'summary_object' | 'summary_field' | 'summary_filter' - | 'min_length' | 'max_length' | 'auto_compute' | 'auto_update'; + | 'min_length' | 'max_length' | 'auto_compute' | 'auto_update' | 'formula'; export type assertionRetiredMembersAreGone = [ Expect, never>>, Expect, never>>, diff --git a/packages/types/src/field-types.ts b/packages/types/src/field-types.ts index 808fb1bf50..99a4636b52 100644 --- a/packages/types/src/field-types.ts +++ b/packages/types/src/field-types.ts @@ -842,12 +842,19 @@ export interface LookupFieldMetadata extends BaseFieldMetadata { export interface FormulaFieldMetadata extends BaseFieldMetadata { type: 'formula'; /** - * Formula expression - * Supports JavaScript-like expressions with field references - * @example "${amount} * ${tax_rate}" - * @example "${firstName} + ' ' + ${lastName}" + * The formula — `@objectstack/spec`'s `FieldSchema.expression`, typed BY + * REFERENCE so the two cannot drift (objectui#11070): a CEL source string + * (`record.quantity * record.unit_price`) or the spec's expression envelope + * (`{ dialect, source, … }`). The backend evaluates it; `FormulaField` only + * formats the computed value by `returnType`. + * + * It replaces the retired `formula` member, which `FieldSchema` refuses by + * name (its rename hint points here) and which no typed reader read. A + * stored Studio draft that still carries `formula` is objectui#6526's ruled + * migration path on the metadata-admin seam, not this published type. + * @example "record.quantity * record.unit_price" */ - formula?: string; + expression?: SpecField['expression']; /** * The value type the formula computes — `@objectstack/spec`'s * `FieldSchema.returnType`, typed BY REFERENCE so the two cannot drift From b126b7dcbb39eb6f869e0ea717ac886ee6ec5d61 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 03:51:14 +0000 Subject: [PATCH 2/3] docs(changeset): state the measured display-pointer readings for round 6 (objectui#11070) Claude-Session: https://claude.ai/code/session_01TdiauJaVCHuj45EzZGUxHh Co-authored-by: Claude --- .changeset/11070-dashboard-formula-round6.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/11070-dashboard-formula-round6.md b/.changeset/11070-dashboard-formula-round6.md index 507cdd3426..6b02c8532b 100644 --- a/.changeset/11070-dashboard-formula-round6.md +++ b/.changeset/11070-dashboard-formula-round6.md @@ -12,7 +12,7 @@ - **Display pointer.** `deriveColumns` and `hydrateColumns` (`@object-ui/plugin-form`, the master-detail grid columns), `ObjectChart`'s group-by labels (`@object-ui/plugin-charts`) and the action-param resolver (`@object-ui/app-shell`) read `displayField`, then `reference_field`. None of them reads `display_field` any more. - **A fix for spec-spelled lookups in master-detail grids.** `deriveColumns` and `hydrateColumns` read `display_field || reference_field` before, with no `displayField` leg. A lookup that declared only `displayField`, which is the spec's spelling, got no display pointer on its grid column. It now gets one. -A definition served through `ObjectStackAdapter.getObjectSchema` or `MetadataProvider` loses nothing: the ingestion pass (objectui#7650) stamps a stored `display_field` onto `displayField` before any of these readers sees it. Measured with a lookup carrying `display_field: 'title'` served through `ObjectStackAdapter`: the master-detail column, the chart's axis label and the action param all resolve the `title` column before and after this change. +A definition served through `ObjectStackAdapter.getObjectSchema` or `MetadataProvider` loses nothing: the ingestion pass (objectui#7650) stamps a stored `display_field` onto `displayField` before any of these readers sees it. Measured with a lookup carrying `display_field: 'title'` served through `ObjectStackAdapter.getObjectSchema`: the master-detail column (`deriveColumns` and `hydrateColumns`), the chart's axis label and the action param all resolve the `title` column before and after this change. ## ⚠️ BREAKING, priced as minor under the fixed group's version policy @@ -24,6 +24,6 @@ At runtime, a lookup whose display pointer is spelled only `display_field` loses - **`ObjectChart` on a `DataSource` other than `ObjectStackAdapter`**, grouped by that lookup: the axis label came from the `title` column. It now comes from the `name` column, the generic fallback. - **The action-param resolver, when a host passes its own unfolded `objects`** (for example through `RecordDetailView`'s `objects` prop): the lookup param's `displayField` was `title`. It is now absent. -The same lookups spelled `displayField` work in all three before and after. +The same lookups spelled `displayField` resolve the `title` column in all three after this change. Before it, the chart and the action param already did, and the master-detail columns did not (the fix above). **Fix:** spell the pointer `displayField`, or serve the definition through `ObjectStackAdapter`. From eddd18d8d29d6c90cca8dfcf91717182fc8c0188 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 03:58:16 +0000 Subject: [PATCH 3/3] chore(scripts): declare the dashboard example's `area` widget kind in check:doc-types (objectui#11070 round 6) The DashboardComponentSchema example's `component` chart is now a dataset-bound `area` widget (objectui#11228 ruling C). `area` is a widget family, not a node type, so the page declares it with its reason, the way the dashboard pages already declare `bar` / `line`. Claude-Session: https://claude.ai/code/session_01TdiauJaVCHuj45EzZGUxHh Co-authored-by: Claude --- scripts/check-doc-component-types.mjs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/scripts/check-doc-component-types.mjs b/scripts/check-doc-component-types.mjs index 660491614a..5cc3587a7a 100644 --- a/scripts/check-doc-component-types.mjs +++ b/scripts/check-doc-component-types.mjs @@ -728,6 +728,13 @@ const DOC_TYPE_EXEMPTIONS = { '@objectstack/spec\'s `PageVariableSchema`). (This reason used to add "same vocabulary as ' + 'blocks/block-schema.mdx\'s `string`"; that page was DELETED with the whole block schema ' + 'family in objectui#4895, so this entry now stands on its own declaration site.)', + area: + 'Dashboard widget kind under `widgets[].type` in the `DashboardComponentSchema` example — a ' + + 'member of `ChartTypeSchema` (@objectstack/spec/ui) reaching this repo by reference through ' + + '`DashboardWidgetTypeName`. Not a node type: the node type is `dashboard`, which the enclosing ' + + 'snippet spells. Needed from objectui#11070 round 6, which moved that example\'s `component` ' + + 'chart to the dataset-bound widget form under objectui#11228 ruling C. Same vocabulary as the ' + + '`packages/plugin-dashboard/README.md` entries below.', }, 'content/docs/blocks/authentication.mdx': { submit: