From 5fbe7ddd6d8c21ae12f50ebefee4e28cfc6c5f55 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Wed, 10 Jun 2026 09:45:27 +0500 Subject: [PATCH] =?UTF-8?q?feat(spec)!:=20ADR-0021=20single-form=20cutover?= =?UTF-8?q?=20=E2=80=94=20delete=20inline=20analytics,=20dataset=20is=20th?= =?UTF-8?q?e=20only=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE (spec major): dashboard widgets, reports, and list-charts no longer accept the inline query form. Every analytics surface must now bind a semantic `dataset` (ADR-0021) and select dimensions/measures BY NAME. Removed from the spec author surface: - DashboardWidget: `object`, `categoryField`, `categoryGranularity`, `valueField`, `aggregate`, `measures` (+ `WidgetMeasure` schema/type). `dataset` + `values` are now required; `filter` is the presentation-scope runtimeFilter; `dimensions`/`compareTo` retained. - Report: top-level `objectName`, `columns`, `groupingsDown`, `groupingsAcross`, `filter` (and the same on joined blocks). A non-joined report now requires `dataset` + `values`; `rows` are the dimensions. - ListChart: `xAxisField`, `yAxisFields`, `aggregation`, `groupByField`. `dataset` + `values` required. This is the convergence step of ADR-0021 Phase 2 (#1658): the dual-form period is over. All in-repo consumers were migrated to dataset-form first (#1673/#1674 and prior), reconciled old==new by the analytics harness, so this cut is mechanical, not behavioural. Migrated in this commit: - spec ui schemas (dashboard/report/view) + focused dataset-form test suites. - stack.test strict-config Todo/CRM fixtures → dataset form. - objectql metadata-write fixtures (protocol-meta, validation-sweep, overlay-precedence) → dataset form. - examples app-todo / app-crm / app-showcase dashboards, reports, views; the CRM trend widget's monthly bucketing moves onto the dataset's close_date dimension (`dateGranularity: 'month'`). - platform-objects system_overview dashboard. - regenerated ui reference docs. Incidental: lite-kernel.test uses a unique per-process temp log path instead of a shared hardcoded /tmp file (the shared file can be owned by another user, causing non-deterministic EACCES under concurrent turbo runs). Full `turbo build` (72/72) and `turbo test` (123/123) green. Co-Authored-By: Claude Opus 4.8 --- content/docs/references/ui/dashboard.mdx | 31 +- content/docs/references/ui/dataset.mdx | 128 ++ content/docs/references/ui/index.mdx | 1 + content/docs/references/ui/meta.json | 1 + content/docs/references/ui/report.mdx | 18 +- content/docs/references/ui/view.mdx | 7 +- .../src/dashboards/pipeline.dashboard.ts | 20 - .../src/datasets/opportunity.dataset.ts | 4 +- .../src/reports/sales-by-stage.report.ts | 6 - examples/app-crm/test/smoke.test.ts | 9 +- .../src/dashboards/chart-gallery.dashboard.ts | 54 +- examples/app-showcase/src/reports/index.ts | 31 +- .../app-showcase/src/views/project.view.ts | 4 +- examples/app-showcase/src/views/task.view.ts | 5 - examples/app-showcase/test/coverage.test.ts | 7 +- examples/app-showcase/test/seed.test.ts | 5 +- .../app-todo/src/dashboards/task.dashboard.ts | 25 - examples/app-todo/src/reports/task.report.ts | 45 - packages/core/src/lite-kernel.test.ts | 8 +- .../src/metadata-validation-sweep.test.ts | 9 +- .../objectql/src/overlay-precedence.test.ts | 7 +- packages/objectql/src/protocol-meta.test.ts | 6 +- .../dashboards/system_overview.dashboard.ts | 42 +- packages/spec/src/stack.test.ts | 33 +- packages/spec/src/ui/dashboard.test.ts | 1863 +---------------- packages/spec/src/ui/dashboard.zod.ts | 95 +- packages/spec/src/ui/report.test.ts | 601 +----- packages/spec/src/ui/report.zod.ts | 96 +- packages/spec/src/ui/view.zod.ts | 37 +- 29 files changed, 436 insertions(+), 2762 deletions(-) create mode 100644 content/docs/references/ui/dataset.mdx 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')); /**