Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .changeset/11070-dashboard-formula-round6.md
Original file line number Diff line number Diff line change
@@ -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.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

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` 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`.
2 changes: 2 additions & 0 deletions .changeset/7166-retire-inert-fieldmeta-copies.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions .changeset/lucky-donkeys-shave.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
47 changes: 16 additions & 31 deletions content/docs/api/schema-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
]
}
Expand All @@ -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)

Expand Down
51 changes: 25 additions & 26 deletions content/docs/fields/formula.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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).

Expand All @@ -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
Expand All @@ -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

Expand Down
Loading
Loading