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
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
'@objectstack/spec': minor
---

feat(spec)!: an `object-metric` page block's `drillDown` and `compareTo`, and an `object-grid` page block's `columns`, take the shape each block reads instead of any value (#21464)

Clause-②: yes (narrowing)

<!-- adr-0087: registered ui-object-metric-compare-to-typed, ui-object-metric-drill-down-typed, ui-object-grid-columns-typed -->

**BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. What reads the rows: the component-props gate on `objectstack validate`, `objectstack build` and `objectstack lint`, which reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding. A stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path.

**`@objectstack/spec`**

- **`object-metric` `compareTo` takes the tile's read, `{ kind }`.** It was `z.unknown()`, although the tile reads `kind` alone: a bare `'previousYear'` or a kind outside the two compared against the previous period, and a `dimension` was carried and never read. `kind` is the dashboard widget comparison's own vocabulary by reference (`previousPeriod`, `previousYear`). `dimension` is refused by name, with the prescription: this inline tile shifts the date macros in its own `filter` and never reads a dataset time dimension, so state the window on the tile's `filter`.
- **`object-metric` `drillDown` takes the tile's read.** It was `z.unknown()`: a drill `filter`, a `mode` or a misspelled member passed and was ignored. Its five list members — `enabled`, `title`, `target` (`drawer`, `dialog`, `navigate`), `columns` (field names) and `maxRows` (a positive whole number) — are the chart drill-down's own by reference. `filter` and `mode` are refused by name: a metric tile has no click event for a drill filter to resolve against (the drilled list is scoped by the metric's own `filter`), and no row for `mode` to open as a record. The chart's drill-down shape is not taken whole, because it declares `filter`.
- **The drill-down's `report` is not narrowed** and still accepts any value. The tile draws a dataset-bound report through the shared drill drawer, but no spec drill shape declares a `report` member yet; it is typed once the spec declares the drill report.
- **`object-grid` `columns` takes the list view's own `columns`**: all field-name strings, or all column entries `{ field, label?, width?, align?, hidden?, sortable?, resizable?, wrap?, type?, pinned?, summary?, prefix?, link?, action? }`. It was an array of `z.unknown()`, held at the second stage because the grid's group headers drew a column's `options`, which the column entry does not declare. The renderer has since retired that read (the group-header labels come from the object field's `options` only), so the hold is lifted. A column keyed `accessorKey` / `header` or `name`, a column with no `field`, a list mixing strings and entries, or a column key the entry does not declare (`editable`, `options`, `reference`, or a footer number hint such as `currency` or `precision`) is refused.
- **`ObjectMetricProps` and `ObjectGridProps`** carry these types on the three members instead of `unknown`. A parsed column's `prefix.type` now carries the list view's `'text'` default.

## FROM → TO

| you wrote | write instead |
|:--|:--|
| `object-metric` `compareTo: 'previousYear'` | `compareTo: { kind: 'previousYear' }` |
| `object-metric` `compareTo: { kind: 'previousYear', dimension: 'close_date' }` | `compareTo: { kind: 'previousYear' }`, with the window stated on the tile's own `filter` (date macros such as `{current_quarter_start}`) |
| `object-metric` `compareTo: { kind: 'previousQuarter' }` | `previousPeriod` (the equal-length window before the one the filter resolves to) or `previousYear` |
| `object-metric` `drillDown: { filter: { stage: 'won' } }` | delete it, and scope the metric with its own `filter` (the drilled list follows it) |
| `object-metric` `drillDown: { mode: 'record' }` | delete it — a metric always lists the records behind its number |
| `object-metric` `drillDown: { limit: 50 }` | `drillDown: { maxRows: 50 }` |
| `object-grid` `columns: [{ accessorKey: 'amount', header: 'Amount' }]` | `columns: [{ field: 'amount', label: 'Amount' }]` |
| `object-grid` `columns: [{ name: 'salary' }]` | `columns: [{ field: 'salary' }]` |
| `object-grid` `columns: ['name', { field: 'amount', width: 120 }]` | all entries: `[{ field: 'name' }, { field: 'amount', width: 120 }]` |
| `object-grid` a column `editable`, `options`, `reference`, `currency` or `precision` | delete the key: inline editing is the grid's own `editable`, and option labels, relational metadata and number formats are the object field's |

The one-line fix: write each member as the table above shows. No conversion is registered, because a refused value has no rewrite that both keeps what the block shows today and honours what the author wrote; the D3 entries `ui-object-metric-compare-to-typed`, `ui-object-metric-drill-down-typed` and `ui-object-grid-columns-typed` carry that judgment.

## Who is affected, measured

A writer is a value written on the block: a page-component node (an object literal naming the type, a literal annotated with the block's type, a direct parse through the row), the block's React component with the member as a prop or inside `schema={{…}}`, or the argument of a local test helper that mounts one (positional helper parameters resolved at every call site). Values resolve through same-file constants. Each static value was parsed through the row.

- **objectstack** at `6ec54f00ba`, over `examples/`, `packages/` (with `packages/apps/`), `content/`, `skills/`, `apps/` and `docs/`: 5 `object-grid` `columns` values, all field-name strings (the showcase's `my-work.page.ts` and `command-center.page.ts` grids, and three test copies), all parse. No `object-metric` `drillDown` or `compareTo` is authored.
- **objectui** at the `.objectui-sha` pin `ab1879721595`, every one a test fixture or a run-time hand-off:
- `compareTo`: 5 values, 4 parse. The refused one is the test that asserts a `dimension` never touches the query (`ObjectMetricWidget.compareTo.test.tsx`).
- `drillDown`: 26 values, 25 static; 23 parse. The two refused are the compile-time refusal probes for `filter` and `mode` (`ObjectMetricWidget.drillDownRefusal-9002.test.tsx`). The one that is not static carries a report probe, which parses, since `report` stays open.
- `object-grid` `columns`: 362 values, 353 static (147 distinct); 312 parse. Each of the 41 refused is a test fixture whose refused key or entry the grid does not draw: 17 columns keyed `accessorKey` / `header` and 5 keyed `name` (the column-spelling diagnostic, identity and field-security tests); 14 columns carrying `editable: false` and 1 carrying `reference`, keys no read takes off an authored column; 2 carrying `options`, the tests asserting that the group headers no longer read them; 1 column with no `field`; and 1 numeric `columns` refused by objectui's own mirror. The 9 values that are not static are 3 run-time hand-offs (the object view and two designer grids) and 6 test lists built from `{ field, label, type }` entries, which parse.
- **Deployed metadata** was not measured.
42 changes: 39 additions & 3 deletions content/docs/references/ui/component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -710,7 +710,7 @@ Sort field and direction pair
| **title** | `string \| Record<string, string>` | optional | Fallback for `label` (the renderer reads `label \|\| title`) |
| **description** | `string \| Record<string, string>` | optional | One line of help text drawn above the grid's rows — a string, or an inline locale map resolved against the display locale |
| **emptyState** | `{ title?: string \| Record<string, string>; message?: string \| Record<string, string>; icon?: string }` | optional | What the grid draws instead of an empty table: `{ title, message, icon }` — the list view's own empty-state shape |
| **columns** | `any[]` | optional | Columns: field names or column definition objects |
| **columns** | `string[] \| { field: string; label?: string \| Record<string, string>; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | optional | Columns — all field-name strings, or all column entries `{ field, label?, width?, align?, hidden?, sortable?, … }`, the same union a list view's `columns` declares. One spelling per list: an array mixing strings and column objects is refused |
| **fields** | `string[]` | optional | Field-name fallback the grid reads when `columns` is absent — bare field names (`['name', 'amount']`); write column decoration such as `label` or `width` on `columns` |
| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. THE key, singular — not the plural misspelling. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` |
| **defaultFilters** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Legacy base-filter fallback, read only when `filter` is absent — the SAME ViewFilterRule array form `[{ field, operator, value }, ...]` as `filter`, lowered through the same sink. Prefer `filter`. The MongoDB-style record form, a bare string and an ObjectQL AST tuple array are refused — see migration `object-grid-default-filters-rule-array` |
Expand Down Expand Up @@ -754,6 +754,25 @@ Sort field and direction pair
| **message** | `string \| Record<string, string>` | optional | Line of text below the heading |
| **icon** | `string` | optional | Icon name drawn above the heading; a name that resolves to no icon draws the default empty-state glyph |

### Nested Shape: `ObjectGridProps.columns[number]`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **field** | `string` | ✅ | Field name (snake_case) |
| **label** | `string \| Record<string, string>` | optional | Display label override |
| **width** | `number` | optional | Column width in pixels |
| **align** | `Enum<'left' \| 'center' \| 'right'>` | optional | Text alignment |
| **hidden** | `boolean` | optional | Hide column by default |
| **sortable** | `boolean` | optional | Allow sorting by this column |
| **resizable** | `boolean` | optional | Allow resizing this column |
| **wrap** | `boolean` | optional | Allow text wrapping |
| **type** | `string` | optional | Renderer type override (e.g., "currency", "date") |
| **pinned** | `Enum<'left' \| 'right'>` | optional | Pin/freeze column to left or right side |
| **summary** | `Enum<'none' \| 'count' \| 'count_empty' \| 'count_filled' \| 'count_unique' \| …> \| { type: Enum<'none' \| 'count' \| 'count_empty' \| 'count_filled' \| 'count_unique' \| …>; field?: string }` | optional | Footer aggregation for this column — the function alone, or `{ type, field }` to aggregate another field |
| **prefix** | `{ field: string; type?: Enum<'badge' \| 'text'> }` | optional | Field rendered inline before this cell value |
| **link** | `boolean` | optional | Functions as the primary navigation link (triggers View navigation) |
| **action** | `string` | optional | Registered Action ID to execute when clicked |

### Nested Shape: `ObjectGridProps.filter[number]`

View filter rule
Expand Down Expand Up @@ -1116,8 +1135,8 @@ Sort field and direction pair
| **variant** | `Enum<'card' \| 'bare'>` | optional | Layout variant |
| **fallbackValue** | `string \| number` | optional | Static value shown when no data source is available |
| **trend** | `{ value: number; label?: string \| Record<string, string>; direction?: Enum<'up' \| 'down' \| 'neutral'> }` | optional | Static trend badge `{ value, label?, direction? }` — `value` painted as a percentage, `direction` up / down / neutral. A `compareTo`-derived trend replaces it |
| **drillDown** | `any` | optional | Click-through drill config — opens the underlying records |
| **compareTo** | `any` | optional | Period-over-period comparison (`{ kind: 'previousPeriod' \| 'previousYear' }`) |
| **drillDown** | `{ enabled?: boolean; title?: string; target?: Enum<'drawer' \| 'dialog' \| 'navigate'>; columns?: string[]; … }` | optional | Click-through drill config `{ enabled?, title?, target?, columns?, maxRows?, report? }` — opens the records behind the number, scoped by the metric's own `filter`; a present block is on unless `enabled: false`. `filter` and `mode` are refused: a metric tile has no click context and no row |
| **compareTo** | `{ kind: Enum<'previousPeriod' \| 'previousYear'> }` | optional | Period-over-period comparison `{ kind }` — `previousPeriod` or `previousYear`, shifting the date macros in the tile's own `filter`. `dimension` is refused: this tile never reads a dataset time dimension |

### Nested Shape: `ObjectMetricProps.aggregate`

Expand Down Expand Up @@ -1145,6 +1164,23 @@ View filter rule
| **label** | `string \| Record<string, string>` | optional | Badge caption — a string or an inline locale map. The tile-level `description` outranks it in the one caption slot they share |
| **direction** | `Enum<'up' \| 'down' \| 'neutral'>` | optional | Arrow beside the value; omit it for no arrow |

### Nested Shape: `ObjectMetricProps.drillDown`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **enabled** | `boolean` | optional | Turn the tile's drill on or off; the block being present already means on, so this is only needed to force it off |
| **title** | `string` | optional | Drill drawer/dialog heading; defaults to the tile's own `title`, then its `label`. A metric tile has no click context, so an `${event.*}` token here resolves to nothing |
| **target** | `Enum<'drawer' \| 'dialog' \| 'navigate'>` | optional | Where the drilled list opens: 'drawer' (default, side sheet), 'dialog' (centered modal), or 'navigate' (skip the in-place view and open the object's full list page; needs host drill navigation, else falls back to 'drawer') |
| **columns** | `string[]` | optional | Field names to show as columns in the drilled list (default: the table's own columns) |
| **maxRows** | `integer` | optional | Rows per page in the drilled list |
| **report** | `any` | optional | Drill into a report instead of the record list — not typed on this row yet: the tile draws a dataset-bound report here, but no spec drill shape declares a `report` member yet (the chart's drill-down refuses it) |

### Nested Shape: `ObjectMetricProps.compareTo`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **kind** | `Enum<'previousPeriod' \| 'previousYear'>` | ✅ | Comparison window: previousPeriod (equal-length, immediately before) or previousYear (−1 calendar year) |


---

Expand Down
10 changes: 5 additions & 5 deletions docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th

| Dir | Sites | strict | passthrough | catchall | strip |
|---|---|---|---|---|---|
| `ui/` | 194 | 183 | 4 | 0 | 7 |
| `ui/` | 196 | 185 | 4 | 0 | 7 |

## `ui/` — sites

Expand All @@ -36,7 +36,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
| `app.zod.ts` | 19 |
| `bulk-action.zod.ts` | 4 |
| `chart.zod.ts` | 8 |
| `component.zod.ts` | 64 |
| `component.zod.ts` | 66 |
| `dashboard.zod.ts` | 11 |
| `dataset.zod.ts` | 4 |
| `i18n.zod.ts` | 1 |
Expand All @@ -46,23 +46,23 @@ classify and is not listed (it becomes reportable the day it grows its first sit
| `sharing.zod.ts` | 1 |
| `view.zod.ts` | 60 |
| `widget.zod.ts` | 1 |
| **total** | **194** |
| **total** | **196** |

## `ui/` — open

Per file, how many of its sites still silently discard unknown keys. The `Class`
column that decides the bucket split is hand-written in the ledger; the arithmetic
over it is here.

**7 strip of 194**, in 4 file(s).
**7 strip of 196**, in 4 file(s).

| File | Strip | Sites |
|---|---|---|
| `action-params.zod.ts` | 1 | 1 |
| `app.zod.ts` | 1 | 19 |
| `view.zod.ts` | 4 | 60 |
| `widget.zod.ts` | 1 | 1 |
| **total** | **7** | **194** |
| **total** | **7** | **196** |

| Bucket | Sites |
|---|---|
Expand Down
Loading
Loading