Skip to content

Commit 6dff2bb

Browse files
authored
Merge pull request #592 from objectstack-ai/copilot/add-pivottable-component
2 parents ba53b4a + e43829d commit 6dff2bb

7 files changed

Lines changed: 496 additions & 1 deletion

File tree

ROADMAP.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ All 4 phases complete across 5 designers (Page, View, DataModel, Process, Report
177177
- [x] Add `sharing` spec property support to ListView — Share button in toolbar with visibility level
178178

179179
#### P1.3 Advanced View Features
180+
- [x] PivotTable component (`plugin-dashboard`) — cross-tabulation with sum/count/avg/min/max, row/column totals, format, columnColors
180181
- [ ] Inline task editing for Gantt chart
181182
- [ ] Marker clustering for map plugin (Supercluster for 100+ markers)
182183
- [ ] Combo chart support (e.g., bar + line overlay)

packages/plugin-dashboard/src/DashboardRenderer.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,15 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
9999
};
100100
}
101101

102+
if (widgetType === 'pivot') {
103+
const widgetData = (widget as any).data || options.data;
104+
return {
105+
type: 'pivot',
106+
...options,
107+
data: Array.isArray(widgetData) ? widgetData : widgetData?.items || [],
108+
};
109+
}
110+
102111
return {
103112
...widget,
104113
...options
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import React, { useMemo } from 'react';
10+
import type { PivotTableSchema, PivotAggregation } from '@object-ui/types';
11+
import { cn } from '@object-ui/components';
12+
13+
export interface PivotTableProps {
14+
schema: PivotTableSchema;
15+
className?: string;
16+
}
17+
18+
/** Apply a simple format string to a number. Supports prefix/suffix like "$,.2f". */
19+
function formatValue(value: number, format?: string): string {
20+
if (!format) return String(value);
21+
22+
let prefix = '';
23+
let suffix = '';
24+
let useGrouping = false;
25+
let decimals: number | undefined;
26+
27+
let fmt = format;
28+
29+
// Extract leading non-format characters as prefix (e.g. "$")
30+
const prefixMatch = fmt.match(/^([^0-9.,#]*)/);
31+
if (prefixMatch && prefixMatch[1]) {
32+
// comma inside the prefix-ish area means grouping, not a literal prefix
33+
const raw = prefixMatch[1];
34+
prefix = raw.replace(',', '');
35+
if (raw.includes(',')) useGrouping = true;
36+
fmt = fmt.slice(prefixMatch[1].length);
37+
}
38+
39+
// Grouping indicator anywhere remaining
40+
if (fmt.includes(',')) {
41+
useGrouping = true;
42+
fmt = fmt.replace(/,/g, '');
43+
}
44+
45+
// Decimal specifier e.g. ".2f"
46+
const decMatch = fmt.match(/\.(\d+)f?/);
47+
if (decMatch) {
48+
decimals = Number(decMatch[1]);
49+
fmt = fmt.slice(decMatch[0].length);
50+
}
51+
52+
// Remaining characters become suffix
53+
suffix = fmt.replace(/[0-9#.f]/g, '');
54+
55+
const formatted = decimals !== undefined ? value.toFixed(decimals) : String(value);
56+
57+
if (useGrouping) {
58+
const [intPart, decPart] = formatted.split('.');
59+
const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
60+
return prefix + (decPart !== undefined ? `${grouped}.${decPart}` : grouped) + suffix;
61+
}
62+
63+
return prefix + formatted + suffix;
64+
}
65+
66+
/** Aggregate an array of numbers with the given function. */
67+
function aggregate(values: number[], fn: PivotAggregation): number {
68+
if (values.length === 0) return 0;
69+
switch (fn) {
70+
case 'sum':
71+
return values.reduce((a, b) => a + b, 0);
72+
case 'count':
73+
return values.length;
74+
case 'avg':
75+
return values.reduce((a, b) => a + b, 0) / values.length;
76+
case 'min':
77+
return Math.min(...values);
78+
case 'max':
79+
return Math.max(...values);
80+
default:
81+
return values.reduce((a, b) => a + b, 0);
82+
}
83+
}
84+
85+
/**
86+
* PivotTable – Cross-tabulation / Pivot Table component.
87+
*
88+
* Renders a matrix where rows correspond to `rowField`, columns to
89+
* `columnField`, and cells show the aggregated `valueField`.
90+
*/
91+
export const PivotTable: React.FC<PivotTableProps> = ({ schema, className }) => {
92+
const {
93+
title,
94+
rowField,
95+
columnField,
96+
valueField,
97+
aggregation = 'sum',
98+
data = [],
99+
showRowTotals = false,
100+
showColumnTotals = false,
101+
format,
102+
columnColors,
103+
} = schema;
104+
105+
const { rowKeys, colKeys, matrix, rowTotals, colTotals, grandTotal } = useMemo(() => {
106+
// Collect unique row/column values preserving insertion order
107+
const rowSet = new Map<string, true>();
108+
const colSet = new Map<string, true>();
109+
// Bucket raw values: bucket[row][col] = number[]
110+
const bucket: Record<string, Record<string, number[]>> = {};
111+
112+
for (const item of data) {
113+
const r = String(item[rowField] ?? '');
114+
const c = String(item[columnField] ?? '');
115+
const v = Number(item[valueField]) || 0;
116+
117+
rowSet.set(r, true);
118+
colSet.set(c, true);
119+
120+
if (!bucket[r]) bucket[r] = {};
121+
if (!bucket[r][c]) bucket[r][c] = [];
122+
bucket[r][c].push(v);
123+
}
124+
125+
const rKeys = Array.from(rowSet.keys());
126+
const cKeys = Array.from(colSet.keys());
127+
128+
// Build aggregated matrix
129+
const mat: Record<string, Record<string, number>> = {};
130+
const rTotals: Record<string, number> = {};
131+
const cTotals: Record<string, number> = {};
132+
133+
for (const r of rKeys) {
134+
mat[r] = {};
135+
const rowValues: number[] = [];
136+
for (const c of cKeys) {
137+
const cellValues = bucket[r]?.[c] ?? [];
138+
const cellAgg = aggregate(cellValues, aggregation);
139+
mat[r][c] = cellAgg;
140+
rowValues.push(...cellValues);
141+
142+
// Accumulate column bucket values for column totals
143+
if (!cTotals[c] && cTotals[c] !== 0) {
144+
// Will compute after
145+
}
146+
}
147+
rTotals[r] = aggregate(rowValues, aggregation);
148+
}
149+
150+
// Column totals
151+
for (const c of cKeys) {
152+
const colValues: number[] = [];
153+
for (const r of rKeys) {
154+
const cellValues = bucket[r]?.[c] ?? [];
155+
colValues.push(...cellValues);
156+
}
157+
cTotals[c] = aggregate(colValues, aggregation);
158+
}
159+
160+
// Grand total
161+
const allValues: number[] = [];
162+
for (const item of data) {
163+
allValues.push(Number(item[valueField]) || 0);
164+
}
165+
const gt = aggregate(allValues, aggregation);
166+
167+
return { rowKeys: rKeys, colKeys: cKeys, matrix: mat, rowTotals: rTotals, colTotals: cTotals, grandTotal: gt };
168+
}, [data, rowField, columnField, valueField, aggregation]);
169+
170+
const fmt = (v: number) => formatValue(v, format);
171+
172+
return (
173+
<div className={cn('overflow-auto', className)}>
174+
{title && (
175+
<h3 className="text-sm font-semibold mb-2">{title}</h3>
176+
)}
177+
<table className="w-full text-sm border-collapse" role="table">
178+
<thead>
179+
<tr className="border-b border-border">
180+
<th className="text-left p-2 font-medium text-muted-foreground">{rowField}</th>
181+
{colKeys.map((col) => (
182+
<th
183+
key={col}
184+
className={cn(
185+
'text-right p-2 font-medium',
186+
columnColors?.[col] ?? 'text-muted-foreground',
187+
)}
188+
>
189+
{col}
190+
</th>
191+
))}
192+
{showRowTotals && (
193+
<th className="text-right p-2 font-semibold text-muted-foreground">Total</th>
194+
)}
195+
</tr>
196+
</thead>
197+
<tbody>
198+
{rowKeys.map((row) => (
199+
<tr key={row} className="border-b border-border/50 hover:bg-muted/30">
200+
<td className="p-2 font-medium">{row}</td>
201+
{colKeys.map((col) => (
202+
<td
203+
key={col}
204+
className={cn(
205+
'text-right p-2 tabular-nums',
206+
columnColors?.[col],
207+
)}
208+
>
209+
{fmt(matrix[row]?.[col] ?? 0)}
210+
</td>
211+
))}
212+
{showRowTotals && (
213+
<td className="text-right p-2 font-semibold tabular-nums">
214+
{fmt(rowTotals[row] ?? 0)}
215+
</td>
216+
)}
217+
</tr>
218+
))}
219+
</tbody>
220+
{showColumnTotals && (
221+
<tfoot>
222+
<tr className="border-t-2 border-border font-semibold">
223+
<td className="p-2">Total</td>
224+
{colKeys.map((col) => (
225+
<td key={col} className="text-right p-2 tabular-nums">
226+
{fmt(colTotals[col] ?? 0)}
227+
</td>
228+
))}
229+
{showRowTotals && (
230+
<td className="text-right p-2 tabular-nums">
231+
{fmt(grandTotal)}
232+
</td>
233+
)}
234+
</tr>
235+
</tfoot>
236+
)}
237+
</table>
238+
</div>
239+
);
240+
};

0 commit comments

Comments
 (0)