Skip to content

Commit 402049c

Browse files
authored
Merge pull request #933 from objectstack-ai/copilot/optimize-dashboard-table-widget
2 parents 6205fc4 + 138c165 commit 402049c

14 files changed

+1377
-94
lines changed

ROADMAP.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind
1919
1. ~~**AppShell** — No dynamic navigation renderer from spec JSON (last P0 blocker)~~ ✅ Complete
2020
2. **Designer Interaction** — DataModelDesigner has undo/redo, field type selectors, inline editing, Ctrl+S save. ViewDesigner has been removed; its capabilities (drag-to-reorder columns via @dnd-kit, undo/redo via useConfigDraft history) are now integrated into ViewConfigPanel (right-side config panel) ✅
2121
3. ~~**View Config Live Preview Sync** — Config panel changes sync in real-time for Grid, but `showSort`/`showSearch`/`showFilters`/`striped`/`bordered` not yet propagated to Kanban/Calendar/Timeline/Gallery/Map/Gantt (see P1.8.1)~~ ✅ Complete — all 7 phases of P1.8.1 done, 100% coverage across all view types
22-
4. **Dashboard Config Panel** — Airtable-style right-side configuration panel for dashboards (data source, layout, widget properties, sub-editors, type definitions). Widget config live preview sync and scatter chart type switch ✅ fixed (P1.10 Phase 10). Dashboard save/refresh metadata sync ✅ fixed (P1.10 Phase 11). Data provider field override for live preview ✅ fixed (P1.10 Phase 12).
22+
4. **Dashboard Config Panel** — Airtable-style right-side configuration panel for dashboards (data source, layout, widget properties, sub-editors, type definitions). Widget config live preview sync and scatter chart type switch ✅ fixed (P1.10 Phase 10). Dashboard save/refresh metadata sync ✅ fixed (P1.10 Phase 11). Data provider field override for live preview ✅ fixed (P1.10 Phase 12). Table/Pivot widget enhancements and context-aware config panel ✅ (P1.10 Phase 13).
2323
5. **Console Advanced Polish** — Remaining upgrades for forms, import/export, automation, comments
2424
6. **PWA Sync** — Background sync is simulated only
2525

@@ -460,6 +460,25 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind
460460
- [x] Fix: `DashboardWithConfig` did not pass `designMode`, `selectedWidgetId`, or `onWidgetClick` to `DashboardRenderer` — widgets could not be selected or live-previewed in the plugin-level component
461461
- [x] Add 10 new Vitest tests: widget-level field overrides for aggregate groupBy/field/function (3), objectName precedence for chart/table (2), simultaneous field overrides (1), DashboardWithConfig design mode and widget selection (2), existing live preview tests (2)
462462

463+
**Phase 13 — Table/Pivot Widget Enhancements & Context-Aware Config Panel:**
464+
- [x] Add `pivot` to `DASHBOARD_WIDGET_TYPES` constant and `WidgetConfigPanel` type options dropdown
465+
- [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 icon-group), Sort order (↑/↓ icon-group), Show label toggle, Show totals toggle for both rows and columns, Aggregation, Number format
467+
- [x] Chart-specific config: X-axis label, Y-axis label, Show legend toggle
468+
- [x] Table-specific config: Searchable toggle, Pagination toggle
469+
- [x] Breadcrumb adapts to widget type ("Pivot table", "Table", "Chart", "Widget")
470+
- [x] I18nLabel resolution: `WidgetConfigPanel` pre-processes `title` and `description` config values via `resolveLabel()` to prevent `[object Object]` display
471+
- [x] `DashboardRenderer`: widget description rendered in card headers with `line-clamp-2`; I18nLabel resolved via `resolveLabel()`
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] `ObjectDataTable`: new async-aware table wrapper — skeleton loading, error state, empty state, auto-column derivation from fetched data keys
474+
- [x] `DashboardRenderer`: pivot widgets with `objectName` or `provider: 'object'` routed to `object-pivot` type (ObjectPivotTable) for async data loading
475+
- [x] `DashboardRenderer`: table widgets with `objectName` or `provider: 'object'` routed to `object-data-table` type (ObjectDataTable) for async data loading
476+
- [x] `DashboardRenderer`: grid column clamping — widget `layout.w` clamped to `Math.min(w, columns)` preventing layout overflow
477+
- [x] `MetricWidget`: overflow protection — `overflow-hidden` on Card, `truncate` on label/value/description, `shrink-0` on icon/trend
478+
- [x] `PivotTable`: friendly empty state with grid icon + "No data available" message instead of empty table body
479+
- [x] `PivotTable`: improved total/subtotal row styling — `bg-muted/40` on tfoot, `bg-muted/20` on row-total column, `font-bold` on grand total
480+
- [x] Add 29 new Vitest tests: ObjectPivotTable (8), ObjectDataTable (6), 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)
481+
463482
### P1.11 Console — Schema-Driven View Config Panel Migration
464483

465484
> Migrated the Console ViewConfigPanel from imperative implementation (~1655 lines) to Schema-Driven architecture using `ConfigPanelRenderer` + `useConfigDraft` + `ConfigPanelSchema`, reducing to ~170 lines declarative wrapper + schema factory.

packages/plugin-dashboard/src/DashboardRenderer.tsx

Lines changed: 37 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,11 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
113113
}, [designMode, onWidgetClick]);
114114

115115
const renderWidget = (widget: DashboardWidgetSchema, index: number, forceMobileFullWidth?: boolean) => {
116+
// Clamp widget span to grid columns to prevent overflow
117+
const clampedLayout = widget.layout
118+
? { ...widget.layout, w: Math.min(widget.layout.w, columns) }
119+
: undefined;
120+
116121
const getComponentSchema = () => {
117122
if (widget.component) return widget.component;
118123

@@ -187,30 +192,30 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
187192
// Support data at widget level or nested inside options
188193
const widgetData = (widget as any).data || options.data;
189194

190-
// provider: 'object' — pass through object config for async data loading
195+
// provider: 'object' — use ObjectDataTable for async data loading
191196
if (isObjectProvider(widgetData)) {
192197
const { data: _data, ...restOptions } = options;
193198
return {
194-
type: 'data-table',
199+
type: 'object-data-table',
195200
...restOptions,
196201
objectName: widget.object || widgetData.object,
197202
dataProvider: widgetData,
198-
data: [],
199-
searchable: false,
200-
pagination: false,
203+
filter: widgetData.filter || widget.filter,
204+
searchable: widget.searchable ?? false,
205+
pagination: widget.pagination ?? false,
201206
className: "border-0"
202207
};
203208
}
204209

205210
// No explicit data provider but widget has object binding
206211
if (!widgetData && widget.object) {
207212
return {
208-
type: 'data-table',
213+
type: 'object-data-table',
209214
...options,
210215
objectName: widget.object,
211-
data: [],
212-
searchable: false,
213-
pagination: false,
216+
filter: widget.filter,
217+
searchable: widget.searchable ?? false,
218+
pagination: widget.pagination ?? false,
214219
className: "border-0"
215220
};
216221
}
@@ -228,15 +233,25 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
228233
if (widgetType === 'pivot') {
229234
const widgetData = (widget as any).data || options.data;
230235

231-
// provider: 'object' — pass through object config for async data loading
236+
// provider: 'object' — use ObjectPivotTable for async data loading
232237
if (isObjectProvider(widgetData)) {
233238
const { data: _data, ...restOptions } = options;
234239
return {
235-
type: 'pivot',
240+
type: 'object-pivot',
236241
...restOptions,
237242
objectName: widget.object || widgetData.object,
238243
dataProvider: widgetData,
239-
data: [],
244+
filter: widgetData.filter || widget.filter,
245+
};
246+
}
247+
248+
// No explicit data provider but widget has object binding
249+
if (!widgetData && widget.object) {
250+
return {
251+
type: 'object-pivot',
252+
...options,
253+
objectName: widget.object,
254+
filter: widget.filter,
240255
};
241256
}
242257

@@ -256,6 +271,7 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
256271
const componentSchema = getComponentSchema();
257272
const isSelfContained = widget.type === 'metric';
258273
const resolvedTitle = resolveLabel(widget.title);
274+
const resolvedDescription = resolveLabel(widget.description);
259275
const widgetKey = widget.id || resolvedTitle || `widget-${index}`;
260276
const isSelected = designMode && selectedWidgetId === widget.id;
261277

@@ -285,9 +301,9 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
285301
<div
286302
key={widgetKey}
287303
className={cn("h-full w-full", designMode && "relative", selectionClasses)}
288-
style={!isMobile && widget.layout ? {
289-
gridColumn: `span ${widget.layout.w}`,
290-
gridRow: `span ${widget.layout.h}`
304+
style={!isMobile && clampedLayout ? {
305+
gridColumn: `span ${clampedLayout.w}`,
306+
gridRow: `span ${clampedLayout.h}`
291307
}: undefined}
292308
{...designModeProps}
293309
>
@@ -307,9 +323,9 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
307323
designMode && "relative",
308324
selectionClasses
309325
)}
310-
style={!isMobile && widget.layout ? {
311-
gridColumn: `span ${widget.layout.w}`,
312-
gridRow: `span ${widget.layout.h}`
326+
style={!isMobile && clampedLayout ? {
327+
gridColumn: `span ${clampedLayout.w}`,
328+
gridRow: `span ${clampedLayout.h}`
313329
}: undefined}
314330
{...designModeProps}
315331
>
@@ -318,6 +334,9 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
318334
<CardTitle className="text-sm sm:text-base font-medium tracking-tight truncate" title={resolvedTitle}>
319335
{resolvedTitle}
320336
</CardTitle>
337+
{resolvedDescription && (
338+
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{resolvedDescription}</p>
339+
)}
321340
</CardHeader>
322341
)}
323342
<CardContent className="p-0">

packages/plugin-dashboard/src/MetricWidget.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,20 +43,20 @@ export const MetricWidget = ({
4343
}, [icon]);
4444

4545
return (
46-
<Card className={cn("h-full", className)} {...props}>
46+
<Card className={cn("h-full overflow-hidden", className)} {...props}>
4747
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
48-
<CardTitle className="text-sm font-medium">
48+
<CardTitle className="text-sm font-medium truncate">
4949
{resolveLabel(label)}
5050
</CardTitle>
51-
{resolvedIcon && <div className="h-4 w-4 text-muted-foreground">{resolvedIcon}</div>}
51+
{resolvedIcon && <div className="h-4 w-4 text-muted-foreground shrink-0">{resolvedIcon}</div>}
5252
</CardHeader>
5353
<CardContent>
54-
<div className="text-2xl font-bold">{value}</div>
54+
<div className="text-2xl font-bold truncate">{value}</div>
5555
{(trend || description) && (
56-
<p className="text-xs text-muted-foreground flex items-center mt-1">
56+
<p className="text-xs text-muted-foreground flex items-center mt-1 truncate">
5757
{trend && (
5858
<span className={cn(
59-
"flex items-center mr-2",
59+
"flex items-center mr-2 shrink-0",
6060
trend.direction === 'up' && "text-green-500",
6161
trend.direction === 'down' && "text-red-500",
6262
trend.direction === 'neutral' && "text-yellow-500"
@@ -67,7 +67,7 @@ export const MetricWidget = ({
6767
{trend.value}%
6868
</span>
6969
)}
70-
{resolveLabel(description) || resolveLabel(trend?.label)}
70+
<span className="truncate">{resolveLabel(description) || resolveLabel(trend?.label)}</span>
7171
</p>
7272
)}
7373
</CardContent>
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
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, useMemo } from 'react';
10+
import { useDataScope, SchemaRendererContext, SchemaRenderer } from '@object-ui/react';
11+
import { extractRecords } from '@object-ui/core';
12+
import { Skeleton, cn } from '@object-ui/components';
13+
14+
export interface ObjectDataTableProps {
15+
schema: {
16+
type: string;
17+
objectName?: string;
18+
dataProvider?: { provider: string; object?: string };
19+
bind?: string;
20+
filter?: any;
21+
data?: any[];
22+
columns?: any[];
23+
searchable?: boolean;
24+
pagination?: boolean;
25+
className?: string;
26+
[key: string]: any;
27+
};
28+
dataSource?: any;
29+
className?: string;
30+
}
31+
32+
/**
33+
* ObjectDataTable — Async-aware wrapper for data-table.
34+
*
35+
* When `objectName` is provided and a `dataSource` is available via context
36+
* or props, fetches records automatically and passes them to the registered
37+
* `data-table` component via SchemaRenderer.
38+
*
39+
* Also auto-derives columns from fetched data keys when no explicit columns
40+
* are configured.
41+
*
42+
* Lifecycle states:
43+
* - **Loading** → skeleton placeholder
44+
* - **Error** → error message
45+
* - **Empty** → friendly "No data available" message
46+
* - **Data** → data-table with fetched rows
47+
*/
48+
export const ObjectDataTable: React.FC<ObjectDataTableProps> = ({ schema, dataSource: propDataSource, className }) => {
49+
const context = useContext(SchemaRendererContext);
50+
const dataSource = propDataSource || context?.dataSource;
51+
const boundData = useDataScope(schema.bind);
52+
53+
const [fetchedData, setFetchedData] = useState<any[]>([]);
54+
const [loading, setLoading] = useState(false);
55+
const [error, setError] = useState<string | null>(null);
56+
57+
useEffect(() => {
58+
let isMounted = true;
59+
60+
const fetchData = async () => {
61+
if (!dataSource || !schema.objectName) return;
62+
if (isMounted) {
63+
setLoading(true);
64+
setError(null);
65+
}
66+
try {
67+
let data: any[];
68+
69+
if (typeof dataSource.find === 'function') {
70+
const results = await dataSource.find(schema.objectName, {
71+
$filter: schema.filter,
72+
});
73+
data = extractRecords(results);
74+
} else {
75+
return;
76+
}
77+
78+
if (isMounted) {
79+
setFetchedData(data);
80+
}
81+
} catch (e) {
82+
console.error('[ObjectDataTable] Fetch error:', e);
83+
if (isMounted) {
84+
setError(e instanceof Error ? e.message : 'Failed to load data');
85+
}
86+
} finally {
87+
if (isMounted) setLoading(false);
88+
}
89+
};
90+
91+
if (schema.objectName && !boundData && (!schema.data || schema.data.length === 0)) {
92+
fetchData();
93+
}
94+
95+
return () => { isMounted = false; };
96+
}, [schema.objectName, dataSource, boundData, schema.data, schema.filter]);
97+
98+
// Resolve data: bound data > static schema data > fetched data
99+
const rawData = boundData || schema.data || fetchedData;
100+
const finalData = Array.isArray(rawData) ? rawData : [];
101+
102+
// Auto-derive columns from data keys when none are provided
103+
const derivedColumns = useMemo(() => {
104+
if (schema.columns && schema.columns.length > 0) return schema.columns;
105+
if (finalData.length === 0) return [];
106+
// Exclude internal/private fields (prefixed with '_') from auto-derived columns
107+
const keys = Object.keys(finalData[0]).filter(k => !k.startsWith('_'));
108+
// Convert camelCase keys to human-readable headers (e.g. firstName → First Name)
109+
return keys.map(k => ({
110+
header: k.charAt(0).toUpperCase() + k.slice(1).replace(/([A-Z])/g, ' $1'),
111+
accessorKey: k,
112+
}));
113+
}, [schema.columns, finalData]);
114+
115+
// Loading skeleton
116+
if (loading && finalData.length === 0) {
117+
return (
118+
<div className={cn('overflow-auto', className)} data-testid="table-loading">
119+
<div className="space-y-2 p-2">
120+
<div className="flex gap-2">
121+
<Skeleton className="h-6 w-1/4" />
122+
<Skeleton className="h-6 w-1/4" />
123+
<Skeleton className="h-6 w-1/4" />
124+
<Skeleton className="h-6 w-1/4" />
125+
</div>
126+
{[1, 2, 3, 4].map((i) => (
127+
<div key={i} className="flex gap-2">
128+
<Skeleton className="h-5 w-1/4" />
129+
<Skeleton className="h-5 w-1/4" />
130+
<Skeleton className="h-5 w-1/4" />
131+
<Skeleton className="h-5 w-1/4" />
132+
</div>
133+
))}
134+
</div>
135+
</div>
136+
);
137+
}
138+
139+
// Error state
140+
if (error) {
141+
return (
142+
<div className={cn('overflow-auto', className)} data-testid="table-error">
143+
<div className="flex flex-col items-center justify-center py-8 text-destructive" data-testid="table-error-message">
144+
<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">
145+
<circle cx="12" cy="12" r="10" />
146+
<line x1="12" y1="8" x2="12" y2="12" />
147+
<line x1="12" y1="16" x2="12.01" y2="16" />
148+
</svg>
149+
<p className="text-xs">{error}</p>
150+
</div>
151+
</div>
152+
);
153+
}
154+
155+
// No data source available but objectName configured
156+
if (!dataSource && schema.objectName && finalData.length === 0) {
157+
return (
158+
<div className={cn('overflow-auto', className)}>
159+
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
160+
<p className="text-xs">No data source available for &ldquo;{schema.objectName}&rdquo;</p>
161+
</div>
162+
</div>
163+
);
164+
}
165+
166+
// Empty state
167+
if (finalData.length === 0) {
168+
return (
169+
<div className={cn('overflow-auto', className)} data-testid="table-empty-state">
170+
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
171+
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 mb-2 opacity-40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
172+
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
173+
<line x1="3" y1="9" x2="21" y2="9" />
174+
<line x1="9" y1="21" x2="9" y2="9" />
175+
</svg>
176+
<p className="text-xs">No data available</p>
177+
</div>
178+
</div>
179+
);
180+
}
181+
182+
// Delegate to data-table via SchemaRenderer
183+
const tableSchema = {
184+
...schema,
185+
type: 'data-table',
186+
data: finalData,
187+
columns: derivedColumns,
188+
};
189+
190+
return <SchemaRenderer schema={tableSchema} className={className} />;
191+
};

0 commit comments

Comments
 (0)