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
60 changes: 60 additions & 0 deletions .changeset/objectchart-aggregate-result-columns.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
---
'@objectstack/spec': minor
'@objectstack/lint': minor
---

`<ObjectChart>` aggregate result-column naming is now a contract, and its axis bindings are validated (issue #3701)

Split out of #3583 Phase 2 (#3684), which extended ADR-0021 axis checking to
report charts, list-view charts, and dataset-bound page chart components but had
to leave the react `<ObjectChart>` block out: it is OBJECT-bound (`objectName` +
an inline `aggregate`), `aggregate` existed in the contract only as the
description string `'{ field, function, groupBy }'`, and nothing in the repo said
what the aggregated result columns were called. Without that, `xAxis`/`yAxis` had
nothing to resolve against, and guessing a convention would have manufactured
false positives (ADR-0072 D1).

**The convention, recorded rather than invented.** Every path that can serve an
object-bound chart already agreed — the engine's structured-`groupBy` aggregate
(whose alias objectui sets to `field || function`), the legacy analytics query
(which remaps its measure key back to `field`), the client-side fallback, and the
console's own chart-view wiring (`xAxisKey: groupBy`, `series[].dataKey: field`).
`packages/spec/src/ui/chart-aggregate.ts` writes it down and exports it:

* an object-bound aggregate returns rows keyed by the **raw field names** —
`groupBy` for the category column, `field` for the value column, the literal
`count` for a fieldless count, plus `<field>__comparison` under a comparison
overlay;
* `chartAggregateCategoryKey` / `chartAggregateValueKey` / `chartAggregateResultKeys`
derive those columns so producers and checkers cannot re-derive them apart;
* `ChartAggregateSchema` replaces the description string with a real Zod schema
and rejects a non-`count` function with no `field` (which used to reach the
renderer as `sum(undefined)` and render blank).

This is the deliberate opposite of the dataset path, whose rows are keyed by the
declared measure `name` (`sum_amount`) — the trap `chart-measure-unknown` catches.
Only the dataset path has an author-chosen name to key by.

**`<ObjectChart>`'s contract now names the props it actually reads.** The block
consumes `xAxisKey` and `series[].dataKey`; `ChartConfig`'s `xAxis`/`yAxis`/`series`
shapes reached it and were silently dropped, which ADR-0078 forbids. They are
removed from the block's `dataProps`; `chartType`, `xAxisKey`, and `series` are
declared in the React overlay where the other bindings live.

**`validate-react-page-props` now reads attribute VALUES**, not just names, for
`<ObjectChart>`:

* `react-chart-field-unknown` (error) — `aggregate.field` / `aggregate.groupBy`
naming a field the bound object does not declare;
* `react-chart-aggregate-invalid` (error) — an unimplemented aggregation
function, or a non-`count` function with nothing to aggregate;
* `react-chart-axis-unknown` (error) — `xAxisKey` / `series[].dataKey` naming a
column the aggregate does not return (including a dataset-style `sum_total`),
or a category axis bound to the value column;
* `react-chart-axis-inert` (warning) — the `xAxis` / `yAxis` shapes this block
never reads.

Value reading is opt-in per block and evaluates only static literals: a prop
driven by React state or a variable, a usage carrying a `{...spread}`, a chart
given inline `data`, and objects another package defines are all skipped
silently — an unresolvable binding is not a wrong one.
36 changes: 34 additions & 2 deletions content/docs/deployment/validating-metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,12 @@ chart: { type: 'bar', xAxis: 'status', yAxis: 'estimate_hours' }

An axis naming a measure the dataset declares but this chart does not *select*
(not in `values`) is a **warning**: the query never returns it, so it plots
nothing. The react `<ObjectChart>` block is object-bound rather than
dataset-bound and is not checked here.
nothing.

The react `<ObjectChart>` block is **object-bound** (`objectName` + an inline
`aggregate`) rather than dataset-bound, so it is checked by the react-page prop
gate below against a different rule — its result rows are keyed by the raw field
names, exactly the opposite of the dataset case.

### 7. Navigation exposing objects nobody can read

Expand All @@ -177,6 +181,34 @@ ships. Skipped for platform-provided objects (whose own packages grant them),
for stacks that declare no permission sets at all, and when any set carries a
wildcard (`objects: { '*': … }`) grant.

### 8. `<ObjectChart>` axes bound to the wrong result column

A `kind:'react'` page's `<ObjectChart>` is **object-bound**: `objectName` plus an
inline `aggregate`, run as one ad-hoc query. Its rows come back keyed by the
**raw field names** the aggregate was given — `groupBy` names the category
column, `field` names the value column (the literal `count` for a fieldless
`count`). That is the exact opposite of the dataset case in §6, and mixing the
two conventions up is the usual cause of a chart that renders axes and no bars.

```jsx
<ObjectChart objectName="invoice" chartType="bar"
aggregate={{ field: 'total', function: 'sum', groupBy: 'status' }}
xAxisKey="status" series={[{ dataKey: 'sum_total' }]} />
// ↑ a dataset-style measure name; the rows
// are keyed `total` → error
```

Both halves are checked: `aggregate.field` / `aggregate.groupBy` must be fields
the object declares, and `xAxisKey` / `series[].dataKey` must name a column the
aggregate actually returns (plus `<field>__comparison` when a comparison overlay
is on). `ChartConfig`'s `xAxis` / `yAxis` shapes are **not** read by this block
and are reported as inert (warning).

Skipped, to keep false positives at zero: any prop whose value is not a static
literal (it comes from React state or a variable), a usage carrying a `{...spread}`,
a chart given static `data` (its columns are the author's own), and objects
another package defines.

## The one gate, two entry points

`os validate` and `os build` (alias of `os compile`) run the **same** validator:
Expand Down
63 changes: 60 additions & 3 deletions content/docs/references/ui/chart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,41 @@ Provides a comprehensive set of chart types for data visualization.
## TypeScript Usage

```typescript
import { ChartAnnotation, ChartAxis, ChartConfig, ChartInteraction, ChartSeries, ChartType } from '@objectstack/spec/ui';
import type { ChartAnnotation, ChartAxis, ChartConfig, ChartInteraction, ChartSeries, ChartType } from '@objectstack/spec/ui';
import { ChartAggregate, ChartAggregateFunction, ChartAnnotation, ChartAxis, ChartConfig, ChartGroupBy, ChartInteraction, ChartSeries, ChartType } from '@objectstack/spec/ui';
import type { ChartAggregate, ChartAggregateFunction, ChartAnnotation, ChartAxis, ChartConfig, ChartGroupBy, ChartInteraction, ChartSeries, ChartType } from '@objectstack/spec/ui';

// Validate data
const result = ChartAnnotation.parse(data);
const result = ChartAggregate.parse(data);
```

---

## ChartAggregate

Inline aggregation for an object-bound chart

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **field** | `string` | optional | Field to aggregate — required for sum/avg/min/max, optional for count |
| **function** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max'>` | ✅ | Aggregation function |
| **groupBy** | `string \| { field: string; dateGranularity?: Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; alias?: string }` | ✅ | Field the rows are grouped by — the chart category axis |


---

## ChartAggregateFunction

### Allowed Values

* `count`
* `sum`
* `avg`
* `min`
* `max`


---

## ChartAnnotation
Expand Down Expand Up @@ -85,6 +113,35 @@ const result = ChartAnnotation.parse(data);
| **aria** | `{ ariaLabel?: string; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |


---

## ChartGroupBy

### Union Options

This schema accepts one of the following structures:

#### Option 1

Field to group by

Type: `string`

---

#### Option 2

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **field** | `string` | ✅ | Field to group by |
| **dateGranularity** | `Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional | Bucket date values into uniform periods |
| **alias** | `string` | optional | Alias for the projected group value (defaults to `field`) — this becomes the category column |

---


---

## ChartInteraction
Expand Down
7 changes: 6 additions & 1 deletion examples/app-showcase/src/ui/pages/renewals-pipeline.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ import { definePage } from '@objectstack/spec/ui';
* cross-object reads declaratively (zero data code).
* (This comparison absorbed the former Account Cockpit page.)
*
* The chart's axes are bound explicitly (#3701) to show the object-bound
* result-column rule: an inline `aggregate` returns rows keyed by the RAW
* FIELD NAMES — `status` (its `groupBy`) and `total` (its `field`) — not by a
* dataset-style measure name. `os validate` now checks both halves.
*
* Styling (ADR-0065): no Tailwind — inline `style={{}}` with `hsl(var(--token))`;
* data blocks and the drawer bring their own compiled styling. The drawer sets
* NO pixel width: per #2578 pixel widths are deprecated (the author can't know
Expand Down Expand Up @@ -114,7 +119,7 @@ function Page() {
<Stat label="Open AR" value={related.openInvoices} accent="hsl(38 92% 50%)" />
</div>

<ObjectChart objectName="showcase_invoice" aggregate={{ field: 'total', function: 'sum', groupBy: 'status' }} title="Invoice value by status" showLegend={true} />
<ObjectChart objectName="showcase_invoice" chartType="bar" aggregate={{ field: 'total', function: 'sum', groupBy: 'status' }} xAxisKey="status" series={[{ dataKey: 'total', label: 'Invoice value' }]} title="Invoice value by status" showLegend={true} />

<RecordRelatedList objectName="showcase_account" recordId={sel} relationshipField="account" columns={['name', 'status', 'total']} limit={5} showViewAll={true} title="Invoices" />

Expand Down
8 changes: 7 additions & 1 deletion packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,13 @@ export { validateJsxPages } from './validate-jsx-pages.js';
export type { JsxPageFinding, JsxPageSeverity } from './validate-jsx-pages.js';
export { validateReactPages } from './validate-react-pages.js';
export type { ReactPageFinding, ReactPageSeverity } from './validate-react-pages.js';
export { validateReactPageProps } from './validate-react-page-props.js';
export {
validateReactPageProps,
REACT_CHART_FIELD_UNKNOWN,
REACT_CHART_AGGREGATE_INVALID,
REACT_CHART_AXIS_UNKNOWN,
REACT_CHART_AXIS_INERT,
} from './validate-react-page-props.js';
export type { ReactPropFinding, ReactPropSeverity } from './validate-react-page-props.js';
export { validatePageSourceStyling, PAGE_SOURCE_CLASSNAME } from './validate-page-source-styling.js';
export type { SourceStyleFinding, SourceStyleSeverity } from './validate-page-source-styling.js';
Expand Down
17 changes: 8 additions & 9 deletions packages/lint/src/validate-chart-bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,14 @@
* binding shape as a list chart, but it arrives through the untyped
* `properties` bag.
*
* Deliberately NOT covered: the react `<ObjectChart>` block. It is
* OBJECT-bound (`objectName` + an inline `aggregate`), not dataset-bound, and
* nothing in the repo pins down what the runtime names the aggregated result
* column — so there is no defensible answer for what its `yAxis[].field`
* should resolve against. Guessing one would manufacture false positives on
* the single authored usage. Its `aggregate.field` / `groupBy` ARE checkable
* against the object's fields, but only by reading JSX attribute values, which
* `validate-react-page-props` does not do today. Left for a follow-up rather
* than half-built (ADR-0078 §5: verify, then enforce).
* Not covered HERE, and deliberately so: the react `<ObjectChart>` block. It is
* OBJECT-bound (`objectName` + an inline `aggregate`), so its result rows are
* keyed by the RAW FIELD NAMES rather than by a measure name — the opposite
* convention, which would make `chart-measure-unknown`'s message a lie. It also
* arrives as JSX rather than config, so it needs the TypeScript compiler this
* rule has no business loading. It is checked by `validate-react-page-props`
* instead, against the naming convention `chartAggregateResultKeys`
* (`@objectstack/spec/ui`) now pins down (#3701).
*/

export const CHART_DIMENSION_UNKNOWN = 'chart-dimension-unknown';
Expand Down
Loading
Loading