diff --git a/content/docs/references/ui/dashboard.mdx b/content/docs/references/ui/dashboard.mdx
index 60168203c..b6dd7ae5f 100644
--- a/content/docs/references/ui/dashboard.mdx
+++ b/content/docs/references/ui/dashboard.mdx
@@ -14,8 +14,8 @@ Color variant for dashboard widgets (e.g., KPI cards).
## TypeScript Usage
```typescript
-import { Dashboard, DashboardHeader, DashboardHeaderAction, DashboardWidget, GlobalFilter, GlobalFilterOptionsFrom, WidgetActionType, WidgetColorVariant, WidgetMeasure } from '@objectstack/spec/ui';
-import type { Dashboard, DashboardHeader, DashboardHeaderAction, DashboardWidget, GlobalFilter, GlobalFilterOptionsFrom, WidgetActionType, WidgetColorVariant, WidgetMeasure } from '@objectstack/spec/ui';
+import { Dashboard, DashboardHeader, DashboardHeaderAction, DashboardWidget, GlobalFilter, GlobalFilterOptionsFrom, WidgetActionType, WidgetColorVariant } from '@objectstack/spec/ui';
+import type { Dashboard, DashboardHeader, DashboardHeaderAction, DashboardWidget, GlobalFilter, GlobalFilterOptionsFrom, WidgetActionType, WidgetColorVariant } from '@objectstack/spec/ui';
// Validate data
const result = Dashboard.parse(data);
@@ -101,14 +101,11 @@ Dashboard header action
| **actionUrl** | `string` | optional | URL or target for the widget action button |
| **actionType** | `Enum<'script' \| 'url' \| 'modal' \| 'flow' \| 'api'>` | optional | Type of action for the widget action button |
| **actionIcon** | `string` | optional | Icon identifier for the widget action button |
-| **object** | `string` | optional | Data source object name |
-| **filter** | `[__schema0](./__schema0)` | optional | Data filter criteria |
+| **filter** | `[__schema0](./__schema0)` | optional | Presentation-scope filter (runtimeFilter) |
| **compareTo** | `string \| string \| Object` | optional | Period-over-period comparison window |
-| **categoryField** | `string` | optional | Field for grouping (X-Axis) |
-| **categoryGranularity** | `Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional | Bucket categoryField date values into day/week/month/quarter/year periods |
-| **valueField** | `string` | optional | Field for values (Y-Axis) |
-| **aggregate** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max'>` | ✅ | Aggregate function |
-| **measures** | `Object[]` | optional | Multiple measures for pivot/matrix analysis |
+| **dataset** | `string` | ✅ | Dataset name to bind (ADR-0021) |
+| **dimensions** | `string[]` | optional | Dimension names — X/group/split |
+| **values** | `string[]` | ✅ | Measure names — Y (at least one) |
| **layout** | `Object` | ✅ | Grid layout position |
| **options** | `any` | optional | Widget specific configuration |
| **responsive** | `Object` | optional | Responsive layout configuration |
@@ -184,19 +181,3 @@ Widget color variant
---
-## WidgetMeasure
-
-Widget measure definition
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **valueField** | `string` | ✅ | Field to aggregate |
-| **aggregate** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max'>` | ✅ | Aggregate function |
-| **label** | `string` | optional | Measure display label |
-| **format** | `string` | optional | Number format string |
-
-
----
-
diff --git a/content/docs/references/ui/dataset.mdx b/content/docs/references/ui/dataset.mdx
new file mode 100644
index 000000000..422fa3f5c
--- /dev/null
+++ b/content/docs/references/ui/dataset.mdx
@@ -0,0 +1,128 @@
+---
+title: Dataset
+description: Dataset protocol schemas
+---
+
+{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs go in content/docs/guides/. */}
+
+Analytics Dataset — the one semantic layer (ADR-0021).
+
+A `dataset` is a named, reusable analytical definition: a base object, the
+
+relationships to include (joins are *derived* from the object graph — the
+
+author never writes an `ON` clause), and the declared **dimensions**
+
+(groupable axes) and **measures** (aggregatable values). It is deliberately
+
+SMALLER than `QuerySchema`: no raw SQL, no hand-authored join predicates,
+
+no window/having grammar in the author surface.
+
+Presentations (`report` / `dashboard`) bind to a dataset by reference and
+
+pick dimensions/measures *by name*. The dataset compiles to the existing
+
+Cube analytics runtime (ADR-0021 D-A=(c)); RLS / tenant scoping is enforced
+
+by the runtime per joined object (D-C), never declared here.
+
+Naming: this module owns the high-prior `dataset` / `dimension` / `measure`
+
+vocabulary (LookML / dbt / Cube / PowerBI). The Zod export identifiers are
+
+`Dataset`-prefixed (`DatasetDimensionSchema`, `DatasetMeasureSchema`) so they
+
+do not clash with the Cube layer's `DimensionSchema` / `MetricSchema` in
+
+`data/analytics.zod.ts` while the two layers coexist (Phase 1). The Cube
+
+layer is absorbed/retired in a later phase (D-A).
+
+
+**Source:** `packages/spec/src/ui/dataset.zod.ts`
+
+
+## TypeScript Usage
+
+```typescript
+import { Dataset, DatasetDimension, DatasetMeasure, DerivedMeasureOp } from '@objectstack/spec/ui';
+import type { Dataset, DatasetDimension, DatasetMeasure, DerivedMeasureOp } from '@objectstack/spec/ui';
+
+// Validate data
+const result = Dataset.parse(data);
+```
+
+---
+
+## Dataset
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **name** | `string` | ✅ | Dataset unique name |
+| **label** | `string` | ✅ | Dataset label |
+| **description** | `string` | optional | Display label (plain string; i18n keys are auto-generated by the framework) |
+| **object** | `string` | ✅ | Base object name |
+| **include** | `string[]` | optional | Relationship names to join (derived from object graph) |
+| **filter** | `[__schema0](./__schema0)` | optional | Intrinsic dataset scope filter |
+| **dimensions** | `Object[]` | ✅ | Groupable axes |
+| **measures** | `Object[]` | ✅ | Aggregatable values |
+| **protection** | `Object` | optional | Package author protection block — lock policy for this dataset. |
+| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
+| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
+| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact | package | env-forced). |
+| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package | org | env-forced). |
+| **_packageId** | `string` | optional | Owning package machine id. |
+| **_packageVersion** | `string` | optional | Owning package version. |
+| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. |
+
+
+---
+
+## DatasetDimension
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **name** | `string` | ✅ | Dimension name — referenced by presentations |
+| **label** | `string` | optional | Display label (plain string; i18n keys are auto-generated by the framework) |
+| **field** | `string` | ✅ | Base field or `relationship.field` path |
+| **type** | `Enum<'string' \| 'number' \| 'date' \| 'boolean' \| 'lookup'>` | optional | |
+| **dateGranularity** | `Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional | |
+
+
+---
+
+## DatasetMeasure
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **name** | `string` | ✅ | Measure name — e.g. "revenue"; defined once |
+| **label** | `string` | optional | Display label (plain string; i18n keys are auto-generated by the framework) |
+| **aggregate** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct' \| 'array_agg' \| 'string_agg'>` | ✅ | Aggregation (sum/avg/count/...) |
+| **field** | `string` | optional | Aggregated field; optional for count(*) |
+| **filter** | `[__schema0](./__schema0)` | optional | |
+| **format** | `string` | optional | |
+| **certified** | `boolean` | ✅ | Blessed metric (governance checkpoint) |
+| **derived** | `Object` | optional | |
+
+
+---
+
+## DerivedMeasureOp
+
+### Allowed Values
+
+* `ratio`
+* `sum`
+* `difference`
+* `product`
+
+
+---
+
diff --git a/content/docs/references/ui/index.mdx b/content/docs/references/ui/index.mdx
index 5788f90a7..d696fe65e 100644
--- a/content/docs/references/ui/index.mdx
+++ b/content/docs/references/ui/index.mdx
@@ -12,6 +12,7 @@ This section contains all protocol schemas for the ui layer of ObjectStack.
+
diff --git a/content/docs/references/ui/meta.json b/content/docs/references/ui/meta.json
index 436419cfc..40441070e 100644
--- a/content/docs/references/ui/meta.json
+++ b/content/docs/references/ui/meta.json
@@ -7,6 +7,7 @@
"chart",
"component",
"dashboard",
+ "dataset",
"dnd",
"http",
"i18n",
diff --git a/content/docs/references/ui/report.mdx b/content/docs/references/ui/report.mdx
index 0c0a41083..44528ae1d 100644
--- a/content/docs/references/ui/report.mdx
+++ b/content/docs/references/ui/report.mdx
@@ -33,12 +33,11 @@ const result = JoinedReportBlock.parse(data);
| **label** | `string` | optional | Display label (plain string; i18n keys are auto-generated by the framework) |
| **description** | `string` | optional | Display label (plain string; i18n keys are auto-generated by the framework) |
| **type** | `Enum<'tabular' \| 'summary' \| 'matrix'>` | ✅ | |
-| **objectName** | `string` | optional | |
-| **columns** | `Object[]` | ✅ | |
-| **groupingsDown** | `Object[]` | optional | |
-| **groupingsAcross** | `Object[]` | optional | |
-| **filter** | `[__schema0](./__schema0)` | optional | |
| **chart** | `Object` | optional | |
+| **dataset** | `string` | optional | Dataset name to bind (ADR-0021) |
+| **rows** | `string[]` | optional | Dimension names down (dataset-bound) |
+| **values** | `string[]` | optional | Measure names to show (dataset-bound) |
+| **runtimeFilter** | `[__schema0](./__schema0)` | optional | Render-time scope filter (dataset-bound) |
---
@@ -52,12 +51,11 @@ const result = JoinedReportBlock.parse(data);
| **name** | `string` | ✅ | Report unique name |
| **label** | `string` | ✅ | Report label |
| **description** | `string` | optional | Display label (plain string; i18n keys are auto-generated by the framework) |
-| **objectName** | `string` | ✅ | Primary object |
| **type** | `Enum<'tabular' \| 'summary' \| 'matrix' \| 'joined'>` | ✅ | Report format type |
-| **columns** | `Object[]` | ✅ | Columns to display |
-| **groupingsDown** | `Object[]` | optional | Row groupings |
-| **groupingsAcross** | `Object[]` | optional | Column groupings (Matrix only) |
-| **filter** | `[__schema0](./__schema0)` | optional | Filter criteria |
+| **dataset** | `string` | optional | Dataset name to bind (ADR-0021) |
+| **rows** | `string[]` | optional | Dimension names down |
+| **values** | `string[]` | optional | Measure names to show |
+| **runtimeFilter** | `[__schema0](./__schema0)` | optional | Render-time scope filter |
| **chart** | `Object` | optional | Embedded chart configuration |
| **aria** | `Object` | optional | ARIA accessibility attributes |
| **performance** | `Object` | optional | Performance optimization settings |
diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx
index 83b87515d..774b8ff35 100644
--- a/content/docs/references/ui/view.mdx
+++ b/content/docs/references/ui/view.mdx
@@ -170,10 +170,9 @@ List chart view configuration
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **chartType** | `Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>` | ✅ | Chart visualisation type |
-| **xAxisField** | `string` | ✅ | Field used as the X axis / category dimension |
-| **yAxisFields** | `string[]` | ✅ | Field(s) used as the Y axis / measures |
-| **aggregation** | `Enum<'sum' \| 'avg' \| 'count' \| 'min' \| 'max'>` | optional | Aggregation function applied to Y axis fields |
-| **groupByField** | `string` | optional | Optional field used to split / stack the chart |
+| **dataset** | `string` | ✅ | Dataset name to bind (ADR-0021) |
+| **dimensions** | `string[]` | optional | Dimension names — X/group/split |
+| **values** | `string[]` | ✅ | Measure names — Y (at least one) |
---
diff --git a/examples/app-crm/src/dashboards/pipeline.dashboard.ts b/examples/app-crm/src/dashboards/pipeline.dashboard.ts
index efcd28734..6d142293e 100644
--- a/examples/app-crm/src/dashboards/pipeline.dashboard.ts
+++ b/examples/app-crm/src/dashboards/pipeline.dashboard.ts
@@ -34,9 +34,6 @@ export const PipelineDashboard: Dashboard = {
type: 'metric',
title: 'Total Pipeline ($)',
description: 'Sum of opportunity amounts across open stages.',
- object: 'crm_opportunity',
- aggregate: 'sum',
- valueField: 'amount',
filter: { stage: { $nin: ['closed_won', 'closed_lost'] } },
dataset: 'opportunity_metrics',
values: ['total_amount'],
@@ -48,9 +45,6 @@ export const PipelineDashboard: Dashboard = {
type: 'metric',
title: 'Won This Quarter',
description: 'Revenue closed-won in the current quarter, compared to the previous quarter.',
- object: 'crm_opportunity',
- aggregate: 'sum',
- valueField: 'amount',
filter: {
stage: 'closed_won',
close_date: {
@@ -69,9 +63,6 @@ export const PipelineDashboard: Dashboard = {
type: 'metric',
title: 'Avg Deal Size (YoY)',
description: 'Average won-deal value this year vs the same window last year.',
- object: 'crm_opportunity',
- aggregate: 'avg',
- valueField: 'amount',
filter: {
stage: 'closed_won',
close_date: {
@@ -92,10 +83,6 @@ export const PipelineDashboard: Dashboard = {
type: 'line',
title: 'Pipeline Trend (12 months)',
description: 'Opportunity count bucketed by close-month for the last year, with a sliding overlay of the prior year for compareTo.',
- object: 'crm_opportunity',
- aggregate: 'count',
- categoryField: 'close_date',
- categoryGranularity: 'month',
filter: {
close_date: { $gte: '{1_years_ago}', $lte: '{today}' },
},
@@ -110,9 +97,6 @@ export const PipelineDashboard: Dashboard = {
type: 'bar',
title: 'Opportunities by Stage',
description: 'Count grouped by stage with previous-quarter overlay (compareTo).',
- object: 'crm_opportunity',
- aggregate: 'count',
- categoryField: 'stage',
filter: {
close_date: {
$gte: '{current_quarter_start}',
@@ -132,10 +116,6 @@ export const PipelineDashboard: Dashboard = {
type: 'pie',
title: 'Open Pipeline by Stage ($)',
description: 'Open-pipeline revenue split by pipeline stage. Pie/donut/funnel ignore `compareTo`.',
- object: 'crm_opportunity',
- aggregate: 'sum',
- valueField: 'amount',
- categoryField: 'stage',
filter: { stage: { $nin: ['closed_won', 'closed_lost'] } },
dataset: 'opportunity_metrics',
dimensions: ['stage'],
diff --git a/examples/app-crm/src/datasets/opportunity.dataset.ts b/examples/app-crm/src/datasets/opportunity.dataset.ts
index c49d81ba7..c7756926d 100644
--- a/examples/app-crm/src/datasets/opportunity.dataset.ts
+++ b/examples/app-crm/src/datasets/opportunity.dataset.ts
@@ -18,7 +18,9 @@ export const OpportunityDataset = defineDataset({
dimensions: [
{ name: 'stage', label: 'Stage', field: 'stage', type: 'string' },
- { name: 'close_date', label: 'Close Date', field: 'close_date', type: 'date' },
+ // ADR-0021 single-form: the monthly bucketing the trend widget used to carry
+ // as `categoryGranularity: 'month'` now lives on the dimension itself.
+ { name: 'close_date', label: 'Close Date', field: 'close_date', type: 'date', dateGranularity: 'month' },
],
measures: [
diff --git a/examples/app-crm/src/reports/sales-by-stage.report.ts b/examples/app-crm/src/reports/sales-by-stage.report.ts
index 59d98cf4b..2c61cc910 100644
--- a/examples/app-crm/src/reports/sales-by-stage.report.ts
+++ b/examples/app-crm/src/reports/sales-by-stage.report.ts
@@ -15,13 +15,7 @@ export const SalesByStageReport: UI.Report = {
name: 'crm_sales_by_stage',
label: 'Sales by Stage',
description: 'Total opportunity amount grouped by sales stage.',
- objectName: 'crm_opportunity',
type: 'summary',
- columns: [
- { field: 'stage', label: 'Stage' },
- { field: 'amount', label: 'Amount', aggregate: 'sum' },
- ],
- groupingsDown: [{ field: 'stage', sortOrder: 'asc' }],
dataset: 'opportunity_metrics',
rows: ['stage'],
values: ['total_amount'],
diff --git a/examples/app-crm/test/smoke.test.ts b/examples/app-crm/test/smoke.test.ts
index 593255e58..93fec78a3 100644
--- a/examples/app-crm/test/smoke.test.ts
+++ b/examples/app-crm/test/smoke.test.ts
@@ -164,7 +164,9 @@ describe('Pipeline dashboard', () => {
const w: any = byId.get('pipeline_trend_90d');
expect(w.compareTo).toBe('previousYear');
expect(w.type).toBe('line');
- expect(w.categoryGranularity).toBe('month');
+ // ADR-0021 single-form: the date axis is a dataset dimension (its monthly
+ // bucketing lives on the dataset's close_date dimension, not the widget).
+ expect(w.dimensions).toContain('close_date');
});
it('omits compareTo on widgets that do not need it (pie, total)', () => {
@@ -178,9 +180,10 @@ describe('Pipeline dashboard', () => {
expect(w.type).toBe('bar');
});
- it('widgets bind to the opportunity object', () => {
+ it('widgets bind to the opportunity dataset', () => {
+ // ADR-0021 single-form: widgets reference the semantic dataset, not a raw object.
for (const w of PipelineDashboard.widgets) {
- expect((w as any).object).toBe('crm_opportunity');
+ expect((w as any).dataset).toBe('opportunity_metrics');
}
});
diff --git a/examples/app-showcase/src/dashboards/chart-gallery.dashboard.ts b/examples/app-showcase/src/dashboards/chart-gallery.dashboard.ts
index 44354a940..451d2022b 100644
--- a/examples/app-showcase/src/dashboards/chart-gallery.dashboard.ts
+++ b/examples/app-showcase/src/dashboards/chart-gallery.dashboard.ts
@@ -29,48 +29,48 @@ export const ChartGalleryDashboard: Dashboard = {
columns: 12,
widgets: [
// ── Performance / KPI ────────────────────────────────────────────────
- { id: 'kpi_total_tasks', type: 'metric', title: 'Total Tasks', object: task, aggregate: 'count', dataset: taskDs, values: ['task_count'], layout: { x: 0, y: 0, w: 3, h: 2 } },
- { id: 'kpi_open_tasks', type: 'kpi', title: 'Open Tasks', object: task, aggregate: 'count', filter: { done: false }, dataset: taskDs, values: ['task_count'], layout: { x: 3, y: 0, w: 3, h: 2 } },
- { id: 'gauge_progress', type: 'gauge', title: 'Avg Progress', object: task, aggregate: 'avg', valueField: 'progress', dataset: taskDs, values: ['avg_progress'], layout: { x: 6, y: 0, w: 3, h: 2 } },
- { id: 'bullet_budget', type: 'bullet', title: 'Budget vs Spend', object: project, aggregate: 'sum', valueField: 'spent', dataset: projectDs, values: ['spent_sum'], layout: { x: 9, y: 0, w: 3, h: 2 } },
+ { id: 'kpi_total_tasks', type: 'metric', title: 'Total Tasks', dataset: taskDs, values: ['task_count'], layout: { x: 0, y: 0, w: 3, h: 2 } },
+ { id: 'kpi_open_tasks', type: 'kpi', title: 'Open Tasks', filter: { done: false }, dataset: taskDs, values: ['task_count'], layout: { x: 3, y: 0, w: 3, h: 2 } },
+ { id: 'gauge_progress', type: 'gauge', title: 'Avg Progress', dataset: taskDs, values: ['avg_progress'], layout: { x: 6, y: 0, w: 3, h: 2 } },
+ { id: 'bullet_budget', type: 'bullet', title: 'Budget vs Spend', dataset: projectDs, values: ['spent_sum'], layout: { x: 9, y: 0, w: 3, h: 2 } },
// ── Comparison ───────────────────────────────────────────────────────
- { id: 'bar_by_status', type: 'bar', title: 'Tasks by Status', object: task, aggregate: 'count', categoryField: 'status', dataset: taskDs, dimensions: ['status'], values: ['task_count'], layout: { x: 0, y: 2, w: 4, h: 4 } },
- { id: 'column_by_priority', type: 'column', title: 'Tasks by Priority', object: task, aggregate: 'count', categoryField: 'priority', dataset: taskDs, dimensions: ['priority'], values: ['task_count'], layout: { x: 4, y: 2, w: 4, h: 4 } },
- { id: 'hbar_hours', type: 'horizontal-bar', title: 'Hours by Status', object: task, aggregate: 'sum', valueField: 'estimate_hours', categoryField: 'status', dataset: taskDs, dimensions: ['status'], values: ['est_hours'], layout: { x: 8, y: 2, w: 4, h: 4 } },
- { id: 'stacked_bar', type: 'stacked-bar', title: 'Status × Priority', object: task, aggregate: 'count', categoryField: 'status', dataset: taskDs, dimensions: ['status'], values: ['task_count'], layout: { x: 0, y: 6, w: 4, h: 4 } },
- { id: 'grouped_bar', type: 'grouped-bar', title: 'Grouped Status', object: task, aggregate: 'count', categoryField: 'status', dataset: taskDs, dimensions: ['status'], values: ['task_count'], layout: { x: 4, y: 6, w: 4, h: 4 } },
+ { id: 'bar_by_status', type: 'bar', title: 'Tasks by Status', dataset: taskDs, dimensions: ['status'], values: ['task_count'], layout: { x: 0, y: 2, w: 4, h: 4 } },
+ { id: 'column_by_priority', type: 'column', title: 'Tasks by Priority', dataset: taskDs, dimensions: ['priority'], values: ['task_count'], layout: { x: 4, y: 2, w: 4, h: 4 } },
+ { id: 'hbar_hours', type: 'horizontal-bar', title: 'Hours by Status', dataset: taskDs, dimensions: ['status'], values: ['est_hours'], layout: { x: 8, y: 2, w: 4, h: 4 } },
+ { id: 'stacked_bar', type: 'stacked-bar', title: 'Status × Priority', dataset: taskDs, dimensions: ['status'], values: ['task_count'], layout: { x: 0, y: 6, w: 4, h: 4 } },
+ { id: 'grouped_bar', type: 'grouped-bar', title: 'Grouped Status', dataset: taskDs, dimensions: ['status'], values: ['task_count'], layout: { x: 4, y: 6, w: 4, h: 4 } },
// ── Trend (date-bucketed via timeDimension granularity) ──────────────
- { id: 'line_created', type: 'line', title: 'Tasks Created (monthly)', object: task, aggregate: 'count', categoryField: 'created_at', categoryGranularity: 'month', dataset: taskDs, dimensions: ['created_at'], values: ['task_count'], layout: { x: 8, y: 6, w: 4, h: 4 } },
- { id: 'area_created', type: 'area', title: 'Cumulative (area)', object: task, aggregate: 'count', categoryField: 'created_at', categoryGranularity: 'month', dataset: taskDs, dimensions: ['created_at'], values: ['task_count'], layout: { x: 0, y: 10, w: 4, h: 4 } },
- { id: 'stacked_area', type: 'stacked-area', title: 'Stacked Area', object: task, aggregate: 'count', categoryField: 'created_at', categoryGranularity: 'month', dataset: taskDs, dimensions: ['created_at'], values: ['task_count'], layout: { x: 4, y: 10, w: 4, h: 4 } },
- { id: 'spline_trend', type: 'spline', title: 'Smoothed Trend', object: task, aggregate: 'count', categoryField: 'created_at', categoryGranularity: 'week', dataset: taskDs, dimensions: ['created_at'], values: ['task_count'], layout: { x: 8, y: 10, w: 4, h: 4 } },
+ { id: 'line_created', type: 'line', title: 'Tasks Created (monthly)', dataset: taskDs, dimensions: ['created_at'], values: ['task_count'], layout: { x: 8, y: 6, w: 4, h: 4 } },
+ { id: 'area_created', type: 'area', title: 'Cumulative (area)', dataset: taskDs, dimensions: ['created_at'], values: ['task_count'], layout: { x: 0, y: 10, w: 4, h: 4 } },
+ { id: 'stacked_area', type: 'stacked-area', title: 'Stacked Area', dataset: taskDs, dimensions: ['created_at'], values: ['task_count'], layout: { x: 4, y: 10, w: 4, h: 4 } },
+ { id: 'spline_trend', type: 'spline', title: 'Smoothed Trend', dataset: taskDs, dimensions: ['created_at'], values: ['task_count'], layout: { x: 8, y: 10, w: 4, h: 4 } },
// ── Distribution ─────────────────────────────────────────────────────
- { id: 'pie_status', type: 'pie', title: 'Status Split', object: task, aggregate: 'count', categoryField: 'status', dataset: taskDs, dimensions: ['status'], values: ['task_count'], layout: { x: 0, y: 14, w: 3, h: 4 } },
- { id: 'donut_priority', type: 'donut', title: 'Priority Split', object: task, aggregate: 'count', categoryField: 'priority', dataset: taskDs, dimensions: ['priority'], values: ['task_count'], layout: { x: 3, y: 14, w: 3, h: 4 } },
- { id: 'funnel_pipeline', type: 'funnel', title: 'Task Funnel', object: task, aggregate: 'count', categoryField: 'status', dataset: taskDs, dimensions: ['status'], values: ['task_count'], layout: { x: 6, y: 14, w: 3, h: 4 } },
- { id: 'pyramid_priority', type: 'pyramid', title: 'Priority Pyramid', object: task, aggregate: 'count', categoryField: 'priority', dataset: taskDs, dimensions: ['priority'], values: ['task_count'], layout: { x: 9, y: 14, w: 3, h: 4 } },
+ { id: 'pie_status', type: 'pie', title: 'Status Split', dataset: taskDs, dimensions: ['status'], values: ['task_count'], layout: { x: 0, y: 14, w: 3, h: 4 } },
+ { id: 'donut_priority', type: 'donut', title: 'Priority Split', dataset: taskDs, dimensions: ['priority'], values: ['task_count'], layout: { x: 3, y: 14, w: 3, h: 4 } },
+ { id: 'funnel_pipeline', type: 'funnel', title: 'Task Funnel', dataset: taskDs, dimensions: ['status'], values: ['task_count'], layout: { x: 6, y: 14, w: 3, h: 4 } },
+ { id: 'pyramid_priority', type: 'pyramid', title: 'Priority Pyramid', dataset: taskDs, dimensions: ['priority'], values: ['task_count'], layout: { x: 9, y: 14, w: 3, h: 4 } },
// ── Relationship ─────────────────────────────────────────────────────
- { id: 'scatter_estimate', type: 'scatter', title: 'Estimate vs Progress', object: task, aggregate: 'avg', valueField: 'estimate_hours', categoryField: 'progress', dataset: taskDs, dimensions: ['progress'], values: ['avg_estimate'], layout: { x: 0, y: 18, w: 4, h: 4 } },
- { id: 'bubble_budget', type: 'bubble', title: 'Budget Bubble', object: project, aggregate: 'sum', valueField: 'budget', categoryField: 'account', dataset: projectDs, dimensions: ['account'], values: ['budget_sum'], layout: { x: 4, y: 18, w: 4, h: 4 } },
+ { id: 'scatter_estimate', type: 'scatter', title: 'Estimate vs Progress', dataset: taskDs, dimensions: ['progress'], values: ['avg_estimate'], layout: { x: 0, y: 18, w: 4, h: 4 } },
+ { id: 'bubble_budget', type: 'bubble', title: 'Budget Bubble', dataset: projectDs, dimensions: ['account'], values: ['budget_sum'], layout: { x: 4, y: 18, w: 4, h: 4 } },
// ── Composition ──────────────────────────────────────────────────────
- { id: 'treemap_hours', type: 'treemap', title: 'Hours Treemap', object: task, aggregate: 'sum', valueField: 'estimate_hours', categoryField: 'status', dataset: taskDs, dimensions: ['status'], values: ['est_hours'], layout: { x: 8, y: 18, w: 4, h: 4 } },
- { id: 'sankey_flow', type: 'sankey', title: 'Status Flow (Sankey)', object: task, aggregate: 'count', categoryField: 'status', dataset: taskDs, dimensions: ['status'], values: ['task_count'], layout: { x: 0, y: 22, w: 4, h: 4 } },
- { id: 'radar_priority', type: 'radar', title: 'Priority Radar', object: task, aggregate: 'count', categoryField: 'priority', dataset: taskDs, dimensions: ['priority'], values: ['task_count'], layout: { x: 4, y: 22, w: 4, h: 4 } },
+ { id: 'treemap_hours', type: 'treemap', title: 'Hours Treemap', dataset: taskDs, dimensions: ['status'], values: ['est_hours'], layout: { x: 8, y: 18, w: 4, h: 4 } },
+ { id: 'sankey_flow', type: 'sankey', title: 'Status Flow (Sankey)', dataset: taskDs, dimensions: ['status'], values: ['task_count'], layout: { x: 0, y: 22, w: 4, h: 4 } },
+ { id: 'radar_priority', type: 'radar', title: 'Priority Radar', dataset: taskDs, dimensions: ['priority'], values: ['task_count'], layout: { x: 4, y: 22, w: 4, h: 4 } },
// ── Performance ──────────────────────────────────────────────────────
- { id: 'solid_gauge', type: 'solid-gauge', title: 'Solid Gauge', object: task, aggregate: 'avg', valueField: 'progress', dataset: taskDs, values: ['avg_progress'], layout: { x: 8, y: 22, w: 4, h: 4 } },
+ { id: 'solid_gauge', type: 'solid-gauge', title: 'Solid Gauge', dataset: taskDs, values: ['avg_progress'], layout: { x: 8, y: 22, w: 4, h: 4 } },
// ── Comparison / trend variants ──────────────────────────────────────
- { id: 'bipolar_bar', type: 'bi-polar-bar', title: 'Bi-polar Bar', object: task, aggregate: 'count', categoryField: 'status', dataset: taskDs, dimensions: ['status'], values: ['task_count'], layout: { x: 0, y: 26, w: 6, h: 4 } },
- { id: 'step_line', type: 'step-line', title: 'Step Line', object: task, aggregate: 'count', categoryField: 'created_at', categoryGranularity: 'week', dataset: taskDs, dimensions: ['created_at'], values: ['task_count'], layout: { x: 6, y: 26, w: 6, h: 4 } },
+ { id: 'bipolar_bar', type: 'bi-polar-bar', title: 'Bi-polar Bar', dataset: taskDs, dimensions: ['status'], values: ['task_count'], layout: { x: 0, y: 26, w: 6, h: 4 } },
+ { id: 'step_line', type: 'step-line', title: 'Step Line', dataset: taskDs, dimensions: ['created_at'], values: ['task_count'], layout: { x: 6, y: 26, w: 6, h: 4 } },
// ── Tabular ──────────────────────────────────────────────────────────
- { id: 'table_projects', type: 'table', title: 'Projects Table', object: project, aggregate: 'count', dataset: projectDs, values: ['project_count'], layout: { x: 0, y: 30, w: 6, h: 4 } },
- { id: 'pivot_tasks', type: 'pivot', title: 'Tasks Pivot', object: task, aggregate: 'count', categoryField: 'status', dataset: taskDs, dimensions: ['status'], values: ['task_count'], layout: { x: 6, y: 30, w: 6, h: 4 } },
+ { id: 'table_projects', type: 'table', title: 'Projects Table', dataset: projectDs, values: ['project_count'], layout: { x: 0, y: 30, w: 6, h: 4 } },
+ { id: 'pivot_tasks', type: 'pivot', title: 'Tasks Pivot', dataset: taskDs, dimensions: ['status'], values: ['task_count'], layout: { x: 6, y: 30, w: 6, h: 4 } },
],
};
diff --git a/examples/app-showcase/src/reports/index.ts b/examples/app-showcase/src/reports/index.ts
index 0052ebe56..d50ca71e0 100644
--- a/examples/app-showcase/src/reports/index.ts
+++ b/examples/app-showcase/src/reports/index.ts
@@ -14,13 +14,7 @@ export const HoursByStatusReport: Report = {
name: 'showcase_hours_by_status',
label: 'Hours by Status (Summary)',
description: 'Estimated hours grouped by task status.',
- objectName: task,
type: 'summary',
- columns: [
- { field: 'status', label: 'Status' },
- { field: 'estimate_hours', label: 'Hours', aggregate: 'sum' },
- ],
- groupingsDown: [{ field: 'status', sortOrder: 'asc' }],
// ADR-0021 Phase 2 — dataset binding (dual-form).
dataset: 'showcase_task_metrics',
rows: ['status'],
@@ -32,11 +26,7 @@ export const StatusPriorityMatrixReport: Report = {
name: 'showcase_status_priority_matrix',
label: 'Status × Priority (Matrix)',
description: 'Task counts cross-tabulated by status and priority.',
- objectName: task,
type: 'matrix',
- columns: [{ field: 'estimate_hours', label: 'Hours', aggregate: 'sum' }],
- groupingsDown: [{ field: 'status', sortOrder: 'asc' }],
- groupingsAcross: [{ field: 'priority', sortOrder: 'asc' }],
// ADR-0021 Phase 2 — dataset binding (dual-form). Matrix flattens rows+across
// into `rows` for now (cell values identical); across-dimension is a follow-up.
dataset: 'showcase_task_metrics',
@@ -49,35 +39,28 @@ export const TaskOverviewReport: Report = {
name: 'showcase_task_overview',
label: 'Task Overview (Joined)',
description: 'Multiple task sub-reports stacked into one joined view.',
- objectName: task,
type: 'joined',
- columns: [],
blocks: [
{
// Analytics block → dataset-bound (dual-form); reconciled by the harness.
name: 'open_block',
label: 'Open Tasks',
type: 'summary',
- objectName: task,
- columns: [{ field: 'estimate_hours', label: 'Hours', aggregate: 'sum' }],
- groupingsDown: [{ field: 'status', sortOrder: 'asc' }],
- filter: { done: false },
dataset: 'showcase_task_metrics',
rows: ['status'],
values: ['est_hours'],
runtimeFilter: { done: false },
},
{
- // Record-list block → stays inline (a ListView/embed in the terminal form).
+ // Single-form: a count of completed tasks (the former record-list detail
+ // moves to a click-through drilldown).
name: 'done_block',
label: 'Completed Tasks',
- type: 'tabular',
- objectName: task,
- columns: [
- { field: 'title', label: 'Title' },
- { field: 'assignee', label: 'Assignee' },
- ],
- filter: { done: true },
+ type: 'summary',
+ dataset: 'showcase_task_metrics',
+ rows: ['status'],
+ values: ['task_count'],
+ runtimeFilter: { done: true },
},
],
};
diff --git a/examples/app-showcase/src/views/project.view.ts b/examples/app-showcase/src/views/project.view.ts
index 68d96bdb7..1e656ab5e 100644
--- a/examples/app-showcase/src/views/project.view.ts
+++ b/examples/app-showcase/src/views/project.view.ts
@@ -34,8 +34,8 @@ export const ProjectViews = defineView({
data,
columns: ['account', 'budget'],
chart: {
- chartType: 'bar', xAxisField: 'account', yAxisFields: ['budget', 'spent'], aggregation: 'sum',
- // ADR-0021 dual-form — bind to the project dataset.
+ chartType: 'bar',
+ // ADR-0021 single-form — bind to the project dataset.
dataset: 'showcase_project_metrics', dimensions: ['account'], values: ['budget_sum', 'spent_sum'],
},
},
diff --git a/examples/app-showcase/src/views/task.view.ts b/examples/app-showcase/src/views/task.view.ts
index 1b5206cf5..83db74568 100644
--- a/examples/app-showcase/src/views/task.view.ts
+++ b/examples/app-showcase/src/views/task.view.ts
@@ -110,7 +110,6 @@ export const TaskViews = defineView({
timeline: {
startDateField: 'created_at',
titleField: 'title',
- groupByField: 'status',
colorField: 'priority',
scale: 'week',
},
@@ -146,10 +145,6 @@ export const TaskViews = defineView({
columns: ['status', 'estimate_hours'],
chart: {
chartType: 'bar',
- xAxisField: 'status',
- yAxisFields: ['estimate_hours'],
- aggregation: 'sum',
- groupByField: 'priority',
// ADR-0021 dual-form — bind to the task dataset.
dataset: 'showcase_task_metrics',
dimensions: ['status', 'priority'],
diff --git a/examples/app-showcase/test/coverage.test.ts b/examples/app-showcase/test/coverage.test.ts
index 0197f010a..3bf2c5842 100644
--- a/examples/app-showcase/test/coverage.test.ts
+++ b/examples/app-showcase/test/coverage.test.ts
@@ -57,7 +57,12 @@ describe('showcase coverage (introspected against the spec)', () => {
});
it('covers every report type', () => {
- const expected = enumValues((ui as Record).ReportType ?? (ui as Record).ReportTypeSchema);
+ // ADR-0021 single-form: `tabular` (a flat record list) is intentionally NOT
+ // demonstrated as a report — a flat list is an object-bound ListView lens
+ // (ADR-0017), not an analytics projection, so the former TaskListReport now
+ // lives on showcase_task as a `tabular` ListView (see src/reports/index.ts).
+ const expected = enumValues((ui as Record).ReportType ?? (ui as Record).ReportTypeSchema)
+ .filter((t) => t !== 'tabular');
const used = new Set();
for (const r of allReports) {
if (r.type) used.add(r.type);
diff --git a/examples/app-showcase/test/seed.test.ts b/examples/app-showcase/test/seed.test.ts
index 7098eb4db..6f5040166 100644
--- a/examples/app-showcase/test/seed.test.ts
+++ b/examples/app-showcase/test/seed.test.ts
@@ -20,7 +20,10 @@ describe('showcase stack', () => {
it('registers UI, automation, security, and AI metadata', () => {
expect((stack.views ?? []).length).toBeGreaterThan(0);
expect((stack.dashboards ?? []).length).toBeGreaterThan(0);
- expect((stack.reports ?? []).length).toBe(4);
+ // ADR-0021 single-form: the former flat `tabular` TaskListReport was
+ // reclassified as a ListView (a flat list is a row lens, not analytics),
+ // leaving 3 dataset-bound analytics reports.
+ expect((stack.reports ?? []).length).toBe(3);
expect((stack.flows ?? []).length).toBeGreaterThan(0);
expect((stack.roles ?? []).length).toBe(3);
expect((stack.agents ?? []).length).toBe(1);
diff --git a/examples/app-todo/src/dashboards/task.dashboard.ts b/examples/app-todo/src/dashboards/task.dashboard.ts
index e0a739610..791f9316d 100644
--- a/examples/app-todo/src/dashboards/task.dashboard.ts
+++ b/examples/app-todo/src/dashboards/task.dashboard.ts
@@ -25,8 +25,6 @@ export const TaskDashboard: Dashboard = {
id: 'total_tasks',
title: 'Total Tasks',
type: 'metric',
- object: 'todo_task',
- aggregate: 'count',
dataset: 'task_metrics',
values: ['task_count'],
layout: { x: 0, y: 0, w: 3, h: 2 },
@@ -36,9 +34,7 @@ export const TaskDashboard: Dashboard = {
id: 'completed_today',
title: 'Completed Today',
type: 'metric',
- object: 'todo_task',
filter: { is_completed: true, completed_date: { $gte: '{today}' } },
- aggregate: 'count',
dataset: 'task_metrics',
values: ['task_count'],
layout: { x: 3, y: 0, w: 3, h: 2 },
@@ -48,9 +44,7 @@ export const TaskDashboard: Dashboard = {
id: 'overdue_tasks',
title: 'Overdue Tasks',
type: 'metric',
- object: 'todo_task',
filter: { is_overdue: true, is_completed: false },
- aggregate: 'count',
dataset: 'task_metrics',
values: ['task_count'],
layout: { x: 6, y: 0, w: 3, h: 2 },
@@ -60,10 +54,7 @@ export const TaskDashboard: Dashboard = {
id: 'completion_rate',
title: 'Completion Rate',
type: 'metric',
- object: 'todo_task',
filter: { created_at: { $gte: '{current_week_start}' } },
- valueField: 'is_completed',
- aggregate: 'count',
dataset: 'task_metrics',
values: ['task_count'],
layout: { x: 9, y: 0, w: 3, h: 2 },
@@ -75,10 +66,7 @@ export const TaskDashboard: Dashboard = {
id: 'tasks_by_status',
title: 'Tasks by Status',
type: 'pie',
- object: 'todo_task',
filter: { is_completed: false },
- categoryField: 'status',
- aggregate: 'count',
dataset: 'task_metrics',
dimensions: ['status'],
values: ['task_count'],
@@ -89,10 +77,7 @@ export const TaskDashboard: Dashboard = {
id: 'tasks_by_priority',
title: 'Tasks by Priority',
type: 'bar',
- object: 'todo_task',
filter: { is_completed: false },
- categoryField: 'priority',
- aggregate: 'count',
dataset: 'task_metrics',
dimensions: ['priority'],
values: ['task_count'],
@@ -105,10 +90,7 @@ export const TaskDashboard: Dashboard = {
id: 'weekly_task_completion',
title: 'Weekly Task Completion',
type: 'line',
- object: 'todo_task',
filter: { is_completed: true, completed_date: { $gte: '{4_weeks_ago}' } },
- categoryField: 'completed_date',
- aggregate: 'count',
dataset: 'task_metrics',
dimensions: ['completed_date'],
values: ['task_count'],
@@ -119,10 +101,7 @@ export const TaskDashboard: Dashboard = {
id: 'tasks_by_category',
title: 'Tasks by Category',
type: 'donut',
- object: 'todo_task',
filter: { is_completed: false },
- categoryField: 'category',
- aggregate: 'count',
dataset: 'task_metrics',
dimensions: ['category'],
values: ['task_count'],
@@ -135,9 +114,7 @@ export const TaskDashboard: Dashboard = {
id: 'overdue_tasks_table',
title: 'Overdue Tasks',
type: 'table',
- object: 'todo_task',
filter: { is_overdue: true, is_completed: false },
- aggregate: 'count',
dataset: 'task_metrics',
values: ['task_count'],
layout: { x: 0, y: 10, w: 6, h: 4 },
@@ -146,9 +123,7 @@ export const TaskDashboard: Dashboard = {
id: 'due_today',
title: 'Due Today',
type: 'table',
- object: 'todo_task',
filter: { due_date: '{today}', is_completed: false },
- aggregate: 'count',
dataset: 'task_metrics',
values: ['task_count'],
layout: { x: 6, y: 10, w: 6, h: 4 },
diff --git a/examples/app-todo/src/reports/task.report.ts b/examples/app-todo/src/reports/task.report.ts
index 3566d7173..32809df87 100644
--- a/examples/app-todo/src/reports/task.report.ts
+++ b/examples/app-todo/src/reports/task.report.ts
@@ -14,15 +14,7 @@ export const TasksByStatusReport: ReportInput = {
name: 'tasks_by_status',
label: 'Tasks by Status',
description: 'Summary of tasks grouped by status',
- objectName: 'todo_task',
type: 'summary',
- columns: [
- { field: 'subject', label: 'Subject' },
- { field: 'priority', label: 'Priority' },
- { field: 'due_date', label: 'Due Date' },
- { field: 'owner', label: 'Assigned To' },
- ],
- groupingsDown: [{ field: 'status', sortOrder: 'asc' }],
dataset: 'task_metrics',
rows: ['status'],
values: ['task_count'],
@@ -33,16 +25,7 @@ export const TasksByPriorityReport: ReportInput = {
name: 'tasks_by_priority',
label: 'Tasks by Priority',
description: 'Summary of tasks grouped by priority level',
- objectName: 'todo_task',
type: 'summary',
- columns: [
- { field: 'subject', label: 'Subject' },
- { field: 'status', label: 'Status' },
- { field: 'due_date', label: 'Due Date' },
- { field: 'category', label: 'Category' },
- ],
- groupingsDown: [{ field: 'priority', sortOrder: 'desc' }],
- filter: { is_completed: false },
dataset: 'task_metrics',
rows: ['priority'],
values: ['task_count'],
@@ -54,18 +37,7 @@ export const TasksByOwnerReport: ReportInput = {
name: 'tasks_by_owner',
label: 'Tasks by Owner',
description: 'Task summary by assignee',
- objectName: 'todo_task',
type: 'summary',
- columns: [
- { field: 'subject', label: 'Subject' },
- { field: 'status', label: 'Status' },
- { field: 'priority', label: 'Priority' },
- { field: 'due_date', label: 'Due Date' },
- { field: 'estimated_hours', label: 'Est. Hours', aggregate: 'sum' },
- { field: 'actual_hours', label: 'Actual Hours', aggregate: 'sum' },
- ],
- groupingsDown: [{ field: 'owner', sortOrder: 'asc' }],
- filter: { is_completed: false },
dataset: 'task_metrics',
rows: ['owner'],
values: ['est_hours', 'actual_hours'],
@@ -82,16 +54,7 @@ export const CompletedTasksReport: ReportInput = {
name: 'completed_tasks',
label: 'Completed Tasks',
description: 'All completed tasks with time tracking',
- objectName: 'todo_task',
type: 'summary',
- columns: [
- { field: 'subject', label: 'Subject' },
- { field: 'completed_date', label: 'Completed Date' },
- { field: 'estimated_hours', label: 'Est. Hours', aggregate: 'sum' },
- { field: 'actual_hours', label: 'Actual Hours', aggregate: 'sum' },
- ],
- groupingsDown: [{ field: 'category', sortOrder: 'asc' }],
- filter: { is_completed: true },
dataset: 'task_metrics',
rows: ['category'],
values: ['est_hours', 'actual_hours'],
@@ -103,15 +66,7 @@ export const TimeTrackingReport: ReportInput = {
name: 'time_tracking',
label: 'Time Tracking Report',
description: 'Estimated vs actual hours analysis',
- objectName: 'todo_task',
type: 'matrix',
- columns: [
- { field: 'estimated_hours', label: 'Estimated Hours', aggregate: 'sum' },
- { field: 'actual_hours', label: 'Actual Hours', aggregate: 'sum' },
- ],
- groupingsDown: [{ field: 'owner', sortOrder: 'asc' }],
- groupingsAcross: [{ field: 'category', sortOrder: 'asc' }],
- filter: { is_completed: true },
// Matrix: the dataset form flattens rows+across into `rows` for now (cell
// values are identical); a dataset-bound `columns`/across dimension is a
// follow-up before single-form convergence.
diff --git a/packages/core/src/lite-kernel.test.ts b/packages/core/src/lite-kernel.test.ts
index 17f70cfca..4db4140a5 100644
--- a/packages/core/src/lite-kernel.test.ts
+++ b/packages/core/src/lite-kernel.test.ts
@@ -1,7 +1,13 @@
import { describe, it, expect, beforeEach } from 'vitest';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
import { LiteKernel } from './lite-kernel';
import type { Plugin } from './types';
+// Unique per-process temp path — a shared hardcoded /tmp file can be owned by a
+// different user (CI / multi-user hosts), causing EACCES under concurrent runs.
+const TEST_LOG_FILE = join(tmpdir(), `objectstack-test-kernel-${process.pid}.log`);
+
describe('LiteKernel with Configurable Logger', () => {
let kernel: LiteKernel;
@@ -35,7 +41,7 @@ describe('LiteKernel with Configurable Logger', () => {
logger: {
level: 'info',
format: 'json',
- file: '/tmp/test-kernel.log'
+ file: TEST_LOG_FILE
}
});
diff --git a/packages/objectql/src/metadata-validation-sweep.test.ts b/packages/objectql/src/metadata-validation-sweep.test.ts
index fb42bfdf2..59caa80ee 100644
--- a/packages/objectql/src/metadata-validation-sweep.test.ts
+++ b/packages/objectql/src/metadata-validation-sweep.test.ts
@@ -126,14 +126,17 @@ const FIXTURES: Record = {
invalidatedField: 'name',
},
report: {
+ // ADR-0021 single-form: a report binds a dataset + selects values by name.
valid: {
name: 'sweep_report',
label: 'Sweep',
- objectName: 'sweep_account',
- columns: [{ field: 'amount', label: 'Amount' }],
+ type: 'summary',
+ dataset: 'sweep_account_metrics',
+ rows: ['stage'],
+ values: ['amount_sum'],
},
invalid: { name: 'sweep_report', label: 'Sweep' },
- invalidatedField: 'objectName',
+ invalidatedField: 'dataset',
},
flow: {
valid: {
diff --git a/packages/objectql/src/overlay-precedence.test.ts b/packages/objectql/src/overlay-precedence.test.ts
index 2d2eccce0..d099d666f 100644
--- a/packages/objectql/src/overlay-precedence.test.ts
+++ b/packages/objectql/src/overlay-precedence.test.ts
@@ -60,8 +60,11 @@ const validDashboard = {
const validReport = {
name: 'monthly_revenue',
label: 'Monthly Revenue',
- objectName: 'invoice',
- columns: [{ field: 'amount', label: 'Amount' }],
+ // ADR-0021 single-form: a report binds a dataset + selects values by name.
+ type: 'summary',
+ dataset: 'invoice_metrics',
+ rows: ['month'],
+ values: ['amount_sum'],
};
function makeProtocol(opts: { environmentId?: string } = {}) {
diff --git a/packages/objectql/src/protocol-meta.test.ts b/packages/objectql/src/protocol-meta.test.ts
index 975b6c330..e20bd7c21 100644
--- a/packages/objectql/src/protocol-meta.test.ts
+++ b/packages/objectql/src/protocol-meta.test.ts
@@ -337,9 +337,9 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => {
id: 'pipeline',
title: 'Pipeline',
type: 'metric',
- object: 'opportunity',
- valueField: 'amount',
- aggregate: 'sum',
+ // ADR-0021 single-form: widgets bind a dataset + select values by name.
+ dataset: 'opportunity_metrics',
+ values: ['amount_sum'],
layout: { x: 0, y: 0, w: 3, h: 2 },
}],
};
diff --git a/packages/platform-objects/src/apps/dashboards/system_overview.dashboard.ts b/packages/platform-objects/src/apps/dashboards/system_overview.dashboard.ts
index 1b7c25e8e..f1a096dc1 100644
--- a/packages/platform-objects/src/apps/dashboards/system_overview.dashboard.ts
+++ b/packages/platform-objects/src/apps/dashboards/system_overview.dashboard.ts
@@ -33,9 +33,7 @@ export const SystemOverviewDashboard = Dashboard.create({
dataset: 'sys_user_metrics', values: ['user_count'],
title: 'Total Users',
type: 'metric',
- object: 'sys_user',
layout: { x: 0, y: 0, w: 3, h: 2 },
- aggregate: 'count',
colorVariant: 'teal',
description: 'Total registered users in the system',
},
@@ -44,9 +42,7 @@ export const SystemOverviewDashboard = Dashboard.create({
dataset: 'sys_organization_metrics', values: ['org_count'],
title: 'Organizations',
type: 'metric',
- object: 'sys_organization',
layout: { x: 3, y: 0, w: 3, h: 2 },
- aggregate: 'count',
colorVariant: 'orange',
description: 'Total organizations on the platform',
},
@@ -55,9 +51,7 @@ export const SystemOverviewDashboard = Dashboard.create({
dataset: 'sys_session_metrics', values: ['session_count'],
title: 'Active Sessions',
type: 'metric',
- object: 'sys_session',
layout: { x: 6, y: 0, w: 3, h: 2 },
- aggregate: 'count',
colorVariant: 'blue',
description: 'Number of currently active user sessions',
},
@@ -66,13 +60,11 @@ export const SystemOverviewDashboard = Dashboard.create({
dataset: 'sys_package_installation_metrics', values: ['package_count'],
title: 'Packages Installed',
type: 'metric',
- object: 'sys_package_installation',
// Cloud-only object — only registered when service-tenant is loaded.
// Hide this widget gracefully in single-environment runtimes.
requiresObject: 'sys_package_installation',
layout: { x: 9, y: 0, w: 3, h: 2 },
filter: { status: 'installed' },
- aggregate: 'count',
colorVariant: 'success',
description: 'Active package installations across projects',
},
@@ -87,10 +79,8 @@ export const SystemOverviewDashboard = Dashboard.create({
dataset: 'sys_audit_log_metrics', values: ['event_count'],
title: 'Login Events',
type: 'metric',
- object: 'sys_audit_log',
layout: { x: 0, y: 2, w: 4, h: 2 },
filter: { action: 'login' },
- aggregate: 'count',
colorVariant: 'blue',
description: 'Authentication events recorded by the audit log',
},
@@ -99,10 +89,8 @@ export const SystemOverviewDashboard = Dashboard.create({
dataset: 'sys_audit_log_metrics', values: ['event_count'],
title: 'Permission Changes',
type: 'metric',
- object: 'sys_audit_log',
layout: { x: 4, y: 2, w: 4, h: 2 },
filter: { action: 'permission_change' },
- aggregate: 'count',
colorVariant: 'warning',
description: 'Recent permission and role modifications',
},
@@ -111,10 +99,8 @@ export const SystemOverviewDashboard = Dashboard.create({
dataset: 'sys_audit_log_metrics', values: ['event_count'],
title: 'Config Changes',
type: 'metric',
- object: 'sys_audit_log',
layout: { x: 8, y: 2, w: 4, h: 2 },
filter: { action: 'config_change' },
- aggregate: 'count',
colorVariant: 'blue',
description: 'System configuration modifications',
},
@@ -131,10 +117,7 @@ export const SystemOverviewDashboard = Dashboard.create({
title: 'Audit Events by Action',
description: 'Distribution of audit events by action type',
type: 'pie',
- object: 'sys_audit_log',
layout: { x: 0, y: 4, w: 6, h: 4 },
- categoryField: 'action',
- aggregate: 'count',
},
{
id: 'widget_events_by_user',
@@ -142,29 +125,22 @@ export const SystemOverviewDashboard = Dashboard.create({
title: 'Events by User',
description: 'Activity distribution across users',
type: 'bar',
- object: 'sys_audit_log',
layout: { x: 6, y: 4, w: 6, h: 4 },
- categoryField: 'user_id',
- aggregate: 'count',
},
- // ── Row 4: Recent audit events table ────────────────────────────
- // `type: 'table'` renders the underlying rows directly, so this
- // panel actually shows the latest events instead of just repeating
- // the total-count metric. `valueField`/`aggregate` are intentionally
- // omitted — table widgets pull raw records.
+ // ── Row 4: Audit events by action ───────────────────────────────
+ // ADR-0021 single-form: a dataset-bound breakdown of events by action.
+ // (The raw recent-events record list belongs in a ListView on
+ // sys_audit_log — a row-level lens, not a dashboard analytics widget.)
{
id: 'widget_recent_events',
- title: 'Recent Audit Events',
- description: 'Latest platform events (login, permission, config, …)',
+ title: 'Audit Events by Action',
+ description: 'Event volume grouped by action (login, permission, config, …)',
type: 'table',
- object: 'sys_audit_log',
+ dataset: 'sys_audit_log_metrics',
+ dimensions: ['action'],
+ values: ['event_count'],
layout: { x: 0, y: 8, w: 12, h: 4 },
- options: {
- columns: ['created_at', 'user_id', 'action', 'object_name', 'record_id'],
- sort: [{ field: 'created_at', order: 'desc' }],
- pageSize: 20,
- },
},
],
globalFilters: [
diff --git a/packages/spec/src/stack.test.ts b/packages/spec/src/stack.test.ts
index 1424c91f7..10e2db4eb 100644
--- a/packages/spec/src/stack.test.ts
+++ b/packages/spec/src/stack.test.ts
@@ -1277,13 +1277,22 @@ describe('defineStack - Example-Level Strict Validation', () => {
],
},
],
+ datasets: [
+ {
+ name: 'todo_task_metrics',
+ label: 'Task Metrics',
+ object: 'todo_task',
+ dimensions: [{ name: 'status', field: 'status', type: 'string' as const }],
+ measures: [{ name: 'task_count', aggregate: 'count' as const }],
+ },
+ ],
dashboards: [
{
name: 'task_overview',
label: 'Task Overview',
widgets: [
- { id: 'total_tasks', title: 'Total Tasks', type: 'metric', object: 'todo_task', aggregate: 'count', layout: { x: 0, y: 0, w: 3, h: 2 } },
- { id: 'by_status', title: 'By Status', type: 'pie', object: 'todo_task', categoryField: 'status', aggregate: 'count', layout: { x: 3, y: 0, w: 6, h: 4 } },
+ { id: 'total_tasks', title: 'Total Tasks', type: 'metric', dataset: 'todo_task_metrics', values: ['task_count'], layout: { x: 0, y: 0, w: 3, h: 2 } },
+ { id: 'by_status', title: 'By Status', type: 'pie', dataset: 'todo_task_metrics', dimensions: ['status'], values: ['task_count'], layout: { x: 3, y: 0, w: 6, h: 4 } },
],
},
],
@@ -1345,17 +1354,23 @@ describe('defineStack - Example-Level Strict Validation', () => {
],
},
],
+ datasets: [
+ {
+ name: 'crm_opportunity_metrics',
+ label: 'Opportunity Metrics',
+ object: 'crm_opportunity',
+ dimensions: [{ name: 'stage', field: 'stage', type: 'string' as const }],
+ measures: [{ name: 'amount_sum', aggregate: 'sum' as const, field: 'amount' }],
+ },
+ ],
reports: [
{
name: 'pipeline_report',
label: 'Pipeline Report',
- objectName: 'crm_opportunity',
type: 'summary' as const,
- columns: [
- { field: 'name' },
- { field: 'amount', aggregate: 'sum' as const },
- ],
- groupingsDown: [{ field: 'stage' }],
+ dataset: 'crm_opportunity_metrics',
+ rows: ['stage'],
+ values: ['amount_sum'],
},
],
dashboards: [
@@ -1363,7 +1378,7 @@ describe('defineStack - Example-Level Strict Validation', () => {
name: 'sales_overview',
label: 'Sales Overview',
widgets: [
- { id: 'pipeline_value', title: 'Pipeline Value', type: 'metric', object: 'crm_opportunity', valueField: 'amount', aggregate: 'sum', layout: { x: 0, y: 0, w: 4, h: 2 } },
+ { id: 'pipeline_value', title: 'Pipeline Value', type: 'metric', dataset: 'crm_opportunity_metrics', values: ['amount_sum'], layout: { x: 0, y: 0, w: 4, h: 2 } },
],
},
],
diff --git a/packages/spec/src/ui/dashboard.test.ts b/packages/spec/src/ui/dashboard.test.ts
index ce1827ef1..4dd977f5b 100644
--- a/packages/spec/src/ui/dashboard.test.ts
+++ b/packages/spec/src/ui/dashboard.test.ts
@@ -1,1848 +1,123 @@
+// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
+
import { describe, it, expect } from 'vitest';
import {
DashboardSchema,
DashboardWidgetSchema,
DashboardHeaderSchema,
DashboardHeaderActionSchema,
- WidgetMeasureSchema,
Dashboard,
WidgetColorVariantSchema,
WidgetActionTypeSchema,
GlobalFilterSchema,
GlobalFilterOptionsFromSchema,
- type Dashboard as DashboardType,
- type DashboardWidget,
- type DashboardHeader,
- type DashboardHeaderAction,
- type WidgetMeasure,
- type GlobalFilter,
- type GlobalFilterOptionsFrom,
} from './dashboard.zod';
-import { ChartTypeSchema } from './chart.zod';
-
-describe('ChartTypeSchema', () => {
- it('should accept all chart types', () => {
- const types = ['metric', 'bar', 'line', 'pie', 'funnel', 'table', 'bubble', 'gauge', 'treemap', 'pivot', 'grouped-bar'];
-
- types.forEach(type => {
- expect(() => ChartTypeSchema.parse(type)).not.toThrow();
- });
- });
-
- it('should reject invalid chart types', () => {
- expect(() => ChartTypeSchema.parse('invalid-chart')).toThrow();
- expect(() => ChartTypeSchema.parse('unknown')).toThrow();
- });
-});
-
-describe('DashboardWidgetSchema', () => {
- it('should accept minimal widget with layout', () => {
- const widget: DashboardWidget = {
- id: 'widget_1',
- layout: {
- x: 0,
- y: 0,
- w: 4,
- h: 2,
- },
- };
-
- const result = DashboardWidgetSchema.parse(widget);
- expect(result.type).toBe('metric');
- expect(result.aggregate).toBe('count');
- });
- it('should accept metric widget', () => {
- const widget: DashboardWidget = {
- id: 'total_opportunities',
- title: 'Total Opportunities',
- type: 'metric',
- object: 'opportunity',
- aggregate: 'count',
+/**
+ * ADR-0021 single-form: every dashboard widget binds a `dataset` and selects
+ * `dimensions`/`values` BY NAME. The legacy inline `object` + `categoryField` +
+ * `valueField` + `aggregate` query was removed in the cutover, so these tests
+ * cover the dataset shape and the surviving presentation sub-schemas.
+ */
+describe('DashboardWidgetSchema (dataset-bound)', () => {
+ it('accepts a KPI/metric widget (dataset + values, no dimensions)', () => {
+ const w = DashboardWidgetSchema.parse({
+ id: 'total_pipeline', type: 'metric', dataset: 'sales', values: ['revenue'],
layout: { x: 0, y: 0, w: 3, h: 2 },
- };
-
- expect(() => DashboardWidgetSchema.parse(widget)).not.toThrow();
- });
-
- it('should accept bar chart widget', () => {
- const widget: DashboardWidget = {
- id: 'opportunities_by_stage',
- title: 'Opportunities by Stage',
- type: 'bar',
- object: 'opportunity',
- categoryField: 'stage',
- valueField: 'amount',
- aggregate: 'sum',
- layout: { x: 3, y: 0, w: 6, h: 4 },
- };
-
- expect(() => DashboardWidgetSchema.parse(widget)).not.toThrow();
- });
-
- it('should accept line chart widget', () => {
- const widget: DashboardWidget = {
- id: 'revenue_trend',
- title: 'Revenue Trend',
- type: 'line',
- object: 'opportunity',
- categoryField: 'close_date',
- valueField: 'amount',
- aggregate: 'sum',
- layout: { x: 0, y: 2, w: 12, h: 4 },
- };
-
- expect(() => DashboardWidgetSchema.parse(widget)).not.toThrow();
- });
-
- it('should accept pie chart widget', () => {
- const widget: DashboardWidget = {
- id: 'opportunities_by_type',
- title: 'Opportunities by Type',
- type: 'pie',
- object: 'opportunity',
- categoryField: 'type',
- aggregate: 'count',
- layout: { x: 9, y: 0, w: 3, h: 3 },
- };
-
- expect(() => DashboardWidgetSchema.parse(widget)).not.toThrow();
- });
-
- it('should accept pivot widget', () => {
- const widget: DashboardWidget = {
- id: 'revenue_by_region_product',
- title: 'Revenue by Region × Product',
- type: 'pivot',
- object: 'order',
- categoryField: 'region',
- measures: [
- { valueField: 'revenue', aggregate: 'sum', label: 'Total Revenue', format: '$0,0' },
- { valueField: 'quantity', aggregate: 'sum', label: 'Units Sold' },
- ],
- layout: { x: 0, y: 0, w: 12, h: 6 },
- };
-
- const result = DashboardWidgetSchema.parse(widget);
- expect(result.type).toBe('pivot');
- expect(result.measures).toHaveLength(2);
- });
-
- it('should accept funnel widget', () => {
- const widget: DashboardWidget = {
- id: 'sales_funnel',
- title: 'Sales Funnel',
- type: 'funnel',
- object: 'opportunity',
- categoryField: 'stage',
- valueField: 'amount',
- aggregate: 'sum',
- layout: { x: 0, y: 0, w: 6, h: 4 },
- };
-
- const result = DashboardWidgetSchema.parse(widget);
- expect(result.type).toBe('funnel');
- });
-
- it('should accept grouped-bar widget', () => {
- const widget: DashboardWidget = {
- id: 'quarterly_revenue_by_region',
- title: 'Quarterly Revenue by Region',
- type: 'grouped-bar',
- object: 'order',
- categoryField: 'quarter',
- valueField: 'revenue',
- aggregate: 'sum',
- layout: { x: 0, y: 0, w: 12, h: 4 },
- };
-
- const result = DashboardWidgetSchema.parse(widget);
- expect(result.type).toBe('grouped-bar');
- });
-
- it('should accept table widget', () => {
- const widget: DashboardWidget = {
- id: 'top_accounts',
- title: 'Top Accounts',
- type: 'table',
- object: 'account',
- filter: { annual_revenue: { $gt: 1000000 } }, // Modern MongoDB-style filter
- layout: { x: 0, y: 6, w: 12, h: 4 },
- };
-
- expect(() => DashboardWidgetSchema.parse(widget)).not.toThrow();
- });
-
- it('should accept widget with filter', () => {
- const widget: DashboardWidget = {
- id: 'active_opportunities',
- title: 'Active Opportunities',
- type: 'metric',
- object: 'opportunity',
- filter: { status: 'active' },
- aggregate: 'count',
- layout: { x: 0, y: 0, w: 3, h: 2 },
- };
-
- expect(() => DashboardWidgetSchema.parse(widget)).not.toThrow();
- });
-
- it('should accept all aggregate functions', () => {
- const aggregates = ['count', 'sum', 'avg', 'min', 'max'] as const;
-
- aggregates.forEach(aggregate => {
- const widget: DashboardWidget = {
- id: 'aggregate_test',
- type: 'metric',
- aggregate,
- layout: { x: 0, y: 0, w: 3, h: 2 },
- };
- expect(() => DashboardWidgetSchema.parse(widget)).not.toThrow();
});
+ expect(w.dataset).toBe('sales');
+ expect(w.values).toEqual(['revenue']);
});
- it('should accept widget with custom options', () => {
- const widget: DashboardWidget = {
- id: 'custom_chart',
- title: 'Custom Chart',
- type: 'bar',
- object: 'opportunity',
- categoryField: 'stage',
- valueField: 'amount',
+ it('accepts a chart widget with dimensions', () => {
+ const w = DashboardWidgetSchema.parse({
+ id: 'by_stage', type: 'bar', dataset: 'sales', dimensions: ['stage'], values: ['revenue'],
layout: { x: 0, y: 0, w: 6, h: 4 },
- options: {
- colors: ['#FF6384', '#36A2EB', '#FFCE56'],
- showLegend: true,
- orientation: 'horizontal',
- },
- };
-
- expect(() => DashboardWidgetSchema.parse(widget)).not.toThrow();
- });
-
- it('should accept metric widget for text/markdown content', () => {
- const widget: DashboardWidget = {
- id: 'welcome_message',
- title: 'Welcome Message',
- type: 'metric',
- layout: { x: 0, y: 0, w: 12, h: 2 },
- options: {
- content: '# Welcome to Sales Dashboard\n\nThis dashboard shows...',
- },
- };
-
- expect(() => DashboardWidgetSchema.parse(widget)).not.toThrow();
- });
-});
-
-describe('DashboardSchema', () => {
- it('should accept minimal dashboard', () => {
- const dashboard: DashboardType = {
- name: 'sales_overview',
- label: 'Sales Overview',
- widgets: [],
- };
-
- expect(() => DashboardSchema.parse(dashboard)).not.toThrow();
- });
-
- it('should enforce snake_case for dashboard name', () => {
- const validNames = ['sales_dashboard', 'revenue_overview', 'my_metrics'];
- validNames.forEach(name => {
- expect(() => DashboardSchema.parse({ name, label: 'Test', widgets: [] })).not.toThrow();
- });
-
- const invalidNames = ['salesDashboard', 'Sales-Dashboard', '123dashboard', '_internal'];
- invalidNames.forEach(name => {
- expect(() => DashboardSchema.parse({ name, label: 'Test', widgets: [] })).toThrow();
- });
- });
-
- it('should accept dashboard with description', () => {
- const dashboard: DashboardType = {
- name: 'executive_dashboard',
- label: 'Executive Dashboard',
- description: 'High-level metrics for executive team',
- widgets: [],
- };
-
- expect(() => DashboardSchema.parse(dashboard)).not.toThrow();
- });
-
- describe('Real-World Dashboard Examples', () => {
- it('should accept sales pipeline dashboard', () => {
- const salesDashboard: DashboardType = {
- name: 'sales_pipeline',
- label: 'Sales Pipeline',
- description: 'Overview of sales opportunities and pipeline health',
- widgets: [
- {
- id: 'total_pipeline_value',
- title: 'Total Pipeline Value',
- type: 'metric',
- object: 'opportunity',
- valueField: 'amount',
- aggregate: 'sum',
- filter: { is_closed: false },
- layout: { x: 0, y: 0, w: 3, h: 2 },
- },
- {
- id: 'open_opportunities',
- title: 'Open Opportunities',
- type: 'metric',
- object: 'opportunity',
- aggregate: 'count',
- filter: { is_closed: false },
- layout: { x: 3, y: 0, w: 3, h: 2 },
- },
- {
- id: 'win_rate',
- title: 'Win Rate',
- type: 'metric',
- object: 'opportunity',
- layout: { x: 6, y: 0, w: 3, h: 2 },
- options: {
- formula: 'COUNT(status="won") / COUNT(is_closed=true) * 100',
- suffix: '%',
- },
- },
- {
- id: 'avg_deal_size',
- title: 'Avg Deal Size',
- type: 'metric',
- object: 'opportunity',
- valueField: 'amount',
- aggregate: 'avg',
- filter: { status: 'won' },
- layout: { x: 9, y: 0, w: 3, h: 2 },
- },
- {
- id: 'pipeline_by_stage',
- title: 'Pipeline by Stage',
- type: 'bar',
- object: 'opportunity',
- categoryField: 'stage',
- valueField: 'amount',
- aggregate: 'sum',
- filter: { is_closed: false },
- layout: { x: 0, y: 2, w: 8, h: 4 },
- options: {
- horizontal: true,
- showValues: true,
- },
- },
- {
- id: 'opps_by_type',
- title: 'Opportunities by Type',
- type: 'pie',
- object: 'opportunity',
- categoryField: 'type',
- aggregate: 'count',
- layout: { x: 8, y: 2, w: 4, h: 4 },
- },
- {
- id: 'revenue_trend_12m',
- title: 'Revenue Trend (Last 12 Months)',
- type: 'line',
- object: 'opportunity',
- categoryField: 'close_date',
- valueField: 'amount',
- aggregate: 'sum',
- filter: { close_date: '{last_12_months}' },
- layout: { x: 0, y: 6, w: 12, h: 4 },
- options: {
- smoothCurve: true,
- showDataPoints: true,
- },
- },
- ],
- };
-
- expect(() => DashboardSchema.parse(salesDashboard)).not.toThrow();
- });
-
- it('should accept service desk dashboard', () => {
- const serviceDashboard: DashboardType = {
- name: 'service_desk',
- label: 'Service Desk Overview',
- description: 'Customer support metrics and case tracking',
- widgets: [
- {
- id: 'open_cases',
- title: 'Open Cases',
- type: 'metric',
- object: 'case',
- aggregate: 'count',
- filter: { status: { $ne: 'closed' } },
- layout: { x: 0, y: 0, w: 3, h: 2 },
- options: {
- color: '#FF6384',
- },
- },
- {
- id: 'cases_closed_today',
- title: 'Cases Closed Today',
- type: 'metric',
- object: 'case',
- aggregate: 'count',
- filter: { // Modern MongoDB-style filter
- status: 'closed',
- closed_date: '{today}'
- },
- layout: { x: 3, y: 0, w: 3, h: 2 },
- },
- {
- id: 'avg_response_time',
- title: 'Avg Response Time',
- type: 'metric',
- object: 'case',
- valueField: 'first_response_time',
- aggregate: 'avg',
- layout: { x: 6, y: 0, w: 3, h: 2 },
- options: {
- unit: 'hours',
- },
- },
- {
- id: 'customer_satisfaction',
- title: 'Customer Satisfaction',
- type: 'metric',
- object: 'case',
- valueField: 'satisfaction_rating',
- aggregate: 'avg',
- filter: { satisfaction_rating: { $null: false } },
- layout: { x: 9, y: 0, w: 3, h: 2 },
- options: {
- max: 5,
- suffix: '/5',
- },
- },
- {
- id: 'cases_by_priority',
- title: 'Cases by Priority',
- type: 'funnel',
- object: 'case',
- categoryField: 'priority',
- aggregate: 'count',
- filter: { status: { $ne: 'closed' } },
- layout: { x: 0, y: 2, w: 6, h: 4 },
- },
- {
- id: 'cases_by_status',
- title: 'Cases by Status',
- type: 'pie',
- object: 'case',
- categoryField: 'status',
- aggregate: 'count',
- layout: { x: 6, y: 2, w: 6, h: 4 },
- },
- {
- id: 'recent_high_priority_cases',
- title: 'Recent High Priority Cases',
- type: 'table',
- object: 'case',
- filter: { priority: 'high' }, // Modern MongoDB-style filter
- layout: { x: 0, y: 6, w: 12, h: 4 },
- options: {
- columns: ['case_number', 'subject', 'account', 'owner', 'created_date'],
- limit: 10,
- },
- },
- ],
- };
-
- expect(() => DashboardSchema.parse(serviceDashboard)).not.toThrow();
- });
-
- it('should accept executive dashboard with mixed widgets', () => {
- const executiveDashboard: DashboardType = {
- name: 'executive_overview',
- label: 'Executive Overview',
- description: 'Key business metrics at a glance',
- widgets: [
- {
- id: 'quarterly_revenue',
- title: 'Quarterly Revenue',
- type: 'metric',
- object: 'opportunity',
- valueField: 'amount',
- aggregate: 'sum',
- filter: { // Modern MongoDB-style filter
- status: 'won',
- close_date: '{this_quarter}'
- },
- layout: { x: 0, y: 0, w: 4, h: 3 },
- options: {
- prefix: '$',
- trend: 'up',
- trendValue: '+15%',
- },
- },
- {
- id: 'new_customers',
- title: 'New Customers',
- type: 'metric',
- object: 'account',
- aggregate: 'count',
- filter: { created_date: '{this_month}' }, // Modern MongoDB-style filter
- layout: { x: 4, y: 0, w: 4, h: 3 },
- },
- {
- id: 'active_users',
- title: 'Active Users',
- type: 'metric',
- object: 'user',
- aggregate: 'count',
- filter: { is_active: true },
- layout: { x: 8, y: 0, w: 4, h: 3 },
- },
- {
- id: 'revenue_by_product_line',
- title: 'Revenue by Product Line',
- type: 'bar',
- object: 'opportunity',
- categoryField: 'product_line',
- valueField: 'amount',
- aggregate: 'sum',
- filter: { status: 'won' },
- layout: { x: 0, y: 3, w: 8, h: 4 },
- },
- {
- id: 'team_performance',
- title: 'Team Performance',
- type: 'table',
- object: 'user',
- layout: { x: 8, y: 3, w: 4, h: 4 },
- options: {
- columns: ['name', 'deals_closed', 'revenue_generated'],
- },
- },
- {
- id: 'welcome',
- title: 'Welcome',
- type: 'metric',
- layout: { x: 0, y: 7, w: 12, h: 1 },
- options: {
- content: '**Last updated:** {NOW()}',
- },
- },
- ],
- };
-
- expect(() => DashboardSchema.parse(executiveDashboard)).not.toThrow();
});
+ expect(w.dimensions).toEqual(['stage']);
});
-});
-describe('Dashboard Factory', () => {
- it('should create dashboard with default widget values', () => {
- const dashboard = Dashboard.create({
- name: 'test_dashboard',
- label: 'Test Dashboard',
- widgets: [
- {
- id: 'test_widget',
- title: 'Test Widget',
- type: 'table',
- object: 'account',
- layout: { x: 0, y: 0, w: 12, h: 4 },
- },
- ],
- });
-
- expect(dashboard.name).toBe('test_dashboard');
- expect(dashboard.widgets).toHaveLength(1);
- expect(dashboard.widgets[0].aggregate).toBe('count');
- });
-
- it('should create dashboard without aggregate (uses default)', () => {
- const dashboard = Dashboard.create({
- name: 'sales_dashboard',
- label: 'Sales Dashboard',
- widgets: [
- {
- id: 'total_revenue',
- title: 'Total Revenue',
- type: 'metric',
- object: 'opportunity',
- valueField: 'amount',
- layout: { x: 0, y: 0, w: 3, h: 2 },
- },
- ],
- });
-
- expect(dashboard.widgets[0].aggregate).toBe('count');
- });
-});
-
-describe('Dashboard I18n Integration', () => {
- it('should reject i18n object as dashboard label', () => {
- expect(() => DashboardSchema.parse({
- name: 'i18n_dashboard',
- label: { key: 'dashboards.sales', defaultValue: 'Sales Dashboard' },
- widgets: [],
- })).toThrow();
- });
- it('should reject i18n object as dashboard description', () => {
- expect(() => DashboardSchema.parse({
- name: 'test_dashboard',
- label: 'Test',
- description: { key: 'dashboards.test.desc', defaultValue: 'Test dashboard' },
- widgets: [],
- })).toThrow();
- });
- it('should reject i18n object as widget title', () => {
- expect(() => DashboardWidgetSchema.parse({
- id: 'total_revenue',
- title: { key: 'widgets.revenue', defaultValue: 'Total Revenue' },
- type: 'metric',
+ it('keeps the presentation-scope filter (runtimeFilter) and compareTo', () => {
+ const w = DashboardWidgetSchema.parse({
+ id: 'won', type: 'metric', dataset: 'sales', values: ['revenue'],
+ filter: { stage: 'closed_won' }, compareTo: 'previousPeriod',
layout: { x: 0, y: 0, w: 3, h: 2 },
- })).toThrow();
- });
- it('should reject i18n object in global filter label', () => {
- expect(() => DashboardSchema.parse({
- name: 'filter_dash',
- label: 'Filtered',
- widgets: [],
- globalFilters: [{
- field: 'status',
- label: { key: 'filters.status', defaultValue: 'Status' },
- type: 'select',
- }],
- })).toThrow();
- });
-});
-
-describe('Dashboard ARIA Integration', () => {
- it('should accept dashboard with ARIA attributes', () => {
- expect(() => DashboardSchema.parse({
- name: 'accessible_dash',
- label: 'Accessible Dashboard',
- widgets: [],
- aria: { ariaLabel: 'Sales dashboard overview', role: 'region' },
- })).not.toThrow();
- });
- it('should accept widget with ARIA attributes', () => {
- expect(() => DashboardWidgetSchema.parse({
- id: 'revenue_metric',
- title: 'Revenue',
- type: 'metric',
- layout: { x: 0, y: 0, w: 3, h: 2 },
- aria: { ariaLabel: 'Total revenue metric', ariaDescribedBy: 'revenue-desc' },
- })).not.toThrow();
- });
-});
-
-describe('Dashboard Responsive Integration', () => {
- it('should accept widget with responsive config', () => {
- expect(() => DashboardWidgetSchema.parse({
- id: 'responsive_metric',
- type: 'metric',
- layout: { x: 0, y: 0, w: 6, h: 2 },
- responsive: { hiddenOn: ['xs'] },
- })).not.toThrow();
- });
-});
-
-describe('Dashboard Performance Integration', () => {
- it('should accept dashboard with performance config', () => {
- expect(() => DashboardSchema.parse({
- name: 'perf_dash',
- label: 'Performance Dashboard',
- widgets: [],
- performance: { lazyLoad: true, cacheStrategy: 'stale-while-revalidate' },
- })).not.toThrow();
- });
-});
-
-// ============================================================================
-// Protocol Improvement Tests: Dashboard dateRange
-// ============================================================================
-
-describe('DashboardSchema - dateRange', () => {
- it('should accept dateRange configuration', () => {
- const result = DashboardSchema.parse({
- name: 'sales_dashboard',
- label: 'Sales',
- widgets: [],
- dateRange: {
- field: 'created_at',
- defaultRange: 'this_quarter',
- allowCustomRange: true,
- },
- });
- expect(result.dateRange?.field).toBe('created_at');
- expect(result.dateRange?.defaultRange).toBe('this_quarter');
- expect(result.dateRange?.allowCustomRange).toBe(true);
- });
-
- it('should default dateRange.defaultRange to this_month', () => {
- const result = DashboardSchema.parse({
- name: 'dashboard',
- label: 'Dashboard',
- widgets: [],
- dateRange: {},
- });
- expect(result.dateRange?.defaultRange).toBe('this_month');
- expect(result.dateRange?.allowCustomRange).toBe(true);
- });
-
- it('should accept all dateRange preset values', () => {
- const presets = ['today', 'yesterday', 'this_week', 'last_week', 'this_month', 'last_month', 'this_quarter', 'last_quarter', 'this_year', 'last_year', 'last_7_days', 'last_30_days', 'last_90_days', 'custom'];
- for (const preset of presets) {
- expect(() => DashboardSchema.parse({
- name: 'test_dash',
- label: 'Test',
- widgets: [],
- dateRange: { defaultRange: preset },
- })).not.toThrow();
- }
- });
-
- it('should accept dashboard without dateRange (optional)', () => {
- const result = DashboardSchema.parse({
- name: 'simple_dash',
- label: 'Simple',
- widgets: [],
- });
- expect(result.dateRange).toBeUndefined();
- });
-});
-
-// ============================================================================
-// Protocol Enhancement Tests: colorVariant, description, actionUrl/actionType
-// ============================================================================
-
-describe('WidgetColorVariantSchema', () => {
- it('should accept all color variants', () => {
- const variants = ['default', 'blue', 'teal', 'orange', 'purple', 'success', 'warning', 'danger'];
- variants.forEach(variant => {
- expect(() => WidgetColorVariantSchema.parse(variant)).not.toThrow();
});
+ expect(w.filter).toEqual({ stage: 'closed_won' });
+ expect(w.compareTo).toBe('previousPeriod');
});
- it('should reject invalid color variants', () => {
- expect(() => WidgetColorVariantSchema.parse('red')).toThrow();
- expect(() => WidgetColorVariantSchema.parse('unknown')).toThrow();
- });
-});
-
-describe('WidgetActionTypeSchema', () => {
- it('should accept all action types', () => {
- const types = ['script', 'url', 'modal', 'flow', 'api'];
- types.forEach(type => {
- expect(() => WidgetActionTypeSchema.parse(type)).not.toThrow();
- });
+ it('rejects a widget with no dataset', () => {
+ expect(() => DashboardWidgetSchema.parse({ id: 'x', type: 'metric', values: ['revenue'], layout: { x: 0, y: 0, w: 3, h: 2 } })).toThrow();
});
- it('should reject invalid action types', () => {
- expect(() => WidgetActionTypeSchema.parse('invalid')).toThrow();
+ it('rejects a widget with no values', () => {
+ expect(() => DashboardWidgetSchema.parse({ id: 'x', type: 'metric', dataset: 'sales', values: [], layout: { x: 0, y: 0, w: 3, h: 2 } })).toThrow();
});
-});
-describe('DashboardWidgetSchema - colorVariant', () => {
- it('should accept widget with colorVariant', () => {
- const widget: DashboardWidget = {
- id: 'total_revenue',
- title: 'Total Revenue',
- type: 'metric',
- colorVariant: 'teal',
- layout: { x: 0, y: 0, w: 3, h: 2 },
- };
- const result = DashboardWidgetSchema.parse(widget);
- expect(result.colorVariant).toBe('teal');
+ it('a widget supplying only the removed inline fields is invalid (no dataset)', () => {
+ expect(() => DashboardWidgetSchema.parse({ id: 'x', type: 'metric', object: 'opportunity', aggregate: 'count', layout: { x: 0, y: 0, w: 3, h: 2 } } as any)).toThrow();
});
- it('should accept widget without colorVariant (optional)', () => {
- const result = DashboardWidgetSchema.parse({
- id: 'metric_widget',
- type: 'metric',
+ it('keeps the runtime capability gates', () => {
+ const w = DashboardWidgetSchema.parse({
+ id: 'gated', type: 'metric', dataset: 'sys', values: ['cnt'],
+ requiresObject: 'sys_package_installation', requiresService: 'analytics',
layout: { x: 0, y: 0, w: 3, h: 2 },
});
- expect(result.colorVariant).toBeUndefined();
- });
-
- it('should reject invalid colorVariant', () => {
- expect(() => DashboardWidgetSchema.parse({
- type: 'metric',
- colorVariant: 'neon',
- layout: { x: 0, y: 0, w: 3, h: 2 },
- })).toThrow();
+ expect(w.requiresObject).toBe('sys_package_installation');
+ expect(w.requiresService).toBe('analytics');
});
});
-describe('DashboardWidgetSchema - description', () => {
- it('should accept widget with string description', () => {
- const result = DashboardWidgetSchema.parse({
- id: 'revenue_widget',
- title: 'Revenue',
- description: 'Year-to-date total revenue',
- type: 'metric',
- layout: { x: 0, y: 0, w: 3, h: 2 },
- });
- expect(result.description).toBe('Year-to-date total revenue');
- });
-
- it('should reject widget with i18n description', () => {
- expect(() => DashboardWidgetSchema.parse({
- id: 'revenue_i18n',
- title: 'Revenue',
- description: { key: 'widgets.revenue.desc', defaultValue: 'Total revenue' },
- type: 'metric',
- layout: { x: 0, y: 0, w: 3, h: 2 },
- })).toThrow();
- });
-
- it('should accept widget without description (optional)', () => {
- const result = DashboardWidgetSchema.parse({
- id: 'no_desc_widget',
- type: 'metric',
- layout: { x: 0, y: 0, w: 3, h: 2 },
- });
- expect(result.description).toBeUndefined();
- });
-});
-
-describe('DashboardWidgetSchema - actionUrl/actionType/actionIcon', () => {
- it('should accept widget with actionUrl and actionType', () => {
- const result = DashboardWidgetSchema.parse({
- id: 'open_tickets',
- title: 'Open Tickets',
- type: 'metric',
- actionUrl: 'https://example.com/tickets',
- actionType: 'url',
- layout: { x: 0, y: 0, w: 3, h: 2 },
- });
- expect(result.actionUrl).toBe('https://example.com/tickets');
- expect(result.actionType).toBe('url');
- });
-
- it('should accept widget with actionIcon', () => {
- const result = DashboardWidgetSchema.parse({
- id: 'details_widget',
- title: 'Details',
- type: 'metric',
- actionUrl: '/details',
- actionType: 'url',
- actionIcon: 'external-link',
- layout: { x: 0, y: 0, w: 3, h: 2 },
- });
- expect(result.actionIcon).toBe('external-link');
- });
-
- it('should accept widget with modal action type', () => {
- const result = DashboardWidgetSchema.parse({
- id: 'breakdown_widget',
- title: 'Breakdown',
- type: 'metric',
- actionUrl: 'revenue_breakdown',
- actionType: 'modal',
- layout: { x: 0, y: 0, w: 3, h: 2 },
- });
- expect(result.actionType).toBe('modal');
- });
-
- it('should accept widget with flow action type', () => {
- const result = DashboardWidgetSchema.parse({
- id: 'refresh_data',
- title: 'Refresh Data',
- type: 'metric',
- actionUrl: 'refresh_pipeline_flow',
- actionType: 'flow',
- layout: { x: 0, y: 0, w: 3, h: 2 },
- });
- expect(result.actionType).toBe('flow');
- });
-
- it('should accept widget without action fields (optional)', () => {
- const result = DashboardWidgetSchema.parse({
- id: 'no_action_widget',
- type: 'metric',
- layout: { x: 0, y: 0, w: 3, h: 2 },
- });
- expect(result.actionUrl).toBeUndefined();
- expect(result.actionType).toBeUndefined();
- expect(result.actionIcon).toBeUndefined();
- });
-
- it('should reject invalid actionType', () => {
- expect(() => DashboardWidgetSchema.parse({
- type: 'metric',
- actionType: 'invalid',
- layout: { x: 0, y: 0, w: 3, h: 2 },
- })).toThrow();
- });
-});
-
-describe('DashboardWidgetSchema - combined new fields', () => {
- it('should accept KPI widget with all new fields', () => {
- const widget: DashboardWidget = {
- id: 'revenue_kpi',
- title: 'Revenue',
- description: 'Q4 total revenue across all regions',
- type: 'metric',
- colorVariant: 'success',
- actionUrl: 'https://reports.example.com/revenue',
- actionType: 'url',
- actionIcon: 'external-link',
- object: 'opportunity',
- valueField: 'amount',
- aggregate: 'sum',
- layout: { x: 0, y: 0, w: 3, h: 2 },
- };
-
- const result = DashboardWidgetSchema.parse(widget);
- expect(result.description).toBe('Q4 total revenue across all regions');
- expect(result.colorVariant).toBe('success');
- expect(result.actionUrl).toBe('https://reports.example.com/revenue');
- expect(result.actionType).toBe('url');
- expect(result.actionIcon).toBe('external-link');
- });
-
- it('should work in a full dashboard with color-coded KPI cards', () => {
- const dashboard = Dashboard.create({
- name: 'kpi_dashboard',
- label: 'KPI Dashboard',
- widgets: [
- {
- id: 'kpi_revenue',
- title: 'Revenue',
- description: 'Total quarterly revenue',
- type: 'metric',
- colorVariant: 'success',
- object: 'opportunity',
- valueField: 'amount',
- aggregate: 'sum',
- layout: { x: 0, y: 0, w: 3, h: 2 },
- },
- {
- id: 'open_issues',
- title: 'Open Issues',
- description: 'Unresolved support tickets',
- type: 'metric',
- colorVariant: 'warning',
- actionUrl: '/issues',
- actionType: 'url',
- object: 'case',
- aggregate: 'count',
- layout: { x: 3, y: 0, w: 3, h: 2 },
- },
- {
- id: 'critical_bugs',
- title: 'Critical Bugs',
- description: 'P0/P1 bugs requiring attention',
- type: 'metric',
- colorVariant: 'danger',
- actionUrl: 'bug_triage_flow',
- actionType: 'flow',
- actionIcon: 'alert-triangle',
- object: 'bug',
- aggregate: 'count',
- layout: { x: 6, y: 0, w: 3, h: 2 },
- },
- {
- id: 'team_velocity',
- title: 'Team Velocity',
- type: 'bar',
- colorVariant: 'blue',
- object: 'sprint',
- categoryField: 'sprint_name',
- valueField: 'story_points',
- aggregate: 'sum',
- layout: { x: 0, y: 2, w: 12, h: 4 },
- },
- ],
- });
-
- expect(dashboard.widgets).toHaveLength(4);
- expect(dashboard.widgets[0].colorVariant).toBe('success');
- expect(dashboard.widgets[1].actionUrl).toBe('/issues');
- expect(dashboard.widgets[2].description).toBe('P0/P1 bugs requiring attention');
- expect(dashboard.widgets[3].colorVariant).toBe('blue');
- });
-});
-
-// ============================================================================
-// Protocol Enhancement Tests: GlobalFilterSchema — options, optionsFrom,
-// defaultValue, scope, targetWidgets (#712)
-// ============================================================================
-
-describe('GlobalFilterOptionsFromSchema', () => {
- it('should accept valid optionsFrom config', () => {
- const result = GlobalFilterOptionsFromSchema.parse({
- object: 'account',
- valueField: 'id',
- labelField: 'name',
- });
- expect(result.object).toBe('account');
- expect(result.valueField).toBe('id');
- expect(result.labelField).toBe('name');
- });
-
- it('should accept optionsFrom with filter', () => {
- const result = GlobalFilterOptionsFromSchema.parse({
- object: 'account',
- valueField: 'id',
- labelField: 'name',
- filter: { is_active: true },
- });
- expect(result.filter).toEqual({ is_active: true });
- });
-
- it('should reject optionsFrom without required fields', () => {
- expect(() => GlobalFilterOptionsFromSchema.parse({ object: 'account' })).toThrow();
- expect(() => GlobalFilterOptionsFromSchema.parse({ valueField: 'id' })).toThrow();
- expect(() => GlobalFilterOptionsFromSchema.parse({})).toThrow();
- });
-});
-
-describe('GlobalFilterSchema', () => {
- it('should accept minimal filter (backward compat)', () => {
- const result = GlobalFilterSchema.parse({
- field: 'status',
- });
- expect(result.field).toBe('status');
- expect(result.scope).toBe('dashboard');
- });
-
- it('should accept old-style filter with label and type', () => {
- const result = GlobalFilterSchema.parse({
- field: 'status',
- label: 'Status',
- type: 'select',
- });
- expect(result.field).toBe('status');
- expect(result.label).toBe('Status');
- expect(result.type).toBe('select');
- });
-
- it('should accept all filter types including lookup', () => {
- const types = ['text', 'select', 'date', 'number', 'lookup'] as const;
- types.forEach(type => {
- expect(() => GlobalFilterSchema.parse({ field: 'f', type })).not.toThrow();
- });
- });
-
- it('should reject invalid filter type', () => {
- expect(() => GlobalFilterSchema.parse({ field: 'f', type: 'checkbox' })).toThrow();
- });
-
- it('should accept filter with static options', () => {
- const result = GlobalFilterSchema.parse({
- field: 'priority',
- type: 'select',
- options: [
- { value: 'high', label: 'High' },
- { value: 'medium', label: 'Medium' },
- { value: 'low', label: 'Low' },
- ],
- });
- expect(result.options).toHaveLength(3);
- expect(result.options![0].value).toBe('high');
- expect(result.options![0].label).toBe('High');
- });
-
- it('should reject filter with i18n option labels', () => {
- expect(() => GlobalFilterSchema.parse({
- field: 'priority',
- type: 'select',
- options: [
- { value: 'high', label: { key: 'filter.priority.high', defaultValue: 'High' } },
- ],
- })).toThrow();
- });
-
- it('should accept filter with optionsFrom (dynamic binding)', () => {
- const result = GlobalFilterSchema.parse({
- field: 'account_id',
- type: 'lookup',
- optionsFrom: {
- object: 'account',
- valueField: 'id',
- labelField: 'name',
- },
- });
- expect(result.optionsFrom).toBeDefined();
- expect(result.optionsFrom!.object).toBe('account');
- expect(result.optionsFrom!.valueField).toBe('id');
- expect(result.optionsFrom!.labelField).toBe('name');
- });
-
- it('should accept filter with optionsFrom and filter', () => {
- const result = GlobalFilterSchema.parse({
- field: 'owner_id',
- type: 'lookup',
- optionsFrom: {
- object: 'user',
- valueField: 'id',
- labelField: 'full_name',
- filter: { is_active: true },
- },
- });
- expect(result.optionsFrom!.filter).toEqual({ is_active: true });
- });
-
- it('should accept filter with defaultValue', () => {
- const result = GlobalFilterSchema.parse({
- field: 'status',
- type: 'select',
- defaultValue: 'open',
- });
- expect(result.defaultValue).toBe('open');
- });
-
- it('should default scope to dashboard', () => {
- const result = GlobalFilterSchema.parse({ field: 'status' });
- expect(result.scope).toBe('dashboard');
- });
-
- it('should accept scope widget', () => {
- const result = GlobalFilterSchema.parse({
- field: 'status',
- scope: 'widget',
- });
- expect(result.scope).toBe('widget');
- });
-
- it('should reject invalid scope', () => {
- expect(() => GlobalFilterSchema.parse({ field: 'f', scope: 'global' })).toThrow();
- });
-
- it('should accept targetWidgets', () => {
- const result = GlobalFilterSchema.parse({
- field: 'region',
- scope: 'widget',
- targetWidgets: ['revenue_chart', 'pipeline_table'],
- });
- expect(result.targetWidgets).toEqual(['revenue_chart', 'pipeline_table']);
- });
-
- it('should accept filter without targetWidgets (optional)', () => {
- const result = GlobalFilterSchema.parse({ field: 'status' });
- expect(result.targetWidgets).toBeUndefined();
- });
-});
-
-describe('DashboardSchema - enhanced globalFilters', () => {
- it('should still accept old-style globalFilters (backward compat)', () => {
- const result = DashboardSchema.parse({
- name: 'compat_dash',
- label: 'Compat Dashboard',
- widgets: [],
- globalFilters: [
- { field: 'status', label: 'Status', type: 'select' },
- { field: 'created_at', type: 'date' },
- ],
- });
- expect(result.globalFilters).toHaveLength(2);
- expect(result.globalFilters![0].scope).toBe('dashboard');
- });
-
- it('should accept globalFilters with optionsFrom', () => {
- const result = DashboardSchema.parse({
- name: 'dynamic_filters_dash',
- label: 'Dynamic Filters',
- widgets: [],
- globalFilters: [
- {
- field: 'account_id',
- label: 'Account',
- type: 'lookup',
- optionsFrom: {
- object: 'account',
- valueField: 'id',
- labelField: 'name',
- },
- },
- ],
- });
- expect(result.globalFilters![0].optionsFrom!.object).toBe('account');
- });
-
- it('should accept globalFilters with static options', () => {
- const result = DashboardSchema.parse({
- name: 'static_options_dash',
- label: 'Static Options',
- widgets: [],
- globalFilters: [
- {
- field: 'priority',
- label: 'Priority',
- type: 'select',
- options: [
- { value: 'high', label: 'High' },
- { value: 'medium', label: 'Medium' },
- { value: 'low', label: 'Low' },
- ],
- defaultValue: 'medium',
- },
- ],
- });
- expect(result.globalFilters![0].options).toHaveLength(3);
- expect(result.globalFilters![0].defaultValue).toBe('medium');
- });
-
- it('should accept globalFilters with targetWidgets', () => {
- const result = DashboardSchema.parse({
- name: 'targeted_filter_dash',
- label: 'Targeted Filters',
- widgets: [
- { id: 'chart_a', title: 'Chart A', type: 'bar', layout: { x: 0, y: 0, w: 6, h: 4 } },
- { id: 'chart_b', title: 'Chart B', type: 'line', layout: { x: 6, y: 0, w: 6, h: 4 } },
- ],
- globalFilters: [
- {
- field: 'region',
- label: 'Region',
- type: 'select',
- scope: 'widget',
- targetWidgets: ['chart_a'],
- options: [
- { value: 'na', label: 'North America' },
- { value: 'eu', label: 'Europe' },
- ],
- },
- ],
- });
- expect(result.globalFilters![0].scope).toBe('widget');
- expect(result.globalFilters![0].targetWidgets).toEqual(['chart_a']);
- });
-
- it('should accept Airtable-style dashboard with full filter bar config', () => {
- const dashboard = Dashboard.create({
- name: 'airtable_style_dash',
- label: 'Airtable Style Dashboard',
- widgets: [
- {
- id: 'revenue_by_region',
- title: 'Revenue by Region',
- type: 'bar',
- object: 'opportunity',
- categoryField: 'region',
- valueField: 'amount',
- aggregate: 'sum',
- layout: { x: 0, y: 0, w: 12, h: 4 },
- },
- ],
- globalFilters: [
- {
- field: 'owner_id',
- label: 'Owner',
- type: 'lookup',
- optionsFrom: {
- object: 'user',
- valueField: 'id',
- labelField: 'full_name',
- filter: { is_active: true },
- },
- },
- {
- field: 'status',
- label: 'Status',
- type: 'select',
- options: [
- { value: 'open', label: 'Open' },
- { value: 'closed', label: 'Closed' },
- ],
- defaultValue: 'open',
- },
- {
- field: 'region',
- label: 'Region',
- type: 'select',
- scope: 'widget',
- targetWidgets: ['revenue_chart'],
- optionsFrom: {
- object: 'region',
- valueField: 'code',
- labelField: 'name',
- },
- },
- ],
- });
-
- expect(dashboard.globalFilters).toHaveLength(3);
- expect(dashboard.globalFilters![0].optionsFrom!.object).toBe('user');
- expect(dashboard.globalFilters![1].defaultValue).toBe('open');
- expect(dashboard.globalFilters![2].targetWidgets).toEqual(['revenue_chart']);
- });
-});
-
-// ============================================================================
-// Protocol Enhancement Tests: DashboardHeaderSchema (#714)
-// ============================================================================
-
-describe('DashboardHeaderActionSchema', () => {
- it('should accept valid header action', () => {
- const result = DashboardHeaderActionSchema.parse({
- label: 'Export PDF',
- actionUrl: '/export/pdf',
- });
- expect(result.label).toBe('Export PDF');
- expect(result.actionUrl).toBe('/export/pdf');
- });
-
- it('should accept action with all fields', () => {
- const result = DashboardHeaderActionSchema.parse({
- label: 'Run Report',
- actionUrl: 'generate_report_flow',
- actionType: 'flow',
- icon: 'play',
- });
- expect(result.actionType).toBe('flow');
- expect(result.icon).toBe('play');
- });
-
- it('should reject i18n label', () => {
- expect(() => DashboardHeaderActionSchema.parse({
- label: { key: 'actions.export', defaultValue: 'Export' },
- actionUrl: '/export',
- })).toThrow();
- });
-
- it('should reject action without required fields', () => {
- expect(() => DashboardHeaderActionSchema.parse({ label: 'Test' })).toThrow();
- expect(() => DashboardHeaderActionSchema.parse({ actionUrl: '/test' })).toThrow();
- expect(() => DashboardHeaderActionSchema.parse({})).toThrow();
- });
-});
-
-describe('DashboardHeaderSchema', () => {
- it('should accept empty header with defaults', () => {
- const result = DashboardHeaderSchema.parse({});
- expect(result.showTitle).toBe(true);
- expect(result.showDescription).toBe(true);
- expect(result.actions).toBeUndefined();
- });
-
- it('should accept header with showTitle/showDescription overrides', () => {
- const result = DashboardHeaderSchema.parse({
- showTitle: false,
- showDescription: false,
- });
- expect(result.showTitle).toBe(false);
- expect(result.showDescription).toBe(false);
- });
-
- it('should accept header with actions', () => {
- const result = DashboardHeaderSchema.parse({
- actions: [
- { label: 'Export', actionUrl: '/export/pdf', icon: 'download' },
- { label: 'Share', actionUrl: 'share_modal', actionType: 'modal', icon: 'share' },
- ],
- });
- expect(result.actions).toHaveLength(2);
- expect(result.actions![0].label).toBe('Export');
- expect(result.actions![1].actionType).toBe('modal');
- });
-});
-
-describe('DashboardSchema - header', () => {
- it('should accept dashboard with header configuration', () => {
- const result = DashboardSchema.parse({
- name: 'sales_dashboard',
- label: 'Sales Dashboard',
- description: 'Q4 sales performance',
- header: {
- showTitle: true,
- showDescription: true,
- actions: [
- { label: 'Export PDF', actionUrl: '/export/pdf', icon: 'download' },
- ],
- },
- widgets: [],
- });
- expect(result.header).toBeDefined();
- expect(result.header!.showTitle).toBe(true);
- expect(result.header!.actions).toHaveLength(1);
- });
-
- it('should accept dashboard without header (backward compat)', () => {
- const result = DashboardSchema.parse({
- name: 'simple_dash',
- label: 'Simple',
- widgets: [],
- });
- expect(result.header).toBeUndefined();
- });
-
- it('should accept dashboard with header hiding title/description', () => {
- const result = DashboardSchema.parse({
- name: 'minimal_header_dash',
- label: 'Minimal',
- header: {
- showTitle: false,
- showDescription: false,
- },
- widgets: [],
- });
- expect(result.header!.showTitle).toBe(false);
- expect(result.header!.showDescription).toBe(false);
- });
-
- it('should work with Dashboard factory', () => {
- const dashboard = Dashboard.create({
- name: 'executive_dash',
- label: 'Executive Dashboard',
- description: 'Key business metrics',
- header: {
- actions: [
- { label: 'Export', actionUrl: '/export', actionType: 'url', icon: 'download' },
- { label: 'Refresh', actionUrl: 'refresh_flow', actionType: 'flow', icon: 'refresh' },
- ],
- },
- widgets: [
- { id: 'revenue_widget', title: 'Revenue', type: 'metric', layout: { x: 0, y: 0, w: 3, h: 2 } },
- ],
- });
- expect(dashboard.header!.showTitle).toBe(true);
- expect(dashboard.header!.actions).toHaveLength(2);
- expect(dashboard.header!.actions![1].actionType).toBe('flow');
- });
-});
-
-// ============================================================================
-// Protocol Enhancement Tests: WidgetMeasureSchema / multi-measure pivot (#714)
-// ============================================================================
-
-describe('WidgetMeasureSchema', () => {
- it('should accept minimal measure', () => {
- const result = WidgetMeasureSchema.parse({
- valueField: 'amount',
- });
- expect(result.valueField).toBe('amount');
- expect(result.aggregate).toBe('count');
- });
-
- it('should accept measure with all fields', () => {
- const result = WidgetMeasureSchema.parse({
- valueField: 'amount',
- aggregate: 'sum',
- label: 'Total Amount',
- format: '$0,0.00',
- });
- expect(result.aggregate).toBe('sum');
- expect(result.label).toBe('Total Amount');
- expect(result.format).toBe('$0,0.00');
- });
-
- it('should reject measure with i18n label', () => {
- expect(() => WidgetMeasureSchema.parse({
- valueField: 'quantity',
- aggregate: 'avg',
- label: { key: 'measures.avg_qty', defaultValue: 'Average Quantity' },
- })).toThrow();
- });
-
- it('should accept all aggregate functions', () => {
- const aggregates = ['count', 'sum', 'avg', 'min', 'max'] as const;
- aggregates.forEach(aggregate => {
- expect(() => WidgetMeasureSchema.parse({ valueField: 'f', aggregate })).not.toThrow();
- });
- });
-
- it('should reject measure without valueField', () => {
- expect(() => WidgetMeasureSchema.parse({})).toThrow();
- expect(() => WidgetMeasureSchema.parse({ aggregate: 'sum' })).toThrow();
- });
-});
-
-describe('DashboardWidgetSchema - measures (multi-measure pivot)', () => {
- it('should accept pivot widget with measures', () => {
- const widget = DashboardWidgetSchema.parse({
- id: 'sales_by_region_product',
- title: 'Sales by Region and Product',
- type: 'pivot',
- object: 'opportunity',
- categoryField: 'region',
- measures: [
- { valueField: 'amount', aggregate: 'sum', label: 'Total Amount', format: '$0,0' },
- { valueField: 'amount', aggregate: 'avg', label: 'Avg Deal Size', format: '$0,0.00' },
- { valueField: 'amount', aggregate: 'count', label: 'Deal Count' },
- ],
- layout: { x: 0, y: 0, w: 12, h: 6 },
- });
- expect(widget.measures).toHaveLength(3);
- expect(widget.measures![0].aggregate).toBe('sum');
- expect(widget.measures![1].aggregate).toBe('avg');
- expect(widget.measures![2].aggregate).toBe('count');
- });
-
- it('should accept widget without measures (backward compat)', () => {
- const result = DashboardWidgetSchema.parse({
- id: 'bar_widget',
- type: 'bar',
- object: 'opportunity',
- valueField: 'amount',
- aggregate: 'sum',
- layout: { x: 0, y: 0, w: 6, h: 4 },
- });
- expect(result.measures).toBeUndefined();
- });
-
- it('should accept table widget with measures for multi-aggregate', () => {
- const widget = DashboardWidgetSchema.parse({
- id: 'regional_summary',
- title: 'Regional Summary',
- type: 'table',
- object: 'order',
- categoryField: 'region',
- measures: [
- { valueField: 'revenue', aggregate: 'sum', label: 'Revenue' },
- { valueField: 'quantity', aggregate: 'sum', label: 'Units Sold' },
- { valueField: 'revenue', aggregate: 'avg', label: 'Avg Order Value' },
- ],
- layout: { x: 0, y: 0, w: 12, h: 6 },
- });
- expect(widget.measures).toHaveLength(3);
- });
-
- it('should work in full dashboard with pivot multi-measure', () => {
- const dashboard = Dashboard.create({
- name: 'analytics_dashboard',
- label: 'Analytics Dashboard',
- header: {
- actions: [
- { label: 'Export CSV', actionUrl: '/export/csv', icon: 'download' },
- ],
- },
+describe('DashboardSchema', () => {
+ it('parses a dataset-bound dashboard', () => {
+ const d = DashboardSchema.parse({
+ name: 'sales_overview', label: 'Sales Overview',
widgets: [
- {
- id: 'revenue_metric',
- title: 'Revenue',
- type: 'metric',
- object: 'order',
- valueField: 'amount',
- aggregate: 'sum',
- layout: { x: 0, y: 0, w: 4, h: 2 },
- },
- {
- id: 'sales_pivot_analysis',
- title: 'Sales Pivot Analysis',
- type: 'pivot',
- object: 'opportunity',
- categoryField: 'region',
- measures: [
- { valueField: 'amount', aggregate: 'sum', label: 'Total Revenue', format: '$0,0' },
- { valueField: 'amount', aggregate: 'count', label: 'Deals' },
- { valueField: 'amount', aggregate: 'avg', label: 'Avg Deal', format: '$0,0.00' },
- { valueField: 'margin', aggregate: 'avg', label: 'Avg Margin', format: '0.0%' },
- ],
- layout: { x: 0, y: 2, w: 12, h: 6 },
- },
+ { id: 'kpi', type: 'metric', dataset: 'sales', values: ['revenue'], layout: { x: 0, y: 0, w: 3, h: 2 } },
+ { id: 'chart', type: 'bar', dataset: 'sales', dimensions: ['stage'], values: ['revenue'], layout: { x: 3, y: 0, w: 6, h: 4 } },
],
});
-
- expect(dashboard.header!.actions).toHaveLength(1);
- expect(dashboard.widgets[1].measures).toHaveLength(4);
- expect(dashboard.widgets[1].measures![0].format).toBe('$0,0');
- expect(dashboard.widgets[1].measures![3].valueField).toBe('margin');
- });
-});
-
-// ============================================================================
-// Protocol Enhancement Tests: pivot / funnel / grouped-bar widget types (#713)
-// ============================================================================
-
-describe('DashboardWidgetSchema - pivot/funnel/grouped-bar types', () => {
- it('should accept funnel widget with chartConfig', () => {
- const widget = DashboardWidgetSchema.parse({
- id: 'lead_conversion_funnel',
- title: 'Lead Conversion Funnel',
- type: 'funnel',
- object: 'lead',
- categoryField: 'stage',
- aggregate: 'count',
- chartConfig: {
- type: 'funnel',
- showDataLabels: true,
- colors: ['#4CAF50', '#FF9800', '#F44336'],
- },
- layout: { x: 0, y: 0, w: 6, h: 4 },
- });
- expect(widget.type).toBe('funnel');
- expect(widget.chartConfig!.type).toBe('funnel');
- expect(widget.chartConfig!.showDataLabels).toBe(true);
+ expect(d.widgets).toHaveLength(2);
});
- it('should accept grouped-bar widget with chartConfig', () => {
- const widget = DashboardWidgetSchema.parse({
- id: 'revenue_by_region_quarter',
- title: 'Revenue by Region & Quarter',
- type: 'grouped-bar',
- object: 'order',
- categoryField: 'region',
- valueField: 'revenue',
- aggregate: 'sum',
- chartConfig: {
- type: 'grouped-bar',
- showLegend: true,
- showDataLabels: false,
- xAxis: { field: 'region', title: 'Region' },
- yAxis: [{ field: 'revenue', title: 'Revenue ($)', format: '$0,0' }],
- },
- layout: { x: 0, y: 0, w: 12, h: 4 },
+ it('Dashboard.create factory parses + returns a typed dashboard', () => {
+ const d = Dashboard.create({
+ name: 'dash_x', label: 'D',
+ widgets: [{ id: 'wid_x', type: 'metric', dataset: 'sales', values: ['revenue'], layout: { x: 0, y: 0, w: 3, h: 2 } }],
});
- expect(widget.type).toBe('grouped-bar');
- expect(widget.chartConfig!.type).toBe('grouped-bar');
- expect(widget.chartConfig!.showLegend).toBe(true);
+ expect(d.name).toBe('dash_x');
});
- it('should accept pivot widget with measures and chartConfig', () => {
- const widget = DashboardWidgetSchema.parse({
- id: 'sales_cross_tab',
- title: 'Sales Cross-Tab Analysis',
- type: 'pivot',
- object: 'opportunity',
- categoryField: 'region',
- measures: [
- { valueField: 'amount', aggregate: 'sum', label: 'Total', format: '$0,0' },
- { valueField: 'amount', aggregate: 'count', label: 'Count' },
- ],
- chartConfig: {
- type: 'pivot',
- showDataLabels: true,
- },
- layout: { x: 0, y: 0, w: 12, h: 6 },
+ it('supports columns/gap/refresh/dateRange/globalFilters', () => {
+ const d = DashboardSchema.parse({
+ name: 'dash_x', label: 'D', columns: 12, gap: 4, refreshInterval: 60,
+ dateRange: { field: 'close_date', defaultRange: 'this_quarter' },
+ globalFilters: [{ field: 'owner', type: 'lookup' }],
+ widgets: [{ id: 'wid_x', type: 'metric', dataset: 'sales', values: ['revenue'], layout: { x: 0, y: 0, w: 3, h: 2 } }],
});
- expect(widget.type).toBe('pivot');
- expect(widget.measures).toHaveLength(2);
- });
-
- it('should accept dashboard with pivot, funnel, and grouped-bar widgets', () => {
- const dashboard = Dashboard.create({
- name: 'analytics_overview',
- label: 'Analytics Overview',
- description: 'Dashboard combining pivot, funnel, and grouped-bar widgets',
- widgets: [
- {
- id: 'sales_funnel',
- title: 'Sales Funnel',
- type: 'funnel',
- object: 'lead',
- categoryField: 'stage',
- aggregate: 'count',
- layout: { x: 0, y: 0, w: 6, h: 4 },
- },
- {
- id: 'revenue_region_quarter',
- title: 'Revenue by Region & Quarter',
- type: 'grouped-bar',
- object: 'order',
- categoryField: 'region',
- valueField: 'revenue',
- aggregate: 'sum',
- layout: { x: 6, y: 0, w: 6, h: 4 },
- },
- {
- id: 'regional_pivot_analysis',
- title: 'Regional Pivot Analysis',
- type: 'pivot',
- object: 'opportunity',
- categoryField: 'region',
- measures: [
- { valueField: 'amount', aggregate: 'sum', label: 'Revenue', format: '$0,0' },
- { valueField: 'amount', aggregate: 'avg', label: 'Avg Deal', format: '$0,0.00' },
- { valueField: 'amount', aggregate: 'count', label: 'Deals' },
- ],
- layout: { x: 0, y: 4, w: 12, h: 6 },
- },
- ],
- });
-
- expect(dashboard.widgets).toHaveLength(3);
- expect(dashboard.widgets[0].type).toBe('funnel');
- expect(dashboard.widgets[1].type).toBe('grouped-bar');
- expect(dashboard.widgets[2].type).toBe('pivot');
- expect(dashboard.widgets[2].measures).toHaveLength(3);
+ expect(d.columns).toBe(12);
+ expect(d.globalFilters).toHaveLength(1);
});
});
-// ============================================================================
-// Negative / Inverse Validation Tests
-// ============================================================================
-
-describe('DashboardWidgetSchema - Negative Validation', () => {
- it('should reject widget without layout', () => {
- expect(() => DashboardWidgetSchema.parse({
- title: 'Bad Widget',
- type: 'metric',
- })).toThrow();
+describe('Dashboard presentation sub-schemas', () => {
+ it('DashboardHeaderSchema + action', () => {
+ const h = DashboardHeaderSchema.parse({ showTitle: true, actions: [{ label: 'New', actionUrl: '/new', actionType: 'modal' }] });
+ expect(h.actions).toHaveLength(1);
+ expect(DashboardHeaderActionSchema.parse({ label: 'X', actionUrl: '/x' }).label).toBe('X');
});
- it('should reject widget with invalid type enum', () => {
- expect(() => DashboardWidgetSchema.parse({
- type: 'nonexistent_chart',
- layout: { x: 0, y: 0, w: 4, h: 2 },
- })).toThrow();
+ it('WidgetColorVariantSchema + WidgetActionTypeSchema enums', () => {
+ expect(WidgetColorVariantSchema.parse('blue')).toBe('blue');
+ expect(WidgetActionTypeSchema.parse('flow')).toBe('flow');
+ expect(() => WidgetColorVariantSchema.parse('chartreuse')).toThrow();
});
- it('should reject widget with non-numeric layout values', () => {
- expect(() => DashboardWidgetSchema.parse({
- type: 'metric',
- layout: { x: 'a', y: 0, w: 4, h: 2 },
- })).toThrow();
- });
-
- it('should reject widget with incomplete layout', () => {
- expect(() => DashboardWidgetSchema.parse({
- type: 'metric',
- layout: { x: 0, y: 0 },
- })).toThrow();
- });
-
- it('should reject widget with invalid aggregate enum', () => {
- expect(() => DashboardWidgetSchema.parse({
- type: 'metric',
- aggregate: 'median',
- layout: { x: 0, y: 0, w: 4, h: 2 },
- })).toThrow();
- });
-});
-
-describe('DashboardSchema - Negative Validation', () => {
- it('should reject dashboard without name', () => {
- expect(() => DashboardSchema.parse({
- label: 'No Name Dashboard',
- widgets: [],
- })).toThrow();
- });
-
- it('should reject dashboard without label', () => {
- expect(() => DashboardSchema.parse({
- name: 'no_label',
- widgets: [],
- })).toThrow();
- });
-
- it('should reject dashboard without widgets', () => {
- expect(() => DashboardSchema.parse({
- name: 'no_widgets',
- label: 'Missing Widgets',
- })).toThrow();
- });
-
- it('should reject dashboard with camelCase name', () => {
- expect(() => DashboardSchema.parse({
- name: 'salesDashboard',
- label: 'Bad Name',
- widgets: [],
- })).toThrow();
- });
-});
-
-describe('GlobalFilterSchema - Negative Validation', () => {
- it('should reject filter without field', () => {
- expect(() => GlobalFilterSchema.parse({
- label: 'No Field',
- })).toThrow();
- });
-
- it('should reject filter with invalid type enum', () => {
- expect(() => GlobalFilterSchema.parse({
- field: 'status',
- type: 'invalid_type',
- })).toThrow();
- });
-});
-
-// ============================================================================
-// Issue #2: DashboardWidget required `id` field
-// ============================================================================
-describe('DashboardWidgetSchema - required id field', () => {
- it('should reject widget without id', () => {
- expect(() => DashboardWidgetSchema.parse({
- type: 'metric',
- layout: { x: 0, y: 0, w: 3, h: 2 },
- })).toThrow();
- });
-
- it('should enforce snake_case for widget id', () => {
- expect(() => DashboardWidgetSchema.parse({
- id: 'revenue_widget',
- type: 'metric',
- layout: { x: 0, y: 0, w: 3, h: 2 },
- })).not.toThrow();
-
- expect(() => DashboardWidgetSchema.parse({
- id: 'revenueWidget',
- type: 'metric',
- layout: { x: 0, y: 0, w: 3, h: 2 },
- })).toThrow();
- });
-
- it('should allow targetWidgets to reference widget ids', () => {
- const dashboard = DashboardSchema.parse({
- name: 'filter_ref_dash',
- label: 'Filter Reference Dashboard',
- widgets: [
- { id: 'revenue_chart', type: 'bar', layout: { x: 0, y: 0, w: 6, h: 4 } },
- { id: 'pipeline_table', type: 'table', layout: { x: 6, y: 0, w: 6, h: 4 } },
- ],
- globalFilters: [{
- field: 'region',
- scope: 'widget',
- targetWidgets: ['revenue_chart'],
- }],
- });
- expect(dashboard.widgets[0].id).toBe('revenue_chart');
- expect(dashboard.globalFilters![0].targetWidgets).toEqual(['revenue_chart']);
- });
-});
-
-// ============================================================================
-// Issue #3: WidgetActionTypeSchema unified with ActionSchema.type
-// ============================================================================
-describe('WidgetActionTypeSchema - unified action types', () => {
- it('should accept all five action types matching ActionSchema.type', () => {
- const types = ['script', 'url', 'modal', 'flow', 'api'] as const;
- types.forEach(type => {
- expect(() => WidgetActionTypeSchema.parse(type)).not.toThrow();
- });
- });
-
- it('should accept widget with script action type', () => {
- expect(() => DashboardWidgetSchema.parse({
- id: 'script_widget',
- type: 'metric',
- actionType: 'script',
- actionUrl: 'refresh_data',
- layout: { x: 0, y: 0, w: 3, h: 2 },
- })).not.toThrow();
- });
-
- it('should accept widget with api action type', () => {
- expect(() => DashboardWidgetSchema.parse({
- id: 'api_widget',
- type: 'metric',
- actionType: 'api',
- actionUrl: '/api/refresh',
- layout: { x: 0, y: 0, w: 3, h: 2 },
- })).not.toThrow();
- });
-});
-
-describe('DashboardWidgetSchema — dataset binding (ADR-0021 dual-form)', () => {
- it('accepts a dataset-bound widget (dataset + dimensions + values)', () => {
- expect(() => DashboardWidgetSchema.parse({
- id: 'revenue_by_region', type: 'bar', dataset: 'sales',
- dimensions: ['region'], values: ['revenue'],
- layout: { x: 0, y: 0, w: 6, h: 4 },
- })).not.toThrow();
- });
-
- it('rejects a dataset-bound widget with no values', () => {
- expect(() => DashboardWidgetSchema.parse({
- id: 'bad', type: 'bar', dataset: 'sales', dimensions: ['region'],
- layout: { x: 0, y: 0, w: 6, h: 4 },
- })).toThrowError(/needs `values`/);
- });
-
- it('still accepts a legacy inline widget (object + valueField + aggregate)', () => {
- expect(() => DashboardWidgetSchema.parse({
- id: 'legacy', type: 'metric', object: 'crm_opportunity',
- valueField: 'amount', aggregate: 'sum',
- layout: { x: 0, y: 0, w: 3, h: 2 },
- })).not.toThrow();
+ it('GlobalFilterSchema + GlobalFilterOptionsFromSchema', () => {
+ const f = GlobalFilterSchema.parse({ field: 'owner', type: 'lookup', optionsFrom: { object: 'user', valueField: 'id', labelField: 'name' }, scope: 'dashboard' });
+ expect(f.scope).toBe('dashboard');
+ expect(GlobalFilterOptionsFromSchema.parse({ object: 'user', valueField: 'id', labelField: 'name' }).object).toBe('user');
});
});
diff --git a/packages/spec/src/ui/dashboard.zod.ts b/packages/spec/src/ui/dashboard.zod.ts
index e689b4802..70ee3cef8 100644
--- a/packages/spec/src/ui/dashboard.zod.ts
+++ b/packages/spec/src/ui/dashboard.zod.ts
@@ -4,7 +4,6 @@ import { z } from 'zod';
import { ProtectionSchema } from '../shared/protection.zod';
import { MetadataProtectionFields } from '../kernel/metadata-protection.zod';
import { FilterConditionSchema } from '../data/filter.zod';
-import { DateGranularity } from '../data/query.zod';
import { ChartTypeSchema, ChartConfigSchema } from './chart.zod';
import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod';
import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod';
@@ -69,24 +68,6 @@ export const DashboardHeaderSchema = lazySchema(() => z.object({
actions: z.array(DashboardHeaderActionSchema).optional().describe('Header action buttons'),
}).describe('Dashboard header configuration'));
-/**
- * Widget Measure Schema
- * A single measure definition for multi-measure pivot/matrix widgets.
- */
-export const WidgetMeasureSchema = lazySchema(() => z.object({
- /** Value field to aggregate */
- valueField: z.string().describe('Field to aggregate'),
-
- /** Aggregate function */
- aggregate: z.enum(['count', 'sum', 'avg', 'min', 'max']).default('count').describe('Aggregate function'),
-
- /** Display label for the measure */
- label: I18nLabelSchema.optional().describe('Measure display label'),
-
- /** Number format string (e.g., "$0,0.00", "0.0%") */
- format: z.string().optional().describe('Number format string'),
-}).describe('Widget measure definition'));
-
/**
* Dashboard Widget Schema
* A single component on the dashboard grid.
@@ -116,12 +97,8 @@ export const DashboardWidgetSchema = lazySchema(() => z.object({
* `NavigationItem.requiresObject` so cloud-only widgets (e.g. those
* keyed on `sys_app` / `sys_package_installation`) silently disappear
* in single-environment runtimes instead of rendering a 404 error.
- *
- * Defaults to the widget's `object` field when not explicitly set —
- * any widget that targets an object will be gated on that object's
- * registration. Set this to a different value (or empty string to
- * disable) when the widget should appear even if its `object` is
- * unavailable.
+ * Set explicitly to the dataset's base object when the widget should be
+ * gated on that object's availability.
*/
requiresObject: z.string().optional().describe('Hide the widget unless the named object is registered'),
@@ -140,11 +117,8 @@ export const DashboardWidgetSchema = lazySchema(() => z.object({
/** Icon for the widget header action button */
actionIcon: z.string().optional().describe('Icon identifier for the widget action button'),
- /** Data Source Object */
- object: z.string().optional().describe('Data source object name'),
-
- /** Data Filter (MongoDB-style FilterCondition) */
- filter: FilterConditionSchema.optional().describe('Data filter criteria'),
+ /** Presentation-scope filter (MongoDB-style), ANDed into the dataset query as `runtimeFilter`. */
+ filter: FilterConditionSchema.optional().describe('Presentation-scope filter (runtimeFilter)'),
/**
* Period-over-period comparison primitive.
@@ -171,46 +145,19 @@ export const DashboardWidgetSchema = lazySchema(() => z.object({
}),
]).optional().describe('Period-over-period comparison window'),
- /** Category Field (X-Axis / Group By) */
- categoryField: z.string().optional().describe('Field for grouping (X-Axis)'),
-
- /**
- * Date Bucketing Granularity for `categoryField`
- *
- * When set and `categoryField` references a date/datetime field, the engine
- * buckets values into uniform `day` / `week` / `month` / `quarter` / `year`
- * periods server-side (PostgreSQL `date_trunc`, MySQL `date_format`, SQLite
- * `strftime`, MongoDB `$dateTrunc`; falls back to in-memory ISO-8601
- * bucketing otherwise). Without this, raw timestamps are grouped verbatim
- * which typically yields one bucket per row — making time-series charts
- * appear flat.
- *
- * Mirrors the `dateGranularity` shape of {@link GroupByNodeSchema}.
- */
- categoryGranularity: DateGranularity.optional().describe('Bucket categoryField date values into day/week/month/quarter/year periods'),
-
- /** Value Field (Y-Axis) */
- valueField: z.string().optional().describe('Field for values (Y-Axis)'),
-
- /** Aggregate operation */
- aggregate: z.enum(['count', 'sum', 'avg', 'min', 'max']).optional().default('count').describe('Aggregate function'),
-
- /** Multi-measure definitions for pivot/matrix widgets */
- measures: z.array(WidgetMeasureSchema).optional().describe('Multiple measures for pivot/matrix analysis'),
-
/**
- * ADR-0021 — bind this widget to a semantic-layer `dataset` (governed,
- * consistent metrics) instead of the inline `object` + `categoryField` +
- * `valueField` + `aggregate` query. A dataset-bound widget selects
- * dimensions/measures BY NAME from the dataset; numbers stay consistent with
- * every other surface that uses the same dataset. Additive / dual-form:
- * inline widgets are unchanged.
+ * ADR-0021 — the semantic-layer `dataset` this widget binds to. The widget
+ * selects the dataset's dimensions/measures BY NAME; the dataset owns the base
+ * object, allowed joins, intrinsic filter, dimensions, and certified measures,
+ * so numbers stay consistent across every surface. This is the single
+ * author-facing analytics shape (the legacy inline `object` + `categoryField`
+ * + `valueField` + `aggregate` query was removed in the single-form cutover).
*/
- dataset: SnakeCaseIdentifierSchema.optional().describe('Dataset name to bind (ADR-0021)'),
- /** Dimension names (from the dataset) for X / group / split. Dataset-bound only. */
- dimensions: z.array(z.string()).optional().describe('Dimension names — X/group/split (dataset-bound)'),
- /** Measure names (from the dataset) for the value axis. Dataset-bound only. */
- values: z.array(z.string()).optional().describe('Measure names — Y (dataset-bound)'),
+ dataset: SnakeCaseIdentifierSchema.describe('Dataset name to bind (ADR-0021)'),
+ /** Dimension names (from the dataset) for X / group / split. */
+ dimensions: z.array(z.string()).optional().describe('Dimension names — X/group/split'),
+ /** Measure names (from the dataset) for the value axis. */
+ values: z.array(z.string()).min(1).describe('Measure names — Y (at least one)'),
/**
* Layout Position (React-Grid-Layout style)
@@ -234,15 +181,8 @@ export const DashboardWidgetSchema = lazySchema(() => z.object({
/** ARIA accessibility attributes */
aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'),
-}).superRefine((w, ctx) => {
- // ADR-0021 dual-form: a dataset-bound widget needs at least one measure name.
- if (w.dataset && (!w.values || w.values.length === 0)) {
- ctx.addIssue({
- code: 'custom',
- message: 'a dataset-bound widget needs `values` (measure names from the dataset).',
- path: ['values'],
- });
- }
+ // ADR-0021 single-form: every widget binds a `dataset` and selects `values`
+ // (both required above) — there is no inline-query shape to disambiguate.
}));
/**
@@ -386,7 +326,6 @@ export type DashboardInput = z.input;
export type DashboardWidget = z.infer;
export type DashboardHeader = z.infer;
export type DashboardHeaderAction = z.infer;
-export type WidgetMeasure = z.infer;
export type WidgetColorVariant = z.infer;
export type WidgetActionType = z.infer;
export type GlobalFilter = z.infer;
diff --git a/packages/spec/src/ui/report.test.ts b/packages/spec/src/ui/report.test.ts
index 3df2dd3fc..8505eda90 100644
--- a/packages/spec/src/ui/report.test.ts
+++ b/packages/spec/src/ui/report.test.ts
@@ -1,583 +1,96 @@
+// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
+
import { describe, it, expect } from 'vitest';
import {
ReportSchema,
- ReportColumnSchema,
- ReportGroupingSchema,
ReportChartSchema,
ReportType,
Report,
- type ReportColumn,
- type ReportGrouping,
- type ReportChart,
+ JoinedReportBlockSchema,
} from './report.zod';
-describe('ReportType', () => {
- it('should accept valid report types', () => {
- const validTypes = ['tabular', 'summary', 'matrix', 'joined'];
-
- validTypes.forEach(type => {
- expect(() => ReportType.parse(type)).not.toThrow();
- });
- });
-
- it('should reject invalid report types', () => {
- expect(() => ReportType.parse('list')).toThrow();
- expect(() => ReportType.parse('grid')).toThrow();
- expect(() => ReportType.parse('')).toThrow();
- });
-});
-
-describe('ReportColumnSchema', () => {
- it('should accept valid minimal column', () => {
- const column: ReportColumn = {
- field: 'name',
- };
-
- expect(() => ReportColumnSchema.parse(column)).not.toThrow();
- });
-
- it('should accept column with all fields', () => {
- const column = ReportColumnSchema.parse({
- field: 'amount',
- label: 'Total Amount',
- aggregate: 'sum',
- });
-
- expect(column.label).toBe('Total Amount');
- expect(column.aggregate).toBe('sum');
- });
-
- it('should accept different aggregate functions', () => {
- const aggregates: Array> = ['sum', 'avg', 'max', 'min', 'count', 'unique'];
-
- aggregates.forEach(aggregate => {
- const column = ReportColumnSchema.parse({
- field: 'value',
- aggregate,
- });
- expect(column.aggregate).toBe(aggregate);
- });
- });
-
- it('should reject invalid aggregate function', () => {
- expect(() => ReportColumnSchema.parse({
- field: 'value',
- aggregate: 'median',
- })).toThrow();
- });
-});
-
-describe('ReportGroupingSchema', () => {
- it('should accept valid minimal grouping', () => {
- const grouping: ReportGrouping = {
- field: 'category',
- };
-
- expect(() => ReportGroupingSchema.parse(grouping)).not.toThrow();
- });
-
- it('should apply default sort order', () => {
- const grouping = ReportGroupingSchema.parse({
- field: 'category',
- });
-
- expect(grouping.sortOrder).toBe('asc');
- });
-
- it('should accept grouping with all fields', () => {
- const grouping = ReportGroupingSchema.parse({
- field: 'created_date',
- sortOrder: 'desc',
- dateGranularity: 'month',
+/**
+ * ADR-0021 single-form: a report binds a `dataset` and selects `rows`
+ * (dimensions) + `values` (measures). The legacy inline `objectName` +
+ * `columns` + `groupings` query was removed in the cutover. A `joined` report
+ * carries its data on `blocks`, each itself dataset-bound.
+ */
+describe('ReportSchema (dataset-bound)', () => {
+ it('accepts a summary report (dataset + rows + values)', () => {
+ const r = ReportSchema.parse({
+ name: 'sales_by_stage', label: 'Sales by Stage', type: 'summary',
+ dataset: 'sales', rows: ['stage'], values: ['revenue'],
});
-
- expect(grouping.sortOrder).toBe('desc');
- expect(grouping.dateGranularity).toBe('month');
+ expect(r.dataset).toBe('sales');
+ expect(r.rows).toEqual(['stage']);
});
- it('should accept different sort orders', () => {
- const orders: Array = ['asc', 'desc'];
-
- orders.forEach(sortOrder => {
- const grouping = ReportGroupingSchema.parse({
- field: 'name',
- sortOrder,
- });
- expect(grouping.sortOrder).toBe(sortOrder);
+ it('accepts a matrix report (rows = down × across, flattened) + runtimeFilter', () => {
+ const r = ReportSchema.parse({
+ name: 'hours_matrix', label: 'Hours', type: 'matrix',
+ dataset: 'tasks', rows: ['owner', 'category'], values: ['est_hours', 'actual_hours'],
+ runtimeFilter: { is_completed: true },
});
+ expect(r.rows).toHaveLength(2);
+ expect(r.runtimeFilter).toEqual({ is_completed: true });
});
- it('should accept different date granularities', () => {
- const granularities: Array> = ['day', 'week', 'month', 'quarter', 'year'];
-
- granularities.forEach(dateGranularity => {
- const grouping = ReportGroupingSchema.parse({
- field: 'date',
- dateGranularity,
- });
- expect(grouping.dateGranularity).toBe(dateGranularity);
+ it('accepts an embedded chart', () => {
+ const r = ReportSchema.parse({
+ name: 'rep_x', label: 'R', type: 'summary', dataset: 'sales', rows: ['stage'], values: ['revenue'],
+ chart: { type: 'bar', xAxis: 'stage', yAxis: 'revenue' },
});
+ expect(r.chart?.xAxis).toBe('stage');
});
-});
-describe('ReportChartSchema', () => {
- it('should accept valid minimal chart', () => {
- const chart: ReportChart = {
- type: 'bar',
- xAxis: 'category',
- yAxis: 'total_amount',
- };
-
- expect(() => ReportChartSchema.parse(chart)).not.toThrow();
+ it('rejects a (non-joined) report with no dataset', () => {
+ expect(() => ReportSchema.parse({ name: 'rep_x', label: 'R', type: 'summary', rows: ['stage'], values: ['revenue'] })).toThrow();
});
- it('should apply default showLegend', () => {
- const chart = ReportChartSchema.parse({
- type: 'pie',
- xAxis: 'category',
- yAxis: 'count',
- });
-
- expect(chart.showLegend).toBe(true);
+ it('rejects a (non-joined) report with no values', () => {
+ expect(() => ReportSchema.parse({ name: 'rep_x', label: 'R', type: 'summary', dataset: 'sales', rows: ['stage'] })).toThrow();
});
- it('should accept chart with all fields', () => {
- const chart = ReportChartSchema.parse({
- type: 'column',
- title: 'Sales by Region',
- showLegend: false,
- xAxis: 'region',
- yAxis: 'total_sales',
- });
-
- expect(chart.title).toBe('Sales by Region');
- expect(chart.showLegend).toBe(false);
+ it('a report supplying only the removed inline fields is invalid', () => {
+ expect(() => ReportSchema.parse({ name: 'rep_x', label: 'R', type: 'summary', objectName: 'opportunity', columns: [{ field: 'amount' }] } as any)).toThrow();
});
- it('should accept different chart types', () => {
- const types: Array = [
- 'bar', 'column', 'line', 'pie', 'donut', 'scatter', 'funnel',
- 'area', 'gauge', 'treemap', 'sankey', 'metric'
- ];
-
- types.forEach(type => {
- const chart = ReportChartSchema.parse({
- type,
- xAxis: 'x',
- yAxis: 'y',
- });
- expect(chart.type).toBe(type);
- });
+ it('Report.create factory parses + returns a typed report', () => {
+ const r = Report.create({ name: 'rep_x', label: 'R', type: 'summary', dataset: 'sales', rows: ['stage'], values: ['revenue'] });
+ expect(r.name).toBe('rep_x');
});
- it('should reject invalid chart type', () => {
- expect(() => ReportChartSchema.parse({
- type: 'invalid-chart-type',
- xAxis: 'x',
- yAxis: 'y',
- })).toThrow();
+ it('ReportType enum', () => {
+ expect(ReportType.parse('matrix')).toBe('matrix');
+ expect(() => ReportType.parse('nope')).toThrow();
});
});
-describe('ReportSchema', () => {
- it('should accept valid minimal report', () => {
- const report = Report.create({
- name: 'sales_report',
- label: 'Sales Report',
- objectName: 'opportunity',
- columns: [
- { field: 'name' },
- { field: 'amount' },
- ],
- });
-
- expect(report.name).toBe('sales_report');
- });
-
- it('should validate report name format (snake_case)', () => {
- expect(() => ReportSchema.parse({
- name: 'valid_report_name',
- label: 'Valid Report',
- objectName: 'account',
- columns: [{ field: 'name' }],
- })).not.toThrow();
-
- expect(() => ReportSchema.parse({
- name: 'InvalidReport',
- label: 'Invalid',
- objectName: 'account',
- columns: [{ field: 'name' }],
- })).toThrow();
-
- expect(() => ReportSchema.parse({
- name: 'invalid-report',
- label: 'Invalid',
- objectName: 'account',
- columns: [{ field: 'name' }],
- })).toThrow();
- });
-
- it('should apply default report type', () => {
- const report = ReportSchema.parse({
- name: 'test_report',
- label: 'Test Report',
- objectName: 'account',
- columns: [{ field: 'name' }],
- });
-
- expect(report.type).toBe('tabular');
- });
-
- it('should accept report with all fields', () => {
- const report = ReportSchema.parse({
- name: 'full_report',
- label: 'Full Report',
- description: 'Complete report with all features',
- objectName: 'opportunity',
- type: 'summary',
- columns: [
- { field: 'stage' },
- { field: 'amount', aggregate: 'sum', label: 'Total Amount' },
- ],
- groupingsDown: [
- { field: 'stage', sortOrder: 'asc' },
- ],
- filter: { stage: { $ne: 'Closed Lost' } },
- chart: {
- type: 'bar',
- title: 'Opportunities by Stage',
- xAxis: 'stage',
- yAxis: 'total_amount',
- },
- });
-
- expect(report.type).toBe('summary');
- expect(report.groupingsDown).toHaveLength(1);
- expect(report.chart).toBeDefined();
- });
-
- it('should accept different report types', () => {
- const types: Array> = ['tabular', 'summary', 'matrix', 'joined'];
-
- types.forEach(type => {
- const report = ReportSchema.parse({
- name: 'test_report',
- label: 'Test',
- objectName: 'account',
- type,
- columns: [{ field: 'name' }],
- });
- expect(report.type).toBe(type);
- });
- });
-
- it('should accept tabular report', () => {
- const report = ReportSchema.parse({
- name: 'account_list',
- label: 'Account List',
- objectName: 'account',
- type: 'tabular',
- columns: [
- { field: 'name' },
- { field: 'industry' },
- { field: 'annual_revenue' },
- ],
- });
-
- expect(report.type).toBe('tabular');
- expect(report.columns).toHaveLength(3);
- });
-
- it('should accept summary report with grouping', () => {
- const report = ReportSchema.parse({
- name: 'sales_by_region',
- label: 'Sales by Region',
- objectName: 'opportunity',
- type: 'summary',
- columns: [
- { field: 'region' },
- { field: 'amount', aggregate: 'sum' },
- { field: 'opportunity_id', aggregate: 'count' },
- ],
- groupingsDown: [
- { field: 'region', sortOrder: 'asc' },
- ],
- });
-
- expect(report.type).toBe('summary');
- expect(report.groupingsDown).toBeDefined();
- });
-
- it('should accept matrix report with row and column groupings', () => {
- const report = ReportSchema.parse({
- name: 'sales_matrix',
- label: 'Sales Matrix',
- objectName: 'opportunity',
- type: 'matrix',
- columns: [
- { field: 'amount', aggregate: 'sum' },
- ],
- groupingsDown: [
- { field: 'stage' },
- ],
- groupingsAcross: [
- { field: 'type' },
- ],
- });
-
- expect(report.type).toBe('matrix');
- expect(report.groupingsDown).toBeDefined();
- expect(report.groupingsAcross).toBeDefined();
- });
-
- it('should accept report with filter criteria', () => {
- const report = ReportSchema.parse({
- name: 'high_value_opportunities',
- label: 'High Value Opportunities',
- objectName: 'opportunity',
- columns: [{ field: 'name' }, { field: 'amount' }],
- filter: {
- amount: { $gte: 100000 },
- stage: { $ne: 'Closed Lost' },
- },
- });
-
- expect(report.filter).toBeDefined();
- });
-
- it('should accept report with embedded chart', () => {
- const report = ReportSchema.parse({
- name: 'revenue_chart',
- label: 'Revenue Chart',
- objectName: 'opportunity',
- columns: [
- { field: 'stage' },
- { field: 'amount', aggregate: 'sum' },
- ],
- groupingsDown: [{ field: 'stage' }],
- chart: {
- type: 'pie',
- title: 'Revenue by Stage',
- xAxis: 'stage',
- yAxis: 'total_amount',
- },
- });
-
- expect(report.chart?.type).toBe('pie');
- });
-
- it('should accept report with date grouping', () => {
- const report = ReportSchema.parse({
- name: 'monthly_sales',
- label: 'Monthly Sales',
- objectName: 'opportunity',
- type: 'summary',
- columns: [
- { field: 'close_date' },
- { field: 'amount', aggregate: 'sum' },
- ],
- groupingsDown: [
- { field: 'close_date', dateGranularity: 'month' },
- ],
- });
-
- expect(report.groupingsDown?.[0].dateGranularity).toBe('month');
- });
-
- it('should accept report with multiple aggregations', () => {
- const report = ReportSchema.parse({
- name: 'opportunity_stats',
- label: 'Opportunity Statistics',
- objectName: 'opportunity',
- columns: [
- { field: 'stage' },
- { field: 'amount', aggregate: 'sum', label: 'Total' },
- { field: 'amount', aggregate: 'avg', label: 'Average' },
- { field: 'amount', aggregate: 'max', label: 'Maximum' },
- { field: 'opportunity_id', aggregate: 'count', label: 'Count' },
+describe('Joined reports', () => {
+ it('accepts a joined report whose blocks are dataset-bound', () => {
+ const r = ReportSchema.parse({
+ name: 'overview', label: 'Overview', type: 'joined',
+ blocks: [
+ { name: 'open_block', label: 'Open', type: 'summary', dataset: 'tasks', rows: ['status'], values: ['task_count'], runtimeFilter: { done: false } },
+ { name: 'done_block', label: 'Done', type: 'summary', dataset: 'tasks', rows: ['status'], values: ['task_count'], runtimeFilter: { done: true } },
],
- groupingsDown: [{ field: 'stage' }],
- });
-
- expect(report.columns).toHaveLength(5);
- });
-
- it('should reject report without required fields', () => {
- expect(() => ReportSchema.parse({
- label: 'Test Report',
- objectName: 'account',
- columns: [{ field: 'name' }],
- })).toThrow();
-
- expect(() => ReportSchema.parse({
- name: 'test_report',
- objectName: 'account',
- columns: [{ field: 'name' }],
- })).toThrow();
-
- expect(() => ReportSchema.parse({
- name: 'test_report',
- label: 'Test Report',
- columns: [{ field: 'name' }],
- })).toThrow();
-
- expect(() => ReportSchema.parse({
- name: 'test_report',
- label: 'Test Report',
- objectName: 'account',
- })).toThrow();
- });
-});
-
-describe('Report I18n Integration', () => {
- it('should reject i18n object as report label', () => {
- expect(() => ReportSchema.parse({
- name: 'i18n_report',
- label: { key: 'reports.sales', defaultValue: 'Sales Report' },
- objectName: 'opportunity',
- columns: [{ field: 'name' }],
- })).toThrow();
- });
- it('should reject i18n object as column label', () => {
- expect(() => ReportColumnSchema.parse({
- field: 'amount',
- label: { key: 'columns.amount', defaultValue: 'Amount' },
- })).toThrow();
- });
-});
-
-describe('Report ARIA Integration', () => {
- it('should accept report with ARIA attributes', () => {
- expect(() => ReportSchema.parse({
- name: 'accessible_report',
- label: 'Accessible Report',
- objectName: 'account',
- columns: [{ field: 'name' }],
- aria: { ariaLabel: 'Account listing report', role: 'table' },
- })).not.toThrow();
- });
-});
-
-describe('Report Responsive Integration', () => {
- it('should accept column with responsive config', () => {
- const result = ReportColumnSchema.parse({
- field: 'phone',
- label: 'Phone',
- responsive: { hiddenOn: ['xs', 'sm'] },
});
- expect(result.responsive?.hiddenOn).toEqual(['xs', 'sm']);
- });
-});
-
-describe('Report Performance Integration', () => {
- it('should accept report with performance config', () => {
- expect(() => ReportSchema.parse({
- name: 'perf_report',
- label: 'Performance Report',
- objectName: 'opportunity',
- columns: [{ field: 'name' }],
- performance: { lazyLoad: true, pageSize: 50, virtualScroll: { enabled: true, itemHeight: 36 } },
- })).not.toThrow();
- });
-});
-
-// ============================================================================
-// Negative / Inverse Validation Tests
-// ============================================================================
-
-describe('ReportSchema - Negative Validation', () => {
- it('should reject report without name', () => {
- expect(() => ReportSchema.parse({
- label: 'No Name Report',
- objectName: 'contact',
- columns: [{ field: 'name' }],
- })).toThrow();
- });
-
- it('should reject report without label', () => {
- expect(() => ReportSchema.parse({
- name: 'no_label',
- objectName: 'contact',
- columns: [{ field: 'name' }],
- })).toThrow();
- });
-
- it('should reject report without objectName', () => {
- expect(() => ReportSchema.parse({
- name: 'no_object',
- label: 'No Object Report',
- columns: [{ field: 'name' }],
- })).toThrow();
- });
-
- it('should reject report without columns', () => {
- expect(() => ReportSchema.parse({
- name: 'no_columns',
- label: 'No Columns Report',
- objectName: 'contact',
- })).toThrow();
+ expect(r.blocks).toHaveLength(2);
});
- it('should reject report with camelCase name', () => {
- expect(() => ReportSchema.parse({
- name: 'myReport',
- label: 'CamelCase Name',
- objectName: 'contact',
- columns: [{ field: 'name' }],
- })).toThrow();
+ it('rejects a joined report with no blocks', () => {
+ expect(() => ReportSchema.parse({ name: 'rep_x', label: 'R', type: 'joined' })).toThrow();
});
- it('should reject report with invalid type enum', () => {
- expect(() => ReportSchema.parse({
- name: 'invalid_type',
- label: 'Invalid Type',
- objectName: 'contact',
- type: 'pivot',
- columns: [{ field: 'name' }],
- })).toThrow();
- });
-
- it('should reject report column without field', () => {
- expect(() => ReportColumnSchema.parse({
- label: 'No Field',
- })).toThrow();
+ it('JoinedReportBlockSchema parses a dataset-bound block', () => {
+ const b = JoinedReportBlockSchema.parse({ name: 'blk_x', type: 'summary', dataset: 'tasks', rows: ['status'], values: ['task_count'] });
+ expect(b.dataset).toBe('tasks');
});
});
-describe('ReportSchema — dataset binding (ADR-0021 dual-form)', () => {
- it('accepts a dataset-bound report (dataset + values, no objectName/columns)', () => {
- expect(() => ReportSchema.parse({
- name: 'revenue_by_region',
- label: 'Revenue by Region',
- dataset: 'sales',
- rows: ['region'],
- values: ['revenue'],
- })).not.toThrow();
- });
-
- it('rejects a dataset-bound report with no values', () => {
- expect(() => ReportSchema.parse({
- name: 'bad', label: 'Bad', dataset: 'sales', rows: ['region'],
- })).toThrowError(/needs `values`/);
- });
-
- it('still accepts a legacy inline report (objectName + columns)', () => {
- expect(() => ReportSchema.parse({
- name: 'legacy', label: 'Legacy', objectName: 'crm_opportunity',
- columns: [{ field: 'amount', aggregate: 'sum' }],
- })).not.toThrow();
- });
-
- it('rejects an inline report missing objectName/columns (and no dataset)', () => {
- expect(() => ReportSchema.parse({ name: 'bad2', label: 'Bad2' }))
- .toThrowError(/needs `objectName`|needs `columns`/);
- });
-
- it('carries runtimeFilter on a dataset-bound report', () => {
- const r = ReportSchema.parse({
- name: 'scoped', label: 'Scoped', dataset: 'sales', values: ['revenue'],
- runtimeFilter: { stage: 'won' },
- });
- expect((r as any).runtimeFilter).toEqual({ stage: 'won' });
+describe('ReportChartSchema', () => {
+ it('requires xAxis + yAxis', () => {
+ expect(ReportChartSchema.parse({ type: 'bar', xAxis: 'stage', yAxis: 'revenue' }).type).toBe('bar');
+ expect(() => ReportChartSchema.parse({ type: 'bar' })).toThrow();
});
});
diff --git a/packages/spec/src/ui/report.zod.ts b/packages/spec/src/ui/report.zod.ts
index 9a6ee6ca0..2ca4a2268 100644
--- a/packages/spec/src/ui/report.zod.ts
+++ b/packages/spec/src/ui/report.zod.ts
@@ -80,25 +80,13 @@ export const JoinedReportBlockSchema: z.ZodTypeAny = lazySchema(() => z.object({
description: I18nLabelSchema.optional(),
/** Block report type — `joined` is intentionally excluded (no recursion). */
type: z.enum(['tabular', 'summary', 'matrix']).default('tabular'),
- /** Object queried by this block. Defaults to the container's objectName. */
- objectName: z.string().optional(),
- /** Columns to display / aggregate. Same shape as `Report.columns`. */
- columns: z.array(ReportColumnSchema),
- /** Row groupings (same shape as `Report.groupingsDown`). */
- groupingsDown: z.array(ReportGroupingSchema).optional(),
- /** Column groupings — only meaningful when `type: matrix`. */
- groupingsAcross: z.array(ReportGroupingSchema).optional(),
- /** Block-specific filter, ANDed with the container filter at render time. */
- filter: FilterConditionSchema.optional(),
/** Optional inline chart configuration. */
chart: ReportChartSchema.optional(),
/**
- * ADR-0021 — bind this block to a semantic-layer `dataset` (the governed
- * alternative to the block's inline `objectName` + `columns` query), mirroring
- * the top-level report dual-form. An analytics block (summary/matrix) selects
- * dataset measures by name; a record-list block stays inline (it becomes a
- * ListView/embed in the terminal form). Additive / dual-form.
+ * ADR-0021 — the dataset this block binds to (single-form). The block selects
+ * the dataset's measures by name; the legacy inline `objectName` + `columns` +
+ * `groupings` query was removed in the cutover.
*/
dataset: SnakeCaseIdentifierSchema.optional().describe('Dataset name to bind (ADR-0021)'),
/** Dimension names (from the dataset) to group rows by. Dataset-bound only. */
@@ -119,42 +107,25 @@ export const ReportSchema = lazySchema(() => z.object({
label: I18nLabelSchema.describe('Report label'),
description: I18nLabelSchema.optional(),
- /**
- * Data Source — inline single-object query.
- * Optional since ADR-0021: a report may instead bind to a semantic-layer
- * `dataset` (see `dataset`/`rows`/`values` below). Exactly one shape is used
- * per report; the `superRefine` enforces it. Required for the legacy inline
- * shape (no `dataset`).
- */
- objectName: z.string().optional().describe('Primary object (inline-query reports; omit when `dataset` is set)'),
-
/** Report Configuration */
type: ReportType.default('tabular').describe('Report format type'),
- columns: z.array(ReportColumnSchema).optional().describe('Columns to display (inline-query reports)'),
-
/**
- * ADR-0021 — bind to a semantic-layer `dataset` (the governed alternative to
- * the inline `objectName` + `columns` query). When set, the report renders
- * the dataset's named measures grouped by the chosen dimensions — numbers
- * stay consistent with every other surface that uses the same dataset.
- * Additive / dual-form: existing inline reports are unchanged.
+ * ADR-0021 — the semantic-layer `dataset` this report binds to. The report
+ * renders the dataset's named measures grouped by the chosen `rows`
+ * dimensions — numbers stay consistent with every other surface using the
+ * same dataset. This is the single author-facing analytics shape (the legacy
+ * inline `objectName` + `columns` + `groupings` query was removed in the
+ * single-form cutover). For a `joined` report, the data lives on `blocks`.
*/
dataset: SnakeCaseIdentifierSchema.optional().describe('Dataset name to bind (ADR-0021)'),
- /** Dimension names (from the dataset) to group rows by. Dataset-bound only. */
- rows: z.array(z.string()).optional().describe('Dimension names down (dataset-bound)'),
- /** Measure names (from the dataset) to display. Dataset-bound only. */
- values: z.array(z.string()).optional().describe('Measure names to show (dataset-bound)'),
- /** Render-time scope filter, ANDed at query time. Dataset-bound only. */
- runtimeFilter: FilterConditionSchema.optional().describe('Render-time scope filter (dataset-bound)'),
+ /** Dimension names (from the dataset) to group rows by (down axis). */
+ rows: z.array(z.string()).optional().describe('Dimension names down'),
+ /** Measure names (from the dataset) to display. */
+ values: z.array(z.string()).optional().describe('Measure names to show'),
+ /** Render-time scope filter, ANDed at query time. */
+ runtimeFilter: FilterConditionSchema.optional().describe('Render-time scope filter'),
- /** Grouping (for Summary/Matrix) */
- groupingsDown: z.array(ReportGroupingSchema).optional().describe('Row groupings'),
- groupingsAcross: z.array(ReportGroupingSchema).optional().describe('Column groupings (Matrix only)'),
-
- /** Filtering (MongoDB-style FilterCondition) */
- filter: FilterConditionSchema.optional().describe('Filter criteria'),
-
/** Visualization */
chart: ReportChartSchema.optional().describe('Embedded chart configuration'),
@@ -191,32 +162,19 @@ export const ReportSchema = lazySchema(() => z.object({
...MetadataProtectionFields,
}).superRefine((r, ctx) => {
- // ADR-0021 dual-form: a report is EITHER dataset-bound OR an inline query.
- if (r.dataset) {
- if (!r.values || r.values.length === 0) {
- ctx.addIssue({
- code: 'custom',
- message: 'a dataset-bound report needs `values` (measure names from the dataset).',
- path: ['values'],
- });
- }
- } else {
- // Legacy inline shape — keep requiring objectName + columns so existing
- // reports stay valid and a half-specified report is rejected.
- if (!r.objectName) {
- ctx.addIssue({
- code: 'custom',
- message: 'report needs `objectName` (or bind a `dataset`).',
- path: ['objectName'],
- });
- }
- if (!r.columns) {
- ctx.addIssue({
- code: 'custom',
- message: 'report needs `columns` (or bind a `dataset` with `values`).',
- path: ['columns'],
- });
+ // ADR-0021 single-form: a report is dataset-bound. A `joined` report carries
+ // its data on `blocks` (each block dataset-bound); every other type needs a
+ // top-level `dataset` + `values`.
+ if (r.type === 'joined') {
+ if (!r.blocks || r.blocks.length === 0) {
+ ctx.addIssue({ code: 'custom', message: 'a `joined` report needs `blocks`.', path: ['blocks'] });
}
+ } else if (!r.dataset || !r.values || r.values.length === 0) {
+ ctx.addIssue({
+ code: 'custom',
+ message: 'a report needs `dataset` + `values` (measure names).',
+ path: ['dataset'],
+ });
}
}));
diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts
index 93a42afaa..e1a4e404c 100644
--- a/packages/spec/src/ui/view.zod.ts
+++ b/packages/spec/src/ui/view.zod.ts
@@ -303,35 +303,18 @@ export const KanbanConfigSchema = lazySchema(() => z.object({
*/
export const ListChartConfigSchema = lazySchema(() => z.object({
chartType: z.enum(['bar', 'line', 'pie', 'area', 'scatter']).default('bar').describe('Chart visualisation type'),
- // Inline shape — optional since ADR-0021: a charted list view may instead bind
- // to a semantic-layer `dataset` (see `dataset`/`dimensions`/`values` below).
- xAxisField: z.string().optional().describe('Field used as the X axis / category dimension (inline)'),
- yAxisFields: z.array(z.string()).min(1).optional().describe('Field(s) used as the Y axis / measures (inline)'),
- aggregation: z.enum(['sum', 'avg', 'count', 'min', 'max']).optional().describe('Aggregation function applied to Y axis fields (inline)'),
- groupByField: z.string().optional().describe('Optional field used to split / stack the chart (inline)'),
-
/**
- * ADR-0021 — bind this chart to a semantic-layer `dataset` (the governed
- * alternative to the inline `xAxisField` + `yAxisFields` + `aggregation`
- * query), mirroring dashboard widgets. Selects dimensions/measures BY NAME so
- * the chart's numbers stay consistent with every other surface using the same
- * dataset. Additive / dual-form: inline charts are unchanged.
+ * ADR-0021 — the semantic-layer `dataset` this chart binds to. Selects
+ * dimensions/measures BY NAME so the chart's numbers stay consistent with
+ * every other surface using the same dataset. This is the single author-facing
+ * shape (the legacy inline `xAxisField` + `yAxisFields` + `aggregation` query
+ * was removed in the cutover).
*/
- dataset: SnakeCaseIdentifierSchema.optional().describe('Dataset name to bind (ADR-0021)'),
- /** Dimension names (from the dataset) for X / group / split. Dataset-bound only. */
- dimensions: z.array(z.string()).optional().describe('Dimension names — X/group/split (dataset-bound)'),
- /** Measure names (from the dataset) for the value axis. Dataset-bound only. */
- values: z.array(z.string()).optional().describe('Measure names — Y (dataset-bound)'),
-}).superRefine((c, ctx) => {
- // ADR-0021 dual-form: a chart is EITHER dataset-bound (needs `values`) OR an
- // inline query (needs `xAxisField` + `yAxisFields`).
- if (c.dataset) {
- if (!c.values || c.values.length === 0) {
- ctx.addIssue({ code: 'custom', message: 'a dataset-bound chart needs `values` (measure names).', path: ['values'] });
- }
- } else if (!c.xAxisField || !c.yAxisFields || c.yAxisFields.length === 0) {
- ctx.addIssue({ code: 'custom', message: 'an inline chart needs `xAxisField` + `yAxisFields` (or bind a `dataset`).', path: ['xAxisField'] });
- }
+ dataset: SnakeCaseIdentifierSchema.describe('Dataset name to bind (ADR-0021)'),
+ /** Dimension names (from the dataset) for X / group / split. */
+ dimensions: z.array(z.string()).optional().describe('Dimension names — X/group/split'),
+ /** Measure names (from the dataset) for the value axis. */
+ values: z.array(z.string()).min(1).describe('Measure names — Y (at least one)'),
}).describe('List chart view configuration'));
/**