diff --git a/ROADMAP.md b/ROADMAP.md index 28bd760f0..b2bbdb61b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -177,6 +177,7 @@ All 4 phases complete across 5 designers (Page, View, DataModel, Process, Report - [x] Add `sharing` spec property support to ListView — Share button in toolbar with visibility level #### P1.3 Advanced View Features +- [x] PivotTable component (`plugin-dashboard`) — cross-tabulation with sum/count/avg/min/max, row/column totals, format, columnColors - [ ] Inline task editing for Gantt chart - [ ] Marker clustering for map plugin (Supercluster for 100+ markers) - [ ] Combo chart support (e.g., bar + line overlay) diff --git a/packages/plugin-dashboard/src/DashboardRenderer.tsx b/packages/plugin-dashboard/src/DashboardRenderer.tsx index 7fbe67caa..65d459a3f 100644 --- a/packages/plugin-dashboard/src/DashboardRenderer.tsx +++ b/packages/plugin-dashboard/src/DashboardRenderer.tsx @@ -99,6 +99,15 @@ export const DashboardRenderer = forwardRef a + b, 0); + case 'count': + return values.length; + case 'avg': + return values.reduce((a, b) => a + b, 0) / values.length; + case 'min': + return Math.min(...values); + case 'max': + return Math.max(...values); + default: + return values.reduce((a, b) => a + b, 0); + } +} + +/** + * PivotTable – Cross-tabulation / Pivot Table component. + * + * Renders a matrix where rows correspond to `rowField`, columns to + * `columnField`, and cells show the aggregated `valueField`. + */ +export const PivotTable: React.FC = ({ schema, className }) => { + const { + title, + rowField, + columnField, + valueField, + aggregation = 'sum', + data = [], + showRowTotals = false, + showColumnTotals = false, + format, + columnColors, + } = schema; + + const { rowKeys, colKeys, matrix, rowTotals, colTotals, grandTotal } = useMemo(() => { + // Collect unique row/column values preserving insertion order + const rowSet = new Map(); + const colSet = new Map(); + // Bucket raw values: bucket[row][col] = number[] + const bucket: Record> = {}; + + for (const item of data) { + const r = String(item[rowField] ?? ''); + const c = String(item[columnField] ?? ''); + const v = Number(item[valueField]) || 0; + + rowSet.set(r, true); + colSet.set(c, true); + + if (!bucket[r]) bucket[r] = {}; + if (!bucket[r][c]) bucket[r][c] = []; + bucket[r][c].push(v); + } + + const rKeys = Array.from(rowSet.keys()); + const cKeys = Array.from(colSet.keys()); + + // Build aggregated matrix + const mat: Record> = {}; + const rTotals: Record = {}; + const cTotals: Record = {}; + + for (const r of rKeys) { + mat[r] = {}; + const rowValues: number[] = []; + for (const c of cKeys) { + const cellValues = bucket[r]?.[c] ?? []; + const cellAgg = aggregate(cellValues, aggregation); + mat[r][c] = cellAgg; + rowValues.push(...cellValues); + + // Accumulate column bucket values for column totals + if (!cTotals[c] && cTotals[c] !== 0) { + // Will compute after + } + } + rTotals[r] = aggregate(rowValues, aggregation); + } + + // Column totals + for (const c of cKeys) { + const colValues: number[] = []; + for (const r of rKeys) { + const cellValues = bucket[r]?.[c] ?? []; + colValues.push(...cellValues); + } + cTotals[c] = aggregate(colValues, aggregation); + } + + // Grand total + const allValues: number[] = []; + for (const item of data) { + allValues.push(Number(item[valueField]) || 0); + } + const gt = aggregate(allValues, aggregation); + + return { rowKeys: rKeys, colKeys: cKeys, matrix: mat, rowTotals: rTotals, colTotals: cTotals, grandTotal: gt }; + }, [data, rowField, columnField, valueField, aggregation]); + + const fmt = (v: number) => formatValue(v, format); + + return ( +
+ {title && ( +

{title}

+ )} + + + + + {colKeys.map((col) => ( + + ))} + {showRowTotals && ( + + )} + + + + {rowKeys.map((row) => ( + + + {colKeys.map((col) => ( + + ))} + {showRowTotals && ( + + )} + + ))} + + {showColumnTotals && ( + + + + {colKeys.map((col) => ( + + ))} + {showRowTotals && ( + + )} + + + )} +
{rowField} + {col} + Total
{row} + {fmt(matrix[row]?.[col] ?? 0)} + + {fmt(rowTotals[row] ?? 0)} +
Total + {fmt(colTotals[col] ?? 0)} + + {fmt(grandTotal)} +
+
+ ); +}; diff --git a/packages/plugin-dashboard/src/__tests__/PivotTable.test.tsx b/packages/plugin-dashboard/src/__tests__/PivotTable.test.tsx new file mode 100644 index 000000000..a06733cf9 --- /dev/null +++ b/packages/plugin-dashboard/src/__tests__/PivotTable.test.tsx @@ -0,0 +1,147 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { PivotTable } from '../PivotTable'; +import type { PivotTableSchema } from '@object-ui/types'; + +const SAMPLE_DATA = [ + { owner: 'Alice', stage: 'Discovery', amount: 1000 }, + { owner: 'Alice', stage: 'Proposal', amount: 2000 }, + { owner: 'Alice', stage: 'Discovery', amount: 500 }, + { owner: 'Bob', stage: 'Discovery', amount: 3000 }, + { owner: 'Bob', stage: 'Closed', amount: 5000 }, + { owner: 'Carol', stage: 'Proposal', amount: 4000 }, +]; + +function makeSchema(overrides?: Partial): PivotTableSchema { + return { + type: 'pivot', + rowField: 'owner', + columnField: 'stage', + valueField: 'amount', + data: SAMPLE_DATA, + ...overrides, + }; +} + +describe('PivotTable', () => { + it('should render row and column headers', () => { + render(); + + // Row headers + expect(screen.getByText('Alice')).toBeInTheDocument(); + expect(screen.getByText('Bob')).toBeInTheDocument(); + expect(screen.getByText('Carol')).toBeInTheDocument(); + + // Column headers + expect(screen.getByText('Discovery')).toBeInTheDocument(); + expect(screen.getByText('Proposal')).toBeInTheDocument(); + expect(screen.getByText('Closed')).toBeInTheDocument(); + }); + + it('should aggregate values with sum by default', () => { + render(); + + // Alice + Discovery = 1000 + 500 = 1500 + expect(screen.getByText('1500')).toBeInTheDocument(); + // Alice + Proposal = 2000 + expect(screen.getByText('2000')).toBeInTheDocument(); + // Bob + Discovery = 3000 + expect(screen.getByText('3000')).toBeInTheDocument(); + // Bob + Closed = 5000 + expect(screen.getByText('5000')).toBeInTheDocument(); + // Carol + Proposal = 4000 + expect(screen.getByText('4000')).toBeInTheDocument(); + }); + + it('should support count aggregation', () => { + render(); + + // Alice + Discovery = 2 items + expect(screen.getByText('2')).toBeInTheDocument(); + }); + + it('should support avg aggregation', () => { + render(); + + // Alice + Discovery = avg(1000, 500) = 750 + expect(screen.getByText('750')).toBeInTheDocument(); + }); + + it('should render title when provided', () => { + render(); + + expect(screen.getByText('Revenue Pivot')).toBeInTheDocument(); + }); + + it('should show row totals when showRowTotals is true', () => { + render(); + + // Header "Total" column + const totalHeaders = screen.getAllByText('Total'); + expect(totalHeaders.length).toBeGreaterThanOrEqual(1); + + // Alice total = 1000 + 2000 + 500 = 3500 + expect(screen.getByText('3500')).toBeInTheDocument(); + // Bob total = 3000 + 5000 = 8000 + expect(screen.getByText('8000')).toBeInTheDocument(); + }); + + it('should show column totals when showColumnTotals is true', () => { + render(); + + // Footer row with "Total" label + const totalCells = screen.getAllByText('Total'); + expect(totalCells.length).toBeGreaterThanOrEqual(1); + + // Discovery total = 1000 + 500 + 3000 = 4500 + expect(screen.getByText('4500')).toBeInTheDocument(); + // Proposal total = 2000 + 4000 = 6000 + expect(screen.getByText('6000')).toBeInTheDocument(); + }); + + it('should show grand total when both row and column totals are enabled', () => { + render(); + + // Grand total = 1000 + 2000 + 500 + 3000 + 5000 + 4000 = 15500 + expect(screen.getByText('15500')).toBeInTheDocument(); + }); + + it('should apply format string', () => { + render(); + + // Alice + Discovery = $1,500 + expect(screen.getByText('$1,500')).toBeInTheDocument(); + // Bob + Closed = $5,000 + expect(screen.getByText('$5,000')).toBeInTheDocument(); + }); + + it('should handle empty data gracefully', () => { + const { container } = render(); + + // Should render a table with header row but no body rows + const tbody = container.querySelector('tbody'); + expect(tbody).toBeInTheDocument(); + expect(tbody!.children.length).toBe(0); + }); + + it('should handle missing values in data as 0', () => { + const data = [ + { owner: 'Alice', stage: 'A', amount: 10 }, + { owner: 'Bob', stage: 'B', amount: 20 }, + ]; + render(); + + // Alice × B = 0, Bob × A = 0 should appear + const zeroCells = screen.getAllByText('0'); + expect(zeroCells.length).toBe(2); + }); +}); diff --git a/packages/plugin-dashboard/src/index.tsx b/packages/plugin-dashboard/src/index.tsx index 6ca6b9050..2e44065a3 100644 --- a/packages/plugin-dashboard/src/index.tsx +++ b/packages/plugin-dashboard/src/index.tsx @@ -11,8 +11,9 @@ import { DashboardRenderer } from './DashboardRenderer'; import { DashboardGridLayout } from './DashboardGridLayout'; import { MetricWidget } from './MetricWidget'; import { MetricCard } from './MetricCard'; +import { PivotTable } from './PivotTable'; -export { DashboardRenderer, DashboardGridLayout, MetricWidget, MetricCard }; +export { DashboardRenderer, DashboardGridLayout, MetricWidget, MetricCard, PivotTable }; // Register dashboard component ComponentRegistry.register( @@ -77,6 +78,41 @@ ComponentRegistry.register( } ); +// Register pivot table component +ComponentRegistry.register( + 'pivot', + PivotTable, + { + namespace: 'plugin-dashboard', + label: 'Pivot Table', + category: 'Dashboard', + icon: 'table-2', + inputs: [ + { name: 'title', type: 'string', label: 'Title' }, + { name: 'rowField', type: 'string', label: 'Row Field', required: true }, + { name: 'columnField', type: 'string', label: 'Column Field', required: true }, + { name: 'valueField', type: 'string', label: 'Value Field', required: true }, + { name: 'aggregation', type: 'enum', label: 'Aggregation', enum: [ + { label: 'Sum', value: 'sum' }, + { label: 'Count', value: 'count' }, + { label: 'Average', value: 'avg' }, + { label: 'Min', value: 'min' }, + { label: 'Max', value: 'max' }, + ]}, + { name: 'showRowTotals', type: 'boolean', label: 'Show Row Totals' }, + { name: 'showColumnTotals', type: 'boolean', label: 'Show Column Totals' }, + { name: 'format', type: 'string', label: 'Number Format' }, + ], + defaultProps: { + rowField: '', + columnField: '', + valueField: '', + aggregation: 'sum', + data: [], + } + } +); + // Register dashboard grid layout component ComponentRegistry.register( 'dashboard-grid', @@ -105,4 +141,5 @@ export const dashboardComponents = { DashboardGridLayout, MetricWidget, MetricCard, + PivotTable, }; diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts index 63fdd7419..d3f469e67 100644 --- a/packages/types/src/data-display.ts +++ b/packages/types/src/data-display.ts @@ -614,6 +614,64 @@ export interface ChartSchema extends BaseSchema { config?: Record; } +/** + * Aggregation function for pivot table values + */ +export type PivotAggregation = 'sum' | 'count' | 'avg' | 'min' | 'max'; + +/** + * Pivot table (cross-tabulation) component + * + * Renders a matrix where rows correspond to one field, + * columns to another, and cells show an aggregated value. + */ +export interface PivotTableSchema extends BaseSchema { + type: 'pivot'; + /** + * Pivot table title + */ + title?: string; + /** + * Field used for row headers + */ + rowField: string; + /** + * Field used for column headers + */ + columnField: string; + /** + * Field whose values are aggregated in cells + */ + valueField: string; + /** + * Aggregation function applied to valueField + * @default 'sum' + */ + aggregation?: PivotAggregation; + /** + * Source data rows + */ + data: Record[]; + /** + * Show a totals column on the right + * @default false + */ + showRowTotals?: boolean; + /** + * Show a totals row at the bottom + * @default false + */ + showColumnTotals?: boolean; + /** + * Numeric format string (e.g. "$,.2f") — applied via simple prefix/suffix/decimals + */ + format?: string; + /** + * Mapping of column header values to Tailwind text-color classes + */ + columnColors?: Record; +} + /** * Timeline event */ @@ -727,6 +785,7 @@ export type DataDisplaySchema = | MarkdownSchema | TreeViewSchema | ChartSchema + | PivotTableSchema | TimelineSchema | HtmlSchema | StatisticSchema diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index c317e6f61..f39e4ed9b 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -147,6 +147,8 @@ export type { ChartType, ChartSeries, ChartSchema, + PivotAggregation, + PivotTableSchema, TimelineEvent, TimelineSchema, KbdSchema,