@object-ui/plugin-dashboard 3.0.3 → 3.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +7 -7
- package/CHANGELOG.md +10 -0
- package/dist/index.js +3848 -2635
- package/dist/index.umd.cjs +5 -5
- package/dist/src/DashboardConfigPanel.d.ts +28 -0
- package/dist/src/DashboardConfigPanel.d.ts.map +1 -0
- package/dist/src/DashboardConfigPanel.stories.d.ts +14 -0
- package/dist/src/DashboardConfigPanel.stories.d.ts.map +1 -0
- package/dist/src/DashboardGridLayout.d.ts.map +1 -1
- package/dist/src/DashboardRenderer.d.ts +14 -0
- package/dist/src/DashboardRenderer.d.ts.map +1 -1
- package/dist/src/DashboardWithConfig.d.ts +32 -0
- package/dist/src/DashboardWithConfig.d.ts.map +1 -0
- package/dist/src/MetricCard.d.ts +8 -2
- package/dist/src/MetricCard.d.ts.map +1 -1
- package/dist/src/MetricWidget.d.ts +12 -3
- package/dist/src/MetricWidget.d.ts.map +1 -1
- package/dist/src/ObjectDataTable.d.ts +39 -0
- package/dist/src/ObjectDataTable.d.ts.map +1 -0
- package/dist/src/ObjectPivotTable.d.ts +29 -0
- package/dist/src/ObjectPivotTable.d.ts.map +1 -0
- package/dist/src/PivotTable.d.ts +14 -0
- package/dist/src/PivotTable.d.ts.map +1 -0
- package/dist/src/WidgetConfigPanel.d.ts +43 -0
- package/dist/src/WidgetConfigPanel.d.ts.map +1 -0
- package/dist/src/index.d.ts +13 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/utils.d.ts +15 -0
- package/dist/src/utils.d.ts.map +1 -0
- package/package.json +7 -7
- package/src/DashboardConfigPanel.stories.tsx +164 -0
- package/src/DashboardConfigPanel.tsx +158 -0
- package/src/DashboardGridLayout.tsx +101 -3
- package/src/DashboardRenderer.tsx +269 -28
- package/src/DashboardWithConfig.tsx +211 -0
- package/src/MetricCard.tsx +11 -4
- package/src/MetricWidget.tsx +18 -11
- package/src/ObjectDataTable.tsx +191 -0
- package/src/ObjectPivotTable.tsx +160 -0
- package/src/PivotTable.tsx +262 -0
- package/src/WidgetConfigPanel.tsx +540 -0
- package/src/__tests__/DashboardConfigPanel.test.tsx +206 -0
- package/src/__tests__/DashboardRenderer.designMode.test.tsx +386 -0
- package/src/__tests__/DashboardRenderer.header.test.tsx +114 -0
- package/src/__tests__/DashboardRenderer.mobile.test.tsx +214 -0
- package/src/__tests__/DashboardRenderer.widgetData.test.tsx +1022 -0
- package/src/__tests__/DashboardWithConfig.test.tsx +276 -0
- package/src/__tests__/MetricCard.test.tsx +23 -0
- package/src/__tests__/ObjectDataTable.test.tsx +122 -0
- package/src/__tests__/ObjectPivotTable.test.tsx +192 -0
- package/src/__tests__/PivotTable.test.tsx +162 -0
- package/src/__tests__/WidgetConfigPanel.test.tsx +492 -0
- package/src/__tests__/ensureWidgetIds.test.tsx +103 -0
- package/src/index.tsx +107 -1
- package/src/utils.ts +17 -0
|
@@ -11,6 +11,14 @@ import { SchemaRenderer } from '@object-ui/react';
|
|
|
11
11
|
import { cn, Card, CardHeader, CardTitle, CardContent, Button } from '@object-ui/components';
|
|
12
12
|
import { forwardRef, useState, useEffect, useCallback, useRef } from 'react';
|
|
13
13
|
import { RefreshCw } from 'lucide-react';
|
|
14
|
+
import { isObjectProvider } from './utils';
|
|
15
|
+
|
|
16
|
+
/** Resolve an I18nLabel (string or {key, defaultValue}) to a plain string. */
|
|
17
|
+
function resolveLabel(label: string | { key?: string; defaultValue?: string } | undefined): string | undefined {
|
|
18
|
+
if (label === undefined || label === null) return undefined;
|
|
19
|
+
if (typeof label === 'string') return label;
|
|
20
|
+
return label.defaultValue || label.key;
|
|
21
|
+
}
|
|
14
22
|
|
|
15
23
|
// Color palette for charts
|
|
16
24
|
const CHART_COLORS = [
|
|
@@ -26,11 +34,21 @@ export interface DashboardRendererProps {
|
|
|
26
34
|
className?: string;
|
|
27
35
|
/** Callback invoked when dashboard refresh is triggered (manual or auto) */
|
|
28
36
|
onRefresh?: () => void;
|
|
37
|
+
/** Total record count to display */
|
|
38
|
+
recordCount?: number;
|
|
39
|
+
/** User actions configuration */
|
|
40
|
+
userActions?: { sort?: boolean; search?: boolean; filter?: boolean };
|
|
41
|
+
/** Enable design mode — shows selection affordances on widgets */
|
|
42
|
+
designMode?: boolean;
|
|
43
|
+
/** Currently selected widget ID (controlled) */
|
|
44
|
+
selectedWidgetId?: string | null;
|
|
45
|
+
/** Callback when a widget is clicked in design mode */
|
|
46
|
+
onWidgetClick?: (widgetId: string | null) => void;
|
|
29
47
|
[key: string]: any;
|
|
30
48
|
}
|
|
31
49
|
|
|
32
50
|
export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererProps>(
|
|
33
|
-
({ schema, className, dataSource, onRefresh, ...props }, ref) => {
|
|
51
|
+
({ schema, className, dataSource, onRefresh, recordCount, userActions, designMode, selectedWidgetId, onWidgetClick, ...props }, ref) => {
|
|
34
52
|
const columns = schema.columns || 4; // Default to 4 columns for better density
|
|
35
53
|
const gap = schema.gap || 4;
|
|
36
54
|
const [refreshing, setRefreshing] = useState(false);
|
|
@@ -38,7 +56,7 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
|
|
|
38
56
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
39
57
|
|
|
40
58
|
useEffect(() => {
|
|
41
|
-
const checkMobile = () => setIsMobile(window.innerWidth <
|
|
59
|
+
const checkMobile = () => setIsMobile(window.innerWidth < 768);
|
|
42
60
|
checkMobile();
|
|
43
61
|
window.addEventListener('resize', checkMobile);
|
|
44
62
|
return () => window.removeEventListener('resize', checkMobile);
|
|
@@ -61,19 +79,103 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
|
|
|
61
79
|
};
|
|
62
80
|
}, [schema.refreshInterval, onRefresh, handleRefresh]);
|
|
63
81
|
|
|
64
|
-
const
|
|
82
|
+
const handleWidgetClick = useCallback((e: React.MouseEvent, widgetId: string | undefined) => {
|
|
83
|
+
if (!designMode || !onWidgetClick || !widgetId) return;
|
|
84
|
+
e.stopPropagation();
|
|
85
|
+
onWidgetClick(widgetId);
|
|
86
|
+
}, [designMode, onWidgetClick]);
|
|
87
|
+
|
|
88
|
+
const handleWidgetKeyDown = useCallback((e: React.KeyboardEvent, widgetId: string | undefined, index: number) => {
|
|
89
|
+
if (!designMode || !onWidgetClick) return;
|
|
90
|
+
const widgets = schema.widgets || [];
|
|
91
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
92
|
+
e.preventDefault();
|
|
93
|
+
onWidgetClick(widgetId ?? null);
|
|
94
|
+
} else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
|
|
95
|
+
e.preventDefault();
|
|
96
|
+
const next = index + 1 < widgets.length ? widgets[index + 1] : null;
|
|
97
|
+
if (next?.id) onWidgetClick(next.id);
|
|
98
|
+
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
|
|
99
|
+
e.preventDefault();
|
|
100
|
+
const prev = index - 1 >= 0 ? widgets[index - 1] : null;
|
|
101
|
+
if (prev?.id) onWidgetClick(prev.id);
|
|
102
|
+
} else if (e.key === 'Escape') {
|
|
103
|
+
e.preventDefault();
|
|
104
|
+
onWidgetClick(null);
|
|
105
|
+
}
|
|
106
|
+
}, [designMode, onWidgetClick, schema.widgets]);
|
|
107
|
+
|
|
108
|
+
const handleBackgroundClick = useCallback((e: React.MouseEvent) => {
|
|
109
|
+
if (!designMode || !onWidgetClick) return;
|
|
110
|
+
if (e.target === e.currentTarget) {
|
|
111
|
+
onWidgetClick(null);
|
|
112
|
+
}
|
|
113
|
+
}, [designMode, onWidgetClick]);
|
|
114
|
+
|
|
115
|
+
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
|
+
|
|
65
121
|
const getComponentSchema = () => {
|
|
66
122
|
if (widget.component) return widget.component;
|
|
67
123
|
|
|
68
124
|
// Handle Shorthand Registry Mappings
|
|
69
125
|
const widgetType = widget.type;
|
|
70
126
|
const options = (widget.options || {}) as Record<string, any>;
|
|
71
|
-
if (widgetType === 'bar' || widgetType === 'line' || widgetType === 'area' || widgetType === 'pie' || widgetType === 'donut') {
|
|
127
|
+
if (widgetType === 'bar' || widgetType === 'line' || widgetType === 'area' || widgetType === 'pie' || widgetType === 'donut' || widgetType === 'scatter') {
|
|
72
128
|
// Support data at widget level or nested inside options
|
|
73
129
|
const widgetData = (widget as any).data || options.data;
|
|
130
|
+
// Widget-level fields (from config panel) override options-level fields
|
|
131
|
+
const xAxisKey = widget.categoryField || options.xField || 'name';
|
|
132
|
+
const yField = widget.valueField || options.yField || 'value';
|
|
133
|
+
|
|
134
|
+
// provider: 'object' — delegate to ObjectChart for async data loading
|
|
135
|
+
if (isObjectProvider(widgetData)) {
|
|
136
|
+
// Merge widget-level fields with data provider config.
|
|
137
|
+
// Widget-level fields take precedence so that config panel
|
|
138
|
+
// edits are immediately reflected in the live preview.
|
|
139
|
+
const providerAgg = widgetData.aggregate;
|
|
140
|
+
const effectiveAggregate = providerAgg ? {
|
|
141
|
+
field: widget.valueField || providerAgg.field,
|
|
142
|
+
function: widget.aggregate || providerAgg.function,
|
|
143
|
+
groupBy: widget.categoryField || providerAgg.groupBy,
|
|
144
|
+
} : undefined;
|
|
145
|
+
const effectiveYField = effectiveAggregate?.field || yField;
|
|
146
|
+
return {
|
|
147
|
+
type: 'object-chart',
|
|
148
|
+
chartType: widgetType,
|
|
149
|
+
objectName: widget.object || widgetData.object,
|
|
150
|
+
aggregate: effectiveAggregate,
|
|
151
|
+
xAxisKey: xAxisKey,
|
|
152
|
+
series: [{ dataKey: effectiveYField }],
|
|
153
|
+
colors: CHART_COLORS,
|
|
154
|
+
className: "h-[200px] sm:h-[250px] md:h-[300px]"
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// No explicit data provider but widget has object binding
|
|
159
|
+
// (e.g. newly created widget via config panel) — build object-chart
|
|
160
|
+
if (!widgetData && widget.object) {
|
|
161
|
+
const aggregate = widget.aggregate ? {
|
|
162
|
+
field: widget.valueField || 'value',
|
|
163
|
+
function: widget.aggregate,
|
|
164
|
+
groupBy: widget.categoryField || 'name',
|
|
165
|
+
} : undefined;
|
|
166
|
+
return {
|
|
167
|
+
type: 'object-chart',
|
|
168
|
+
chartType: widgetType,
|
|
169
|
+
objectName: widget.object,
|
|
170
|
+
aggregate,
|
|
171
|
+
xAxisKey: xAxisKey,
|
|
172
|
+
series: [{ dataKey: widget.valueField || 'value' }],
|
|
173
|
+
colors: CHART_COLORS,
|
|
174
|
+
className: "h-[200px] sm:h-[250px] md:h-[300px]"
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
74
178
|
const dataItems = Array.isArray(widgetData) ? widgetData : widgetData?.items || [];
|
|
75
|
-
const xAxisKey = options.xField || 'name';
|
|
76
|
-
const yField = options.yField || 'value';
|
|
77
179
|
|
|
78
180
|
return {
|
|
79
181
|
type: 'chart',
|
|
@@ -89,6 +191,35 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
|
|
|
89
191
|
if (widgetType === 'table') {
|
|
90
192
|
// Support data at widget level or nested inside options
|
|
91
193
|
const widgetData = (widget as any).data || options.data;
|
|
194
|
+
|
|
195
|
+
// provider: 'object' — use ObjectDataTable for async data loading
|
|
196
|
+
if (isObjectProvider(widgetData)) {
|
|
197
|
+
const { data: _data, ...restOptions } = options;
|
|
198
|
+
return {
|
|
199
|
+
type: 'object-data-table',
|
|
200
|
+
...restOptions,
|
|
201
|
+
objectName: widget.object || widgetData.object,
|
|
202
|
+
dataProvider: widgetData,
|
|
203
|
+
filter: widgetData.filter || widget.filter,
|
|
204
|
+
searchable: widget.searchable ?? false,
|
|
205
|
+
pagination: widget.pagination ?? false,
|
|
206
|
+
className: "border-0"
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// No explicit data provider but widget has object binding
|
|
211
|
+
if (!widgetData && widget.object) {
|
|
212
|
+
return {
|
|
213
|
+
type: 'object-data-table',
|
|
214
|
+
...options,
|
|
215
|
+
objectName: widget.object,
|
|
216
|
+
filter: widget.filter,
|
|
217
|
+
searchable: widget.searchable ?? false,
|
|
218
|
+
pagination: widget.pagination ?? false,
|
|
219
|
+
className: "border-0"
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
92
223
|
return {
|
|
93
224
|
type: 'data-table',
|
|
94
225
|
...options,
|
|
@@ -99,6 +230,38 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
|
|
|
99
230
|
};
|
|
100
231
|
}
|
|
101
232
|
|
|
233
|
+
if (widgetType === 'pivot') {
|
|
234
|
+
const widgetData = (widget as any).data || options.data;
|
|
235
|
+
|
|
236
|
+
// provider: 'object' — use ObjectPivotTable for async data loading
|
|
237
|
+
if (isObjectProvider(widgetData)) {
|
|
238
|
+
const { data: _data, ...restOptions } = options;
|
|
239
|
+
return {
|
|
240
|
+
type: 'object-pivot',
|
|
241
|
+
...restOptions,
|
|
242
|
+
objectName: widget.object || widgetData.object,
|
|
243
|
+
dataProvider: widgetData,
|
|
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,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
type: 'pivot',
|
|
260
|
+
...options,
|
|
261
|
+
data: Array.isArray(widgetData) ? widgetData : widgetData?.items || [],
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
102
265
|
return {
|
|
103
266
|
...widget,
|
|
104
267
|
...options
|
|
@@ -107,19 +270,45 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
|
|
|
107
270
|
|
|
108
271
|
const componentSchema = getComponentSchema();
|
|
109
272
|
const isSelfContained = widget.type === 'metric';
|
|
110
|
-
const
|
|
273
|
+
const resolvedTitle = resolveLabel(widget.title);
|
|
274
|
+
const resolvedDescription = resolveLabel(widget.description);
|
|
275
|
+
const widgetKey = widget.id || resolvedTitle || `widget-${index}`;
|
|
276
|
+
const isSelected = designMode && selectedWidgetId === widget.id;
|
|
277
|
+
|
|
278
|
+
const designModeProps = designMode ? {
|
|
279
|
+
'data-testid': `dashboard-preview-widget-${widget.id}`,
|
|
280
|
+
'data-widget-id': widget.id,
|
|
281
|
+
role: 'button' as const,
|
|
282
|
+
tabIndex: 0,
|
|
283
|
+
'aria-selected': isSelected,
|
|
284
|
+
'aria-label': `Widget: ${resolvedTitle || `Widget ${index + 1}`}`,
|
|
285
|
+
onClick: (e: React.MouseEvent) => handleWidgetClick(e, widget.id),
|
|
286
|
+
onKeyDown: (e: React.KeyboardEvent) => handleWidgetKeyDown(e, widget.id, index),
|
|
287
|
+
} : {};
|
|
288
|
+
|
|
289
|
+
const selectionClasses = designMode
|
|
290
|
+
? cn(
|
|
291
|
+
"cursor-pointer rounded-lg transition-all outline-none",
|
|
292
|
+
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
293
|
+
isSelected
|
|
294
|
+
? "ring-2 ring-primary shadow-md bg-primary/5 dark:bg-primary/10"
|
|
295
|
+
: "hover:ring-2 hover:ring-primary/40 hover:shadow-sm"
|
|
296
|
+
)
|
|
297
|
+
: undefined;
|
|
111
298
|
|
|
112
299
|
if (isSelfContained) {
|
|
113
300
|
return (
|
|
114
301
|
<div
|
|
115
302
|
key={widgetKey}
|
|
116
|
-
className={cn("h-full w-full",
|
|
117
|
-
style={!isMobile &&
|
|
118
|
-
gridColumn: `span ${
|
|
119
|
-
gridRow: `span ${
|
|
303
|
+
className={cn("h-full w-full", designMode && "relative", selectionClasses)}
|
|
304
|
+
style={!isMobile && clampedLayout ? {
|
|
305
|
+
gridColumn: `span ${clampedLayout.w}`,
|
|
306
|
+
gridRow: `span ${clampedLayout.h}`
|
|
120
307
|
}: undefined}
|
|
308
|
+
{...designModeProps}
|
|
121
309
|
>
|
|
122
|
-
<SchemaRenderer schema={componentSchema} className="h-full w-full" />
|
|
310
|
+
<SchemaRenderer schema={componentSchema} className={cn("h-full w-full", designMode && "pointer-events-none")} />
|
|
311
|
+
{designMode && <div className="absolute inset-0 z-10" aria-hidden="true" data-testid="widget-click-overlay" />}
|
|
123
312
|
</div>
|
|
124
313
|
);
|
|
125
314
|
}
|
|
@@ -130,31 +319,67 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
|
|
|
130
319
|
className={cn(
|
|
131
320
|
"overflow-hidden border-border/50 shadow-sm transition-all hover:shadow-md",
|
|
132
321
|
"bg-card/50 backdrop-blur-sm",
|
|
133
|
-
|
|
322
|
+
forceMobileFullWidth && "w-full",
|
|
323
|
+
designMode && "relative",
|
|
324
|
+
selectionClasses
|
|
134
325
|
)}
|
|
135
|
-
style={!isMobile &&
|
|
136
|
-
gridColumn: `span ${
|
|
137
|
-
gridRow: `span ${
|
|
326
|
+
style={!isMobile && clampedLayout ? {
|
|
327
|
+
gridColumn: `span ${clampedLayout.w}`,
|
|
328
|
+
gridRow: `span ${clampedLayout.h}`
|
|
138
329
|
}: undefined}
|
|
330
|
+
{...designModeProps}
|
|
139
331
|
>
|
|
140
|
-
{
|
|
332
|
+
{resolvedTitle && (
|
|
141
333
|
<CardHeader className="pb-2 border-b border-border/40 bg-muted/20 px-3 sm:px-6">
|
|
142
|
-
<CardTitle className="text-sm sm:text-base font-medium tracking-tight truncate" title={
|
|
143
|
-
{
|
|
334
|
+
<CardTitle className="text-sm sm:text-base font-medium tracking-tight truncate" title={resolvedTitle}>
|
|
335
|
+
{resolvedTitle}
|
|
144
336
|
</CardTitle>
|
|
337
|
+
{resolvedDescription && (
|
|
338
|
+
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{resolvedDescription}</p>
|
|
339
|
+
)}
|
|
145
340
|
</CardHeader>
|
|
146
341
|
)}
|
|
147
342
|
<CardContent className="p-0">
|
|
148
|
-
<div className={cn("h-full w-full", "p-3 sm:p-4 md:p-6")}>
|
|
343
|
+
<div className={cn("h-full w-full", "p-3 sm:p-4 md:p-6", designMode && "pointer-events-none")}>
|
|
149
344
|
<SchemaRenderer schema={componentSchema} />
|
|
150
345
|
</div>
|
|
151
346
|
</CardContent>
|
|
347
|
+
{designMode && <div className="absolute inset-0 z-10" aria-hidden="true" data-testid="widget-click-overlay" />}
|
|
152
348
|
</Card>
|
|
153
349
|
);
|
|
154
350
|
};
|
|
155
351
|
|
|
352
|
+
const headerSection = schema.header && (
|
|
353
|
+
<div className="col-span-full mb-4">
|
|
354
|
+
{schema.header.showTitle !== false && schema.title && (
|
|
355
|
+
<h2 className="text-lg font-semibold tracking-tight">{resolveLabel(schema.title)}</h2>
|
|
356
|
+
)}
|
|
357
|
+
{schema.header.showDescription !== false && schema.description && (
|
|
358
|
+
<p className="text-sm text-muted-foreground mt-1">{resolveLabel(schema.description)}</p>
|
|
359
|
+
)}
|
|
360
|
+
{schema.header.actions && schema.header.actions.length > 0 && (
|
|
361
|
+
<div className="flex gap-2 mt-3">
|
|
362
|
+
{schema.header.actions.map((action: { label: string; actionUrl?: string; actionType?: string; icon?: string }, i: number) => (
|
|
363
|
+
<Button key={i} variant="outline" size="sm">
|
|
364
|
+
{action.label}
|
|
365
|
+
</Button>
|
|
366
|
+
))}
|
|
367
|
+
</div>
|
|
368
|
+
)}
|
|
369
|
+
</div>
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
const recordCountBadge = recordCount !== undefined && (
|
|
373
|
+
<span className="text-xs text-muted-foreground">
|
|
374
|
+
{recordCount.toLocaleString()} records
|
|
375
|
+
</span>
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
const userActionsAttr = userActions ? JSON.stringify(userActions) : undefined;
|
|
379
|
+
|
|
156
380
|
const refreshButton = onRefresh && (
|
|
157
|
-
<div className={cn(
|
|
381
|
+
<div className={cn("flex items-center justify-end gap-3 mb-2", !isMobile && "col-span-full")}>
|
|
382
|
+
{recordCountBadge}
|
|
158
383
|
<Button
|
|
159
384
|
variant="outline"
|
|
160
385
|
size="sm"
|
|
@@ -169,15 +394,28 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
|
|
|
169
394
|
);
|
|
170
395
|
|
|
171
396
|
if (isMobile) {
|
|
397
|
+
// Separate metric widgets from other widgets for better mobile layout
|
|
398
|
+
const metricWidgets = schema.widgets?.filter((w: DashboardWidgetSchema) => w.type === 'metric') || [];
|
|
399
|
+
const otherWidgets = schema.widgets?.filter((w: DashboardWidgetSchema) => w.type !== 'metric') || [];
|
|
400
|
+
|
|
172
401
|
return (
|
|
173
|
-
<div ref={ref} className={cn("flex flex-col", className)} {...props}>
|
|
402
|
+
<div ref={ref} className={cn("flex flex-col gap-4 px-4", className)} data-user-actions={userActionsAttr} onClick={handleBackgroundClick} {...props}>
|
|
403
|
+
{headerSection}
|
|
174
404
|
{refreshButton}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
405
|
+
|
|
406
|
+
{/* Metric cards: 2-column grid */}
|
|
407
|
+
{metricWidgets.length > 0 && (
|
|
408
|
+
<div className="grid grid-cols-2 gap-3" onClick={handleBackgroundClick}>
|
|
409
|
+
{metricWidgets.map((widget: DashboardWidgetSchema, index: number) => renderWidget(widget, index))}
|
|
410
|
+
</div>
|
|
411
|
+
)}
|
|
412
|
+
|
|
413
|
+
{/* Other widgets (charts, tables): full-width vertical stack */}
|
|
414
|
+
{otherWidgets.length > 0 && (
|
|
415
|
+
<div className="flex flex-col gap-4" onClick={handleBackgroundClick}>
|
|
416
|
+
{otherWidgets.map((widget: DashboardWidgetSchema, index: number) => renderWidget(widget, index, true))}
|
|
417
|
+
</div>
|
|
418
|
+
)}
|
|
181
419
|
</div>
|
|
182
420
|
);
|
|
183
421
|
}
|
|
@@ -194,8 +432,11 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
|
|
|
194
432
|
...(columns > 4 && { gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }),
|
|
195
433
|
gap: `${gap * 0.25}rem`
|
|
196
434
|
}}
|
|
435
|
+
data-user-actions={userActionsAttr}
|
|
436
|
+
onClick={handleBackgroundClick}
|
|
197
437
|
{...props}
|
|
198
438
|
>
|
|
439
|
+
{headerSection}
|
|
199
440
|
{refreshButton}
|
|
200
441
|
{schema.widgets?.map((widget: DashboardWidgetSchema, index: number) => renderWidget(widget, index))}
|
|
201
442
|
</div>
|
|
@@ -0,0 +1,211 @@
|
|
|
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 * as React from 'react';
|
|
10
|
+
import { useState, useCallback, useEffect } from 'react';
|
|
11
|
+
import { Settings } from 'lucide-react';
|
|
12
|
+
import { cn, Button } from '@object-ui/components';
|
|
13
|
+
import type { DashboardSchema, DashboardWidgetSchema } from '@object-ui/types';
|
|
14
|
+
|
|
15
|
+
import { DashboardRenderer } from './DashboardRenderer';
|
|
16
|
+
import { DashboardConfigPanel } from './DashboardConfigPanel';
|
|
17
|
+
import { WidgetConfigPanel } from './WidgetConfigPanel';
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Props
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
export interface DashboardWithConfigProps {
|
|
24
|
+
/** Dashboard schema for rendering */
|
|
25
|
+
schema: DashboardSchema;
|
|
26
|
+
/** Current dashboard configuration (for the config panel) */
|
|
27
|
+
config: Record<string, any>;
|
|
28
|
+
/** Called when config panel saves dashboard-level changes */
|
|
29
|
+
onConfigSave: (config: Record<string, any>) => void;
|
|
30
|
+
/** Called when widget config panel saves widget-level changes */
|
|
31
|
+
onWidgetSave?: (widgetId: string, config: Record<string, any>) => void;
|
|
32
|
+
/** Callback invoked when dashboard refresh is triggered */
|
|
33
|
+
onRefresh?: () => void;
|
|
34
|
+
/** Total record count */
|
|
35
|
+
recordCount?: number;
|
|
36
|
+
/** Whether the config panel is open initially */
|
|
37
|
+
defaultConfigOpen?: boolean;
|
|
38
|
+
/** Additional CSS class name for the container */
|
|
39
|
+
className?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Component
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* DashboardWithConfig — Composite component combining a DashboardRenderer
|
|
48
|
+
* with a DashboardConfigPanel sidebar.
|
|
49
|
+
*
|
|
50
|
+
* Supports:
|
|
51
|
+
* - Toggle config panel visibility via a Settings button
|
|
52
|
+
* - Dashboard-level configuration editing
|
|
53
|
+
* - Click-to-select a widget → sidebar switches to WidgetConfigPanel
|
|
54
|
+
* - Back navigation from widget config to dashboard config
|
|
55
|
+
* - Live preview: widget config changes are reflected in real time
|
|
56
|
+
*/
|
|
57
|
+
export function DashboardWithConfig({
|
|
58
|
+
schema,
|
|
59
|
+
config,
|
|
60
|
+
onConfigSave,
|
|
61
|
+
onWidgetSave,
|
|
62
|
+
onRefresh,
|
|
63
|
+
recordCount,
|
|
64
|
+
defaultConfigOpen = false,
|
|
65
|
+
className,
|
|
66
|
+
}: DashboardWithConfigProps) {
|
|
67
|
+
const [configOpen, setConfigOpen] = useState(defaultConfigOpen);
|
|
68
|
+
const [selectedWidgetId, setSelectedWidgetId] = useState<string | null>(null);
|
|
69
|
+
|
|
70
|
+
// Internal schema state for live preview during widget editing.
|
|
71
|
+
// Updated on every field change; reset when external schema prop changes.
|
|
72
|
+
const [liveSchema, setLiveSchema] = useState<DashboardSchema>(schema);
|
|
73
|
+
const [configVersion, setConfigVersion] = useState(0);
|
|
74
|
+
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
setLiveSchema(schema);
|
|
77
|
+
setConfigVersion((v) => v + 1);
|
|
78
|
+
}, [schema]);
|
|
79
|
+
|
|
80
|
+
// Stable widget config for the config panel — only recomputed on
|
|
81
|
+
// widget selection change or save (configVersion), NOT on every live
|
|
82
|
+
// field change. This prevents useConfigDraft from resetting the draft.
|
|
83
|
+
const selectedWidgetConfig = React.useMemo(() => {
|
|
84
|
+
if (!selectedWidgetId || !liveSchema.widgets) return null;
|
|
85
|
+
const widget = liveSchema.widgets.find(
|
|
86
|
+
(w) => (w.id || w.title) === selectedWidgetId,
|
|
87
|
+
);
|
|
88
|
+
if (!widget) return null;
|
|
89
|
+
return {
|
|
90
|
+
id: widget.id ?? '',
|
|
91
|
+
title: widget.title ?? '',
|
|
92
|
+
description: widget.description ?? '',
|
|
93
|
+
type: widget.type ?? '',
|
|
94
|
+
object: widget.object ?? '',
|
|
95
|
+
categoryField: widget.categoryField ?? '',
|
|
96
|
+
valueField: widget.valueField ?? '',
|
|
97
|
+
aggregate: widget.aggregate ?? '',
|
|
98
|
+
colorVariant: widget.colorVariant ?? 'default',
|
|
99
|
+
actionUrl: widget.actionUrl ?? '',
|
|
100
|
+
layoutW: widget.layout?.w ?? 1,
|
|
101
|
+
layoutH: widget.layout?.h ?? 1,
|
|
102
|
+
};
|
|
103
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
104
|
+
}, [selectedWidgetId, configVersion]);
|
|
105
|
+
|
|
106
|
+
const handleWidgetSelect = useCallback(
|
|
107
|
+
(widgetId: string) => {
|
|
108
|
+
setSelectedWidgetId(widgetId);
|
|
109
|
+
setConfigOpen(true);
|
|
110
|
+
},
|
|
111
|
+
[],
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const handleWidgetClose = useCallback(() => {
|
|
115
|
+
setSelectedWidgetId(null);
|
|
116
|
+
}, []);
|
|
117
|
+
|
|
118
|
+
// Live-update handler: updates liveSchema so DashboardRenderer re-renders.
|
|
119
|
+
const handleWidgetFieldChange = useCallback(
|
|
120
|
+
(field: string, value: any) => {
|
|
121
|
+
if (!selectedWidgetId) return;
|
|
122
|
+
setLiveSchema((prev) => {
|
|
123
|
+
if (!prev.widgets) return prev;
|
|
124
|
+
return {
|
|
125
|
+
...prev,
|
|
126
|
+
widgets: prev.widgets.map((w) => {
|
|
127
|
+
if ((w.id || w.title) !== selectedWidgetId) return w;
|
|
128
|
+
if (field === 'layoutW') {
|
|
129
|
+
return { ...w, layout: { ...(w.layout || {}), w: value } as DashboardWidgetSchema['layout'] };
|
|
130
|
+
}
|
|
131
|
+
if (field === 'layoutH') {
|
|
132
|
+
return { ...w, layout: { ...(w.layout || {}), h: value } as DashboardWidgetSchema['layout'] };
|
|
133
|
+
}
|
|
134
|
+
return { ...w, [field]: value };
|
|
135
|
+
}),
|
|
136
|
+
};
|
|
137
|
+
});
|
|
138
|
+
},
|
|
139
|
+
[selectedWidgetId],
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const handleWidgetSave = useCallback(
|
|
143
|
+
(widgetConfig: Record<string, any>) => {
|
|
144
|
+
if (selectedWidgetId && onWidgetSave) {
|
|
145
|
+
onWidgetSave(selectedWidgetId, widgetConfig);
|
|
146
|
+
}
|
|
147
|
+
setSelectedWidgetId(null);
|
|
148
|
+
setConfigVersion((v) => v + 1);
|
|
149
|
+
},
|
|
150
|
+
[selectedWidgetId, onWidgetSave],
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
const handleToggleConfig = useCallback(() => {
|
|
154
|
+
setConfigOpen((prev) => !prev);
|
|
155
|
+
setSelectedWidgetId(null);
|
|
156
|
+
}, []);
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<div
|
|
160
|
+
className={cn('flex h-full w-full', className)}
|
|
161
|
+
data-testid="dashboard-with-config"
|
|
162
|
+
>
|
|
163
|
+
{/* Main dashboard area */}
|
|
164
|
+
<div className="flex-1 min-w-0 overflow-auto relative">
|
|
165
|
+
{/* Settings toggle button */}
|
|
166
|
+
<div className="absolute top-2 right-2 z-10">
|
|
167
|
+
<Button
|
|
168
|
+
size="sm"
|
|
169
|
+
variant={configOpen ? 'default' : 'outline'}
|
|
170
|
+
onClick={handleToggleConfig}
|
|
171
|
+
data-testid="dashboard-config-toggle"
|
|
172
|
+
>
|
|
173
|
+
<Settings className="h-3.5 w-3.5 mr-1" />
|
|
174
|
+
Settings
|
|
175
|
+
</Button>
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
<DashboardRenderer
|
|
179
|
+
schema={liveSchema}
|
|
180
|
+
onRefresh={onRefresh}
|
|
181
|
+
recordCount={recordCount}
|
|
182
|
+
designMode={configOpen}
|
|
183
|
+
selectedWidgetId={selectedWidgetId}
|
|
184
|
+
onWidgetClick={handleWidgetSelect}
|
|
185
|
+
/>
|
|
186
|
+
</div>
|
|
187
|
+
|
|
188
|
+
{/* Config panel sidebar */}
|
|
189
|
+
{configOpen && (
|
|
190
|
+
<div className="relative shrink-0">
|
|
191
|
+
{selectedWidgetId && selectedWidgetConfig ? (
|
|
192
|
+
<WidgetConfigPanel
|
|
193
|
+
open={true}
|
|
194
|
+
onClose={handleWidgetClose}
|
|
195
|
+
config={selectedWidgetConfig}
|
|
196
|
+
onSave={handleWidgetSave}
|
|
197
|
+
onFieldChange={handleWidgetFieldChange}
|
|
198
|
+
/>
|
|
199
|
+
) : (
|
|
200
|
+
<DashboardConfigPanel
|
|
201
|
+
open={true}
|
|
202
|
+
onClose={() => setConfigOpen(false)}
|
|
203
|
+
config={config}
|
|
204
|
+
onSave={onConfigSave}
|
|
205
|
+
/>
|
|
206
|
+
)}
|
|
207
|
+
</div>
|
|
208
|
+
)}
|
|
209
|
+
</div>
|
|
210
|
+
);
|
|
211
|
+
}
|
package/src/MetricCard.tsx
CHANGED
|
@@ -12,13 +12,20 @@ import { cn } from '@object-ui/components';
|
|
|
12
12
|
import { ArrowDownIcon, ArrowUpIcon, MinusIcon } from 'lucide-react';
|
|
13
13
|
import * as LucideIcons from 'lucide-react';
|
|
14
14
|
|
|
15
|
+
/** Resolve an I18nLabel (string or {key, defaultValue}) to a plain string. */
|
|
16
|
+
function resolveLabel(label: string | { key?: string; defaultValue?: string } | undefined): string | undefined {
|
|
17
|
+
if (label === undefined || label === null) return undefined;
|
|
18
|
+
if (typeof label === 'string') return label;
|
|
19
|
+
return label.defaultValue || label.key;
|
|
20
|
+
}
|
|
21
|
+
|
|
15
22
|
export interface MetricCardProps {
|
|
16
|
-
title?: string;
|
|
23
|
+
title?: string | { key?: string; defaultValue?: string };
|
|
17
24
|
value: string | number;
|
|
18
25
|
icon?: string;
|
|
19
26
|
trend?: 'up' | 'down' | 'neutral';
|
|
20
27
|
trendValue?: string;
|
|
21
|
-
description?: string;
|
|
28
|
+
description?: string | { key?: string; defaultValue?: string };
|
|
22
29
|
className?: string;
|
|
23
30
|
}
|
|
24
31
|
|
|
@@ -43,7 +50,7 @@ export const MetricCard: React.FC<MetricCardProps> = ({
|
|
|
43
50
|
<Card className={cn("h-full", className)} {...props}>
|
|
44
51
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
45
52
|
<CardTitle className="text-sm font-medium">
|
|
46
|
-
{title}
|
|
53
|
+
{resolveLabel(title)}
|
|
47
54
|
</CardTitle>
|
|
48
55
|
{IconComponent && (
|
|
49
56
|
<IconComponent className="h-4 w-4 text-muted-foreground" />
|
|
@@ -66,7 +73,7 @@ export const MetricCard: React.FC<MetricCardProps> = ({
|
|
|
66
73
|
{trendValue}
|
|
67
74
|
</span>
|
|
68
75
|
)}
|
|
69
|
-
{description}
|
|
76
|
+
{resolveLabel(description)}
|
|
70
77
|
</p>
|
|
71
78
|
)}
|
|
72
79
|
</CardContent>
|