From 4fe41bfed7af1302f5901258440c6d9e7f7cbe8c Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 11 Jun 2026 20:12:57 +0500 Subject: [PATCH] feat(spec): Report matrix `columns` (across) + `drilldown` flag (ADR-0021 D2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A matrix report now pivots `rows` (down) × `columns` (across dimension names) with `values` in the cells — the pivot triple ADR-0021 D2 specified; the flattened rows-only stopgap retires. Joined blocks gain the same `columns` field. `drilldown` (boolean, default true) declares click-through from an aggregated row/cell to the underlying records; the host resolves the dataset's object and dimension→field mapping. reportForm surfaces both in the Dataset binding section (columns gated on type == 'matrix'); the showcase matrix fixture moves to the true pivot form. Co-Authored-By: Claude Fable 5 --- .changeset/report-matrix-columns-drilldown.md | 5 +++++ examples/app-showcase/src/reports/index.ts | 6 +++--- packages/spec/src/ui/report.form.ts | 3 +++ packages/spec/src/ui/report.test.ts | 14 +++++++++++--- packages/spec/src/ui/report.zod.ts | 13 +++++++++++++ 5 files changed, 35 insertions(+), 6 deletions(-) create mode 100644 .changeset/report-matrix-columns-drilldown.md diff --git a/.changeset/report-matrix-columns-drilldown.md b/.changeset/report-matrix-columns-drilldown.md new file mode 100644 index 0000000000..0238bb3e51 --- /dev/null +++ b/.changeset/report-matrix-columns-drilldown.md @@ -0,0 +1,5 @@ +--- +"@objectstack/spec": minor +--- + +ADR-0021 D2: `Report` gains `columns` (dimension names across — a `matrix` report pivots `rows` × `columns` with `values` in the cells; also on joined blocks) and `drilldown` (boolean, default `true` — click an aggregated row/cell to open the underlying records). `reportForm` surfaces both in the Dataset binding section (`columns` visible for matrix only). diff --git a/examples/app-showcase/src/reports/index.ts b/examples/app-showcase/src/reports/index.ts index d50ca71e07..4401240643 100644 --- a/examples/app-showcase/src/reports/index.ts +++ b/examples/app-showcase/src/reports/index.ts @@ -27,10 +27,10 @@ export const StatusPriorityMatrixReport: Report = { label: 'Status × Priority (Matrix)', description: 'Task counts cross-tabulated by status and priority.', type: 'matrix', - // 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. + // ADR-0021 D2 — true pivot: `rows` down × `columns` across, measures in cells. dataset: 'showcase_task_metrics', - rows: ['status', 'priority'], + rows: ['status'], + columns: ['priority'], values: ['est_hours'], }; diff --git a/packages/spec/src/ui/report.form.ts b/packages/spec/src/ui/report.form.ts index c93386c2cd..0c681507ba 100644 --- a/packages/spec/src/ui/report.form.ts +++ b/packages/spec/src/ui/report.form.ts @@ -39,6 +39,9 @@ export const reportForm = defineForm({ { field: 'dataset', widget: 'ref:dataset', helpText: 'Dataset to bind (measures/dimensions come from its semantic layer)' }, { field: 'values', widget: 'string-tags', helpText: 'Measure names (from the dataset) to display' }, { field: 'rows', widget: 'string-tags', helpText: 'Dimension names (from the dataset) to group rows by' }, + // CEL visibility — only Matrix reports pivot across a second dimension. + { field: 'columns', widget: 'string-tags', visibleOn: "data.type == 'matrix'", helpText: 'Dimension names across (matrix only)' }, + { field: 'drilldown', helpText: 'Click an aggregated row/cell to open the underlying records' }, ], }, { diff --git a/packages/spec/src/ui/report.test.ts b/packages/spec/src/ui/report.test.ts index 8505eda907..49325ca214 100644 --- a/packages/spec/src/ui/report.test.ts +++ b/packages/spec/src/ui/report.test.ts @@ -25,16 +25,24 @@ describe('ReportSchema (dataset-bound)', () => { expect(r.rows).toEqual(['stage']); }); - it('accepts a matrix report (rows = down × across, flattened) + runtimeFilter', () => { + it('accepts a matrix report (rows down × columns across) + runtimeFilter', () => { const r = ReportSchema.parse({ name: 'hours_matrix', label: 'Hours', type: 'matrix', - dataset: 'tasks', rows: ['owner', 'category'], values: ['est_hours', 'actual_hours'], + dataset: 'tasks', rows: ['owner'], columns: ['category'], values: ['est_hours', 'actual_hours'], runtimeFilter: { is_completed: true }, }); - expect(r.rows).toHaveLength(2); + expect(r.rows).toEqual(['owner']); + expect(r.columns).toEqual(['category']); expect(r.runtimeFilter).toEqual({ is_completed: true }); }); + it('drilldown defaults on and can be disabled', () => { + const on = ReportSchema.parse({ name: 'r1', label: 'R', type: 'summary', dataset: 'sales', rows: ['stage'], values: ['revenue'] }); + expect(on.drilldown).toBe(true); + const off = ReportSchema.parse({ name: 'r2', label: 'R', type: 'summary', dataset: 'sales', rows: ['stage'], values: ['revenue'], drilldown: false }); + expect(off.drilldown).toBe(false); + }); + it('accepts an embedded chart', () => { const r = ReportSchema.parse({ name: 'rep_x', label: 'R', type: 'summary', dataset: 'sales', rows: ['stage'], values: ['revenue'], diff --git a/packages/spec/src/ui/report.zod.ts b/packages/spec/src/ui/report.zod.ts index 2ca4a22689..38cc05afae 100644 --- a/packages/spec/src/ui/report.zod.ts +++ b/packages/spec/src/ui/report.zod.ts @@ -91,6 +91,8 @@ export const JoinedReportBlockSchema: z.ZodTypeAny = lazySchema(() => z.object({ 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)'), + /** Dimension names across — matrix blocks pivot rows × columns (ADR-0021 D2). */ + columns: z.array(z.string()).optional().describe('Dimension names across (matrix, 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. */ @@ -121,10 +123,21 @@ export const ReportSchema = lazySchema(() => z.object({ dataset: SnakeCaseIdentifierSchema.optional().describe('Dataset name to bind (ADR-0021)'), /** Dimension names (from the dataset) to group rows by (down axis). */ rows: z.array(z.string()).optional().describe('Dimension names down'), + /** + * Dimension names across (ADR-0021 D2) — a `matrix` report pivots + * `rows` × `columns` with `values` in the cells. Ignored for other types. + */ + columns: z.array(z.string()).optional().describe('Dimension names across (matrix)'), /** 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'), + /** + * ADR-0021 D2 — click an aggregated row/cell to open the underlying + * records (dataset-backed; the host resolves the dataset's object and + * dimension→field mapping). Default on; set `false` to disable. + */ + drilldown: z.boolean().default(true).describe('Click-through to underlying records'), /** Visualization */ chart: ReportChartSchema.optional().describe('Embedded chart configuration'),