Skip to content

Commit 761acb2

Browse files
committed
docs(objectstack-ui): standardize the inline-vs-dataset chart rule (#2502)
Encode the chart-data expressibility rule into the objectstack-ui skill, pinned to what the analytics/dataset engine actually supports rather than the draft prose in the issue. - SKILL.md (Dataset-Bound Widgets): reframe around the real boundary. Every persisted chart is dataset-backed (ADR-0021 single-form cutover), so the decision is not "inline vs dataset" but "does the need fit the dataset envelope". Adds a Level A ceiling table (fits -> dataset; beyond -> Cube / stored rollup field / app code), an iron-rule callout, standardized answers for the recurring ambiguous cases (parent->child rollup, computed column, lookup filter, dashboard filter -> #2501), and a Level B note that naming a dataset is governance, not expressibility. Also corrects the Dashboards intro that still described the removed inline widget shape. - evals/analytics-inline-vs-dataset.json: first working evals for this skill, encoding the key decisions in the proven must_contain/must_not_contain format. - dashboard.zod.ts: fix the @example that still showed the removed inline widget shape (object/valueField/aggregate), which contradicted the now-required `dataset` field declared a few lines below it. Follow-ups intentionally left out of this change: service-analytics/README.md documents a retired API surface (HAVING, old IAnalyticsService methods) and needs a full rewrite; the inline-embedded-dataset schema change and the save-as-dataset promotion flow are larger, cross-repo design items tracked on the issue. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016QR9X5jcv9tEQQX5V9UZDu
1 parent 5d62496 commit 761acb2

3 files changed

Lines changed: 129 additions & 19 deletions

File tree

packages/spec/src/ui/dashboard.zod.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -276,26 +276,26 @@ export const GlobalFilterSchema = lazySchema(() => z.object({
276276
* Dashboard Schema
277277
* Represents a page containing multiple visualizations.
278278
*
279-
* @example Sales Executive Dashboard
279+
* @example Sales Executive Dashboard (ADR-0021: widgets bind a `dataset` and
280+
* select `dimensions`/`values` BY NAME — each metric/measure is defined once in
281+
* `defineDataset`, never inline on the widget)
280282
* {
281283
* name: "sales_overview",
282284
* label: "Sales Executive Overview",
283285
* widgets: [
284286
* {
285287
* title: "Total Pipe",
286288
* type: "metric",
287-
* object: "opportunity",
288-
* valueField: "amount",
289-
* aggregate: "sum",
289+
* dataset: "opportunity_metrics",
290+
* values: ["amount_sum"],
290291
* layout: { x: 0, y: 0, w: 3, h: 2 }
291292
* },
292293
* {
293294
* title: "Revenue by Region",
294295
* type: "bar",
295-
* object: "order",
296-
* categoryField: "region",
297-
* valueField: "total",
298-
* aggregate: "sum",
296+
* dataset: "order_metrics",
297+
* dimensions: ["region"],
298+
* values: ["total_sum"],
299299
* layout: { x: 3, y: 0, w: 6, h: 4 }
300300
* }
301301
* ]

skills/objectstack-ui/SKILL.md

Lines changed: 65 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -531,8 +531,9 @@ export const CrmApp = App.create({
531531
## Dashboards
532532

533533
Dashboards are a grid of widgets (`columns` × `rowHeight`) sharing a
534-
`dateRange` scrubber and `globalFilters`. Each widget declares an `object`,
535-
an `aggregate` measure or chart spec, and a `layout: {x,y,w,h}`.
534+
`dateRange` scrubber and `globalFilters`. Each widget **binds a `dataset`** and
535+
selects named `dimensions` + `values`, picks a chart `type`, and sets a
536+
`layout: {x,y,w,h}` (ADR-0021).
536537

537538
### Widget Types
538539

@@ -552,12 +553,15 @@ modifier; date bucketing comes from the bound dataset dimension's
552553

553554
### Dataset-Bound Widgets
554555

555-
For shared metrics, prefer the ADR-0021 dataset shape over per-widget inline
556-
queries. A widget binds to `dataset` and selects named `dimensions` and
557-
`values`; the dataset owns the base object, allowed joins, intrinsic filter,
558-
dimensions, and certified measures. Reports bind the same way (`dataset` +
559-
`rows` + `values` + `runtimeFilter`). Full guide: **Guides → Analytics Datasets**
560-
(`content/docs/data-modeling/analytics.mdx`).
556+
Every persisted chart is **dataset-backed** (ADR-0021 single-form cutover): a
557+
dashboard widget, a report, and a list `type:'chart'` view all **bind a `dataset`
558+
and select named `dimensions` + `values`**; the dataset owns the base object,
559+
allowed joins, intrinsic filter, dimensions, and certified measures. The legacy
560+
per-widget inline query (`object` + `categoryField` + `valueField` + `aggregate`)
561+
**was removed** — a widget now requires `dataset` + `values`; the inline fields are
562+
dropped and a widget lacking `dataset` fails `os validate`. Reports bind the same
563+
way (`dataset` + `rows` + `values` + `runtimeFilter`). Full guide: **Guides →
564+
Analytics Datasets** (`content/docs/data-modeling/analytics.mdx`).
561565

562566
A widget's presentation-scope `filter` flows into the query as the runtime
563567
filter; keep `filter` on the widget when binding a dataset.
@@ -574,9 +578,59 @@ filter; keep `filter` on the widget when binding a dataset.
574578
}
575579
```
576580

577-
- Dataset-bound widgets need at least one `values` entry.
578-
- Do not mix `dataset` with inline `object` / `valueField` / `aggregate`
579-
unless you are intentionally keeping a legacy inline widget shape.
581+
**The real decision is not "inline vs dataset" — it is "can a dataset express
582+
this?"** The shape is already fixed (always a dataset), so what you decide is
583+
whether the *data need fits the dataset envelope*, and if not, which lower layer to
584+
escalate to. Decide on **expressibility**; reuse/governance is Level B.
585+
586+
**Level A — the dataset envelope:**
587+
588+
| Fits a dataset → author one | Beyond the envelope → escalate |
589+
|:--|:--|
590+
| one base object + **to-one** joins (`include`, ≤3 hops) | a join that **changes grain** / a **to-many** rollup onto the parent |
591+
| 0..N dimensions; date-bucket `day/week/month/quarter/year` | a **computed dimension** / CASE bucket / numeric bin |
592+
| measures `count/sum/avg/min/max/count_distinct` | `array_agg`/`string_agg` or any custom-SQL metric |
593+
| **derived measures**`ratio/sum/difference/product` of other measures | scalar math on raw fields (`amount*0.8`), aggregate-of-aggregate |
594+
| WHERE (`$and/$or/$not` on the base object) + measure-scoped filters | **HAVING** (filtering the aggregate result) |
595+
| `compareTo` (previous period/year) + `totals` (matrix subtotals) | **window** (rank, running total, lag/lead, %-of-total); **union**; reshaping params |
596+
597+
> **The iron rule:** a dataset is a governed, *narrow* semantic layer — NOT a
598+
> general analytics escape hatch (no raw SQL, no hand-authored joins, no
599+
> window/having). If the need is in the right column, a dataset **cannot** express
600+
> it — escalate to a hand-authored **Cube** (raw SQL / explicit joins), a **stored
601+
> rollup or formula field** on the object (to-many rollups, computed columns), or
602+
> app code. Do not force it into a dataset: it fails to compile or renders an empty
603+
> series.
604+
605+
Standardized answers to the recurring ambiguous cases:
606+
607+
- **"Count of child tasks per project."** Base the dataset on the **child** (`task`)
608+
and group by the parent lookup (`project`) — grain = child. A rollup onto the
609+
**parent** grain ("on `project`, count related tasks") is a to-many rollup:
610+
**not** dataset-expressible — use a **stored rollup field** on `project`.
611+
- **To-one enrichment** ("revenue by `account.industry`") is fine and does **not**
612+
change grain — put `account` in `include`, add `account.industry` as a dimension.
613+
- **Computed column.** Formatting/currency → a measure's `format`/`currency`;
614+
arithmetic over declared measures (`margin = difference(revenue, cost)`) → a
615+
**derived measure**; CASE / bins / `revenue*0.8` / computed dimensions → **not** a
616+
dataset.
617+
- **Filter by a parent's attribute** → model it as a **dimension** (guaranteed to
618+
join); a lookup-path *filter* is not a reliable analytics-path construct.
619+
- **A dashboard filter driving several charts** (date/region) → **not** a dataset:
620+
a dashboard variable + per-chart `filterBindings` broadcast into each chart's
621+
WHERE (#2501). A dataset is implied only when a parameter **reshapes** the query
622+
(grain/window/join) — and those are beyond the envelope anyway.
623+
624+
**Level B — naming is governance, not expressibility.** An inline dataset draft
625+
(Studio Live Canvas) and a saved named dataset have **identical** expressibility;
626+
naming one is a reuse/governance call (canonical/shared metric, RLS, shared
627+
labels/formats → `defineDataset`). Persisted widgets already require a named
628+
dataset, so Level B only surfaces in Studio previews and hand-coded react-page
629+
`<ObjectChart>` blocks (a single-object inline `aggregate`, no dataset binding).
630+
631+
- Dataset-bound widgets need at least one `values` entry, and every
632+
`dataset`/`dimensions`/`values` name must resolve to its `defineDataset`
633+
`os validate` fails on an unresolved name (an empty chart otherwise).
580634
- Studio's Dashboard Widget Inspector can author per-widget `dataset`,
581635
`dimensions`, and `values`; curated metadata-admin forms merge
582636
server-only fields back into the payload, so saving through Studio should
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
{
2+
"skill_name": "objectstack-ui",
3+
"description": "Inline-vs-dataset / dataset-envelope-ceiling decisions (issue #2502). Every persisted chart is dataset-backed (ADR-0021); the real decision is whether the data need fits the dataset envelope, and if not, which lower layer to escalate to. Naming an inline draft vs a saved dataset is governance, not expressibility.",
4+
"evals": [
5+
{
6+
"id": 1,
7+
"prompt": "On the sales dashboard, add several widgets that all report the same certified 'revenue' metric (sum of amount) — one grouped by region, one by month, one as a KPI total. Emit the ObjectStack metadata.",
8+
"expected_output": "Defines the metric ONCE in a dataset (defineDataset with an amount_sum measure + region and a month-granularity date dimension), then binds each widget to that dataset and selects dimensions/values BY NAME. Does not restate the aggregate inline per widget (the ADR-0021 inline object/valueField/aggregate shape was removed).",
9+
"files": [],
10+
"assertions": {
11+
"must_contain": ["defineDataset", "dataset", "dimensions", "values"],
12+
"must_not_contain": ["valueField", "categoryField"]
13+
}
14+
},
15+
{
16+
"id": 2,
17+
"prompt": "Add a dashboard chart showing 30-day rolling revenue (a moving average / running total over time). Model the data need.",
18+
"expected_output": "Recognizes that a window/rolling calculation is BEYOND the dataset envelope (a dataset has no window/having grammar and compareTo only does previous-period/year). Escalates to a hand-authored Cube (raw SQL) or a stored/materialized rolling field — it does not pretend a dataset date-bucket or a plain measure expresses a moving window.",
19+
"files": [],
20+
"assertions": {
21+
"must_contain": ["window", "Cube"],
22+
"must_not_contain": ["dateGranularity"]
23+
}
24+
},
25+
{
26+
"id": 3,
27+
"prompt": "On a projects dashboard I want each project row to show its count of related child tasks (task has a lookup to project). Model it.",
28+
"expected_output": "Explains that a to-many rollup ONTO the parent grain is not expressible in a dataset (datasets only do to-one joins; to-many traversal is out of scope). Recommends a stored rollup/summary field on project, OR basing the dataset on the child task and grouping by the project lookup (grain = child). Does not author a to-many join in include.",
29+
"files": [],
30+
"assertions": {
31+
"must_contain": ["rollup", "to-many"],
32+
"must_not_contain": []
33+
}
34+
},
35+
{
36+
"id": 4,
37+
"prompt": "A dashboard has a top-level date-range filter that must re-scope six charts built over different objects. How should each chart pick up the filter? Emit the metadata.",
38+
"expected_output": "Uses a dashboard-level variable/filter plus per-chart filterBindings mapping the filter to each widget's own date field (e.g. invoice→created_at, account→signed_at), broadcast into each chart's WHERE (#2501). Charts stay self-contained; it does NOT invent a shared dataset for the filter (a dataset is only implied when a parameter reshapes the query grain/window/join).",
39+
"files": [],
40+
"assertions": {
41+
"must_contain": ["filterBindings"],
42+
"must_not_contain": []
43+
}
44+
},
45+
{
46+
"id": 5,
47+
"prompt": "In a kind:'react' page, drop a quick one-off bar chart of tasks grouped by status. Write the block.",
48+
"expected_output": "Uses the inline <ObjectChart objectName=\"task\" aggregate={{ field: '_id', function: 'count', groupBy: 'status' }} /> block directly — an ad-hoc in-page chart does not require a dataset, and the react ObjectChart block has no dataset prop. Naming a dataset would only be for reuse/governance (Level B), not needed here.",
49+
"files": [],
50+
"assertions": {
51+
"must_contain": ["ObjectChart", "aggregate", "groupBy"],
52+
"must_not_contain": ["defineDataset"]
53+
}
54+
}
55+
]
56+
}

0 commit comments

Comments
 (0)