Skip to content

Commit ed6a6fd

Browse files
Copilothotlong
andcommitted
feat: add ObjectPivotTable with async data loading, icon-group controls, and total row styling
- P0: Create ObjectPivotTable component (skeleton/error/empty states, following ObjectChart pattern) - P0: DashboardRenderer routes pivot+objectName to object-pivot renderer - P0: Register object-pivot in ComponentRegistry - P1: Use icon-group controls for Pivot Sort by / Sort order (Airtable visual parity) - P1: Add "Show label" toggle to Pivot Rows/Columns config sections - P4: Improve total/subtotal row styling with bg-muted contrast - Add 8 new ObjectPivotTable tests (182 total passing) - Update ROADMAP.md Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 4bfb20a commit ed6a6fd

8 files changed

Lines changed: 429 additions & 26 deletions

File tree

ROADMAP.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -463,17 +463,19 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind
463463
**Phase 13 — Table/Pivot Widget Enhancements & Context-Aware Config Panel:**
464464
- [x] Add `pivot` to `DASHBOARD_WIDGET_TYPES` constant and `WidgetConfigPanel` type options dropdown
465465
- [x] Context-aware `WidgetConfigPanel`: sections shown/hidden via `visibleWhen` based on widget type — Pivot shows Rows/Columns/Values/Totals, Chart shows Axis & Series, Table shows Columns config
466-
- [x] Pivot-specific config: Row field, Column field, Value field, Sort by (Group/Value), Sort order (Asc/Desc), Show totals toggle for both rows and columns, Aggregation, Number format
466+
- [x] Pivot-specific config: Row field, Column field, Value field, Sort by (Group/Value icon-group), Sort order (↑/↓ icon-group), Show label toggle, Show totals toggle for both rows and columns, Aggregation, Number format
467467
- [x] Chart-specific config: X-axis label, Y-axis label, Show legend toggle
468468
- [x] Table-specific config: Searchable toggle, Pagination toggle
469469
- [x] Breadcrumb adapts to widget type ("Pivot table", "Table", "Chart", "Widget")
470470
- [x] I18nLabel resolution: `WidgetConfigPanel` pre-processes `title` and `description` config values via `resolveLabel()` to prevent `[object Object]` display
471471
- [x] `DashboardRenderer`: widget description rendered in card headers with `line-clamp-2`; I18nLabel resolved via `resolveLabel()`
472-
- [x] `DashboardRenderer`: pivot widget with `widget.object` but no explicit `data` provider now correctly sets `objectName` and empty `data: []` (parity with table/chart)
472+
- [x] `ObjectPivotTable`: new async-aware pivot wrapper (following ObjectChart pattern) — skeleton loading, error state, no-data-source message, empty state delegation to PivotTable
473+
- [x] `DashboardRenderer`: pivot widgets with `objectName` or `provider: 'object'` routed to `object-pivot` type (ObjectPivotTable) for async data loading
473474
- [x] `DashboardRenderer`: grid column clamping — widget `layout.w` clamped to `Math.min(w, columns)` preventing layout overflow
474475
- [x] `MetricWidget`: overflow protection — `overflow-hidden` on Card, `truncate` on label/value/description, `shrink-0` on icon/trend
475476
- [x] `PivotTable`: friendly empty state with grid icon + "No data available" message instead of empty table body
476-
- [x] Add 15 new Vitest tests: context-aware sections (6), I18nLabel resolution (2), pivot type option (1), pivot object binding (1), widget description rendering (2), grid column clamping (1), pivot empty state (2)
477+
- [x] `PivotTable`: improved total/subtotal row styling — `bg-muted/40` on tfoot, `bg-muted/20` on row-total column, `font-bold` on grand total
478+
- [x] Add 23 new Vitest tests: ObjectPivotTable (8), context-aware sections (6), I18nLabel resolution (2), pivot type option (1), pivot object binding (1), widget description rendering (2), grid column clamping (1), pivot empty state (2)
477479

478480
### P1.11 Console — Schema-Driven View Config Panel Migration
479481

packages/plugin-dashboard/src/DashboardRenderer.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -233,25 +233,25 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
233233
if (widgetType === 'pivot') {
234234
const widgetData = (widget as any).data || options.data;
235235

236-
// provider: 'object' — pass through object config for async data loading
236+
// provider: 'object' — use ObjectPivotTable for async data loading
237237
if (isObjectProvider(widgetData)) {
238238
const { data: _data, ...restOptions } = options;
239239
return {
240-
type: 'pivot',
240+
type: 'object-pivot',
241241
...restOptions,
242242
objectName: widget.object || widgetData.object,
243243
dataProvider: widgetData,
244-
data: [],
244+
filter: widgetData.filter || widget.filter,
245245
};
246246
}
247247

248248
// No explicit data provider but widget has object binding
249249
if (!widgetData && widget.object) {
250250
return {
251-
type: 'pivot',
251+
type: 'object-pivot',
252252
...options,
253253
objectName: widget.object,
254-
data: [],
254+
filter: widget.filter,
255255
};
256256
}
257257

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
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, { useState, useEffect, useContext } from 'react';
10+
import { useDataScope, SchemaRendererContext } from '@object-ui/react';
11+
import { extractRecords } from '@object-ui/core';
12+
import { Skeleton, cn } from '@object-ui/components';
13+
import { PivotTable } from './PivotTable';
14+
import type { PivotTableSchema } from '@object-ui/types';
15+
16+
export interface ObjectPivotTableProps {
17+
schema: PivotTableSchema & {
18+
objectName?: string;
19+
dataProvider?: { provider: string; object?: string };
20+
bind?: string;
21+
filter?: any;
22+
};
23+
dataSource?: any;
24+
className?: string;
25+
}
26+
27+
/**
28+
* ObjectPivotTable — Async-aware wrapper around PivotTable.
29+
*
30+
* When `objectName` is provided and a `dataSource` is available via context
31+
* or props, fetches records automatically and passes them to PivotTable.
32+
*
33+
* Lifecycle states:
34+
* - **Loading** → skeleton placeholder
35+
* - **Error** → error message
36+
* - **Empty** → friendly "No data available" (delegated to PivotTable)
37+
* - **Data** → PivotTable with fetched rows
38+
*/
39+
export const ObjectPivotTable: React.FC<ObjectPivotTableProps> = ({ schema, dataSource: propDataSource, className }) => {
40+
const context = useContext(SchemaRendererContext);
41+
const dataSource = propDataSource || context?.dataSource;
42+
const boundData = useDataScope(schema.bind);
43+
44+
const [fetchedData, setFetchedData] = useState<any[]>([]);
45+
const [loading, setLoading] = useState(false);
46+
const [error, setError] = useState<string | null>(null);
47+
48+
useEffect(() => {
49+
let isMounted = true;
50+
51+
const fetchData = async () => {
52+
if (!dataSource || !schema.objectName) return;
53+
if (isMounted) {
54+
setLoading(true);
55+
setError(null);
56+
}
57+
try {
58+
let data: any[];
59+
60+
if (typeof dataSource.find === 'function') {
61+
const results = await dataSource.find(schema.objectName, {
62+
$filter: schema.filter,
63+
});
64+
data = extractRecords(results);
65+
} else {
66+
return;
67+
}
68+
69+
if (isMounted) {
70+
setFetchedData(data);
71+
}
72+
} catch (e) {
73+
console.error('[ObjectPivotTable] Fetch error:', e);
74+
if (isMounted) {
75+
setError(e instanceof Error ? e.message : 'Failed to load data');
76+
}
77+
} finally {
78+
if (isMounted) setLoading(false);
79+
}
80+
};
81+
82+
if (schema.objectName && !boundData && !schema.data?.length) {
83+
fetchData();
84+
}
85+
86+
return () => { isMounted = false; };
87+
}, [schema.objectName, dataSource, boundData, schema.data, schema.filter]);
88+
89+
// Resolve data: bound data > static schema data > fetched data
90+
const rawData = boundData || schema.data || fetchedData;
91+
const finalData = Array.isArray(rawData) ? rawData : [];
92+
93+
// Loading skeleton
94+
if (loading && finalData.length === 0) {
95+
return (
96+
<div className={cn('overflow-auto', className)} data-testid="pivot-loading">
97+
{schema.title && (
98+
<h3 className="text-sm font-semibold mb-2">{schema.title}</h3>
99+
)}
100+
<div className="space-y-2 p-2">
101+
<div className="flex gap-2">
102+
<Skeleton className="h-6 w-24" />
103+
<Skeleton className="h-6 w-20" />
104+
<Skeleton className="h-6 w-20" />
105+
<Skeleton className="h-6 w-20" />
106+
</div>
107+
{[1, 2, 3].map((i) => (
108+
<div key={i} className="flex gap-2">
109+
<Skeleton className="h-5 w-24" />
110+
<Skeleton className="h-5 w-20" />
111+
<Skeleton className="h-5 w-20" />
112+
<Skeleton className="h-5 w-20" />
113+
</div>
114+
))}
115+
</div>
116+
</div>
117+
);
118+
}
119+
120+
// Error state
121+
if (error) {
122+
return (
123+
<div className={cn('overflow-auto', className)} data-testid="pivot-error">
124+
{schema.title && (
125+
<h3 className="text-sm font-semibold mb-2">{schema.title}</h3>
126+
)}
127+
<div className="flex flex-col items-center justify-center py-8 text-destructive" data-testid="pivot-error-message">
128+
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 mb-2 opacity-60" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
129+
<circle cx="12" cy="12" r="10" />
130+
<line x1="12" y1="8" x2="12" y2="12" />
131+
<line x1="12" y1="16" x2="12.01" y2="16" />
132+
</svg>
133+
<p className="text-xs">{error}</p>
134+
</div>
135+
</div>
136+
);
137+
}
138+
139+
// No data source available but objectName configured
140+
if (!dataSource && schema.objectName && finalData.length === 0) {
141+
return (
142+
<div className={cn('overflow-auto', className)}>
143+
{schema.title && (
144+
<h3 className="text-sm font-semibold mb-2">{schema.title}</h3>
145+
)}
146+
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
147+
<p className="text-xs">No data source available for &ldquo;{schema.objectName}&rdquo;</p>
148+
</div>
149+
</div>
150+
);
151+
}
152+
153+
// Delegate to PivotTable with resolved data
154+
const finalSchema: PivotTableSchema = {
155+
...schema,
156+
data: finalData,
157+
};
158+
159+
return <PivotTable schema={finalSchema} className={className} />;
160+
};

packages/plugin-dashboard/src/PivotTable.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ export const PivotTable: React.FC<PivotTableProps> = ({ schema, className }) =>
212212
</th>
213213
))}
214214
{showRowTotals && (
215-
<th className="text-right p-2 font-semibold text-muted-foreground">Total</th>
215+
<th className="text-right p-2 font-semibold text-muted-foreground bg-muted/20">Total</th>
216216
)}
217217
</tr>
218218
</thead>
@@ -232,7 +232,7 @@ export const PivotTable: React.FC<PivotTableProps> = ({ schema, className }) =>
232232
</td>
233233
))}
234234
{showRowTotals && (
235-
<td className="text-right p-2 font-semibold tabular-nums">
235+
<td className="text-right p-2 font-semibold tabular-nums bg-muted/20">
236236
{fmt(rowTotals[row] ?? 0)}
237237
</td>
238238
)}
@@ -241,15 +241,15 @@ export const PivotTable: React.FC<PivotTableProps> = ({ schema, className }) =>
241241
</tbody>
242242
{showColumnTotals && (
243243
<tfoot>
244-
<tr className="border-t-2 border-border font-semibold">
244+
<tr className="border-t-2 border-border font-semibold bg-muted/40">
245245
<td className="p-2">Total</td>
246246
{colKeys.map((col) => (
247247
<td key={col} className="text-right p-2 tabular-nums">
248248
{fmt(colTotals[col] ?? 0)}
249249
</td>
250250
))}
251251
{showRowTotals && (
252-
<td className="text-right p-2 tabular-nums">
252+
<td className="text-right p-2 tabular-nums font-bold">
253253
{fmt(grandTotal)}
254254
</td>
255255
)}

packages/plugin-dashboard/src/WidgetConfigPanel.tsx

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,8 @@ const SORT_BY_OPTIONS = [
6868
];
6969

7070
const SORT_ORDER_OPTIONS = [
71-
{ value: 'asc', label: 'Ascending' },
72-
{ value: 'desc', label: 'Descending' },
71+
{ value: 'asc', label: '' },
72+
{ value: 'desc', label: '' },
7373
];
7474

7575
// ---------------------------------------------------------------------------
@@ -259,17 +259,23 @@ function buildWidgetSchema(
259259
{
260260
key: 'rowSortBy',
261261
label: 'Sort by',
262-
type: 'select',
262+
type: 'icon-group',
263263
options: SORT_BY_OPTIONS,
264264
defaultValue: 'group',
265265
},
266266
{
267267
key: 'rowSortOrder',
268268
label: 'Sort order',
269-
type: 'select',
269+
type: 'icon-group',
270270
options: SORT_ORDER_OPTIONS,
271271
defaultValue: 'asc',
272272
},
273+
{
274+
key: 'showRowLabels',
275+
label: 'Show label',
276+
type: 'switch',
277+
defaultValue: true,
278+
},
273279
{
274280
key: 'showRowTotals',
275281
label: 'Show totals',
@@ -290,17 +296,23 @@ function buildWidgetSchema(
290296
{
291297
key: 'columnSortBy',
292298
label: 'Sort by',
293-
type: 'select',
299+
type: 'icon-group',
294300
options: SORT_BY_OPTIONS,
295301
defaultValue: 'group',
296302
},
297303
{
298304
key: 'columnSortOrder',
299305
label: 'Sort order',
300-
type: 'select',
306+
type: 'icon-group',
301307
options: SORT_ORDER_OPTIONS,
302308
defaultValue: 'asc',
303309
},
310+
{
311+
key: 'showColumnLabels',
312+
label: 'Show label',
313+
type: 'switch',
314+
defaultValue: true,
315+
},
304316
{
305317
key: 'showColumnTotals',
306318
label: 'Show totals',

packages/plugin-dashboard/src/__tests__/DashboardRenderer.widgetData.test.tsx

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -439,15 +439,15 @@ describe('DashboardRenderer widget data extraction', () => {
439439

440440
const { container } = render(<DashboardRenderer schema={schema} />);
441441
const schemas = getRenderedSchemas(container);
442-
const pivotSchema = schemas.find(s => s.type === 'pivot');
442+
// DashboardRenderer now routes object-bound pivots to 'object-pivot'
443+
const pivotSchema = schemas.find(s => s.type === 'object-pivot');
443444

444445
if (pivotSchema) {
445446
expect(pivotSchema.objectName).toBe('sales');
446447
expect(pivotSchema.dataProvider).toEqual({
447448
provider: 'object',
448449
object: 'sales',
449450
});
450-
expect(pivotSchema.data).toBeUndefined();
451451
}
452452
});
453453

@@ -1200,13 +1200,12 @@ describe('DashboardRenderer widget data extraction', () => {
12001200
],
12011201
} as any;
12021202

1203-
// Since PivotTable is registered, SchemaRenderer renders it directly.
1204-
// With no data and objectName set, the pivot renders empty state.
1203+
// DashboardRenderer routes pivot+objectName to 'object-pivot' type.
1204+
// ObjectPivotTable renders "no data source" message when no context provided.
12051205
const { container } = render(<DashboardRenderer schema={schema} />);
12061206
expect(container).toBeDefined();
1207-
// Should render without crash and show the pivot empty state
1208-
const emptyState = container.querySelector('[data-testid="pivot-empty-state"]');
1209-
expect(emptyState).toBeDefined();
1207+
// Should render without crash
1208+
expect(container.textContent).not.toContain('is not iterable');
12101209
});
12111210

12121211
// ---- Widget description rendering -----------------------------------------

0 commit comments

Comments
 (0)