Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/report-matrix-columns-drilldown.md
Original file line number Diff line number Diff line change
@@ -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).
6 changes: 3 additions & 3 deletions examples/app-showcase/src/reports/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
};

Expand Down
3 changes: 3 additions & 0 deletions packages/spec/src/ui/report.form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
],
},
{
Expand Down
14 changes: 11 additions & 3 deletions packages/spec/src/ui/report.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
13 changes: 13 additions & 0 deletions packages/spec/src/ui/report.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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'),
Expand Down