@object-ui/plugin-dashboard 3.0.2 → 3.1.0

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.
Files changed (55) hide show
  1. package/.turbo/turbo-build.log +40 -7
  2. package/CHANGELOG.md +9 -0
  3. package/dist/index.js +3893 -2669
  4. package/dist/index.umd.cjs +5 -5
  5. package/dist/src/DashboardConfigPanel.d.ts +28 -0
  6. package/dist/src/DashboardConfigPanel.d.ts.map +1 -0
  7. package/dist/src/DashboardConfigPanel.stories.d.ts +14 -0
  8. package/dist/src/DashboardConfigPanel.stories.d.ts.map +1 -0
  9. package/dist/src/DashboardGridLayout.d.ts.map +1 -1
  10. package/dist/src/DashboardRenderer.d.ts +14 -0
  11. package/dist/src/DashboardRenderer.d.ts.map +1 -1
  12. package/dist/src/DashboardWithConfig.d.ts +32 -0
  13. package/dist/src/DashboardWithConfig.d.ts.map +1 -0
  14. package/dist/src/MetricCard.d.ts +8 -2
  15. package/dist/src/MetricCard.d.ts.map +1 -1
  16. package/dist/src/MetricWidget.d.ts +12 -3
  17. package/dist/src/MetricWidget.d.ts.map +1 -1
  18. package/dist/src/ObjectDataTable.d.ts +39 -0
  19. package/dist/src/ObjectDataTable.d.ts.map +1 -0
  20. package/dist/src/ObjectPivotTable.d.ts +29 -0
  21. package/dist/src/ObjectPivotTable.d.ts.map +1 -0
  22. package/dist/src/PivotTable.d.ts +14 -0
  23. package/dist/src/PivotTable.d.ts.map +1 -0
  24. package/dist/src/WidgetConfigPanel.d.ts +43 -0
  25. package/dist/src/WidgetConfigPanel.d.ts.map +1 -0
  26. package/dist/src/index.d.ts +13 -1
  27. package/dist/src/index.d.ts.map +1 -1
  28. package/dist/src/utils.d.ts +14 -0
  29. package/dist/src/utils.d.ts.map +1 -0
  30. package/package.json +7 -7
  31. package/src/DashboardConfigPanel.stories.tsx +164 -0
  32. package/src/DashboardConfigPanel.tsx +158 -0
  33. package/src/DashboardGridLayout.tsx +113 -10
  34. package/src/DashboardRenderer.tsx +283 -37
  35. package/src/DashboardWithConfig.tsx +211 -0
  36. package/src/MetricCard.tsx +11 -4
  37. package/src/MetricWidget.tsx +18 -11
  38. package/src/ObjectDataTable.tsx +191 -0
  39. package/src/ObjectPivotTable.tsx +160 -0
  40. package/src/PivotTable.tsx +262 -0
  41. package/src/WidgetConfigPanel.tsx +540 -0
  42. package/src/__tests__/DashboardConfigPanel.test.tsx +206 -0
  43. package/src/__tests__/DashboardRenderer.designMode.test.tsx +386 -0
  44. package/src/__tests__/DashboardRenderer.header.test.tsx +114 -0
  45. package/src/__tests__/DashboardRenderer.mobile.test.tsx +214 -0
  46. package/src/__tests__/DashboardRenderer.widgetData.test.tsx +1283 -0
  47. package/src/__tests__/DashboardWithConfig.test.tsx +276 -0
  48. package/src/__tests__/MetricCard.test.tsx +23 -0
  49. package/src/__tests__/ObjectDataTable.test.tsx +122 -0
  50. package/src/__tests__/ObjectPivotTable.test.tsx +192 -0
  51. package/src/__tests__/PivotTable.test.tsx +162 -0
  52. package/src/__tests__/WidgetConfigPanel.test.tsx +492 -0
  53. package/src/__tests__/ensureWidgetIds.test.tsx +103 -0
  54. package/src/index.tsx +107 -1
  55. 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 < 640);
59
+ const checkMobile = () => setIsMobile(window.innerWidth < 768);
42
60
  checkMobile();
43
61
  window.addEventListener('resize', checkMobile);
44
62
  return () => window.removeEventListener('resize', checkMobile);
@@ -61,17 +79,103 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
61
79
  };
62
80
  }, [schema.refreshInterval, onRefresh, handleRefresh]);
63
81
 
64
- const renderWidget = (widget: DashboardWidgetSchema) => {
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
- const widgetType = (widget as any).type;
70
- if (widgetType === 'bar' || widgetType === 'line' || widgetType === 'area' || widgetType === 'pie' || widgetType === 'donut') {
71
- const dataItems = Array.isArray((widget as any).data) ? (widget as any).data : (widget as any).data?.items || [];
72
- const options = (widget as any).options || {};
73
- const xAxisKey = options.xField || 'name';
74
- const yField = options.yField || 'value';
125
+ const widgetType = widget.type;
126
+ const options = (widget.options || {}) as Record<string, any>;
127
+ if (widgetType === 'bar' || widgetType === 'line' || widgetType === 'area' || widgetType === 'pie' || widgetType === 'donut' || widgetType === 'scatter') {
128
+ // Support data at widget level or nested inside options
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
+
178
+ const dataItems = Array.isArray(widgetData) ? widgetData : widgetData?.items || [];
75
179
 
76
180
  return {
77
181
  type: 'chart',
@@ -85,71 +189,197 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
85
189
  }
86
190
 
87
191
  if (widgetType === 'table') {
192
+ // Support data at widget level or nested inside options
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
+
88
223
  return {
89
224
  type: 'data-table',
90
- ...(widget as any).options,
91
- data: (widget as any).data?.items || [],
225
+ ...options,
226
+ data: widgetData?.items || [],
92
227
  searchable: false,
93
228
  pagination: false,
94
229
  className: "border-0"
95
230
  };
96
231
  }
97
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
+
98
265
  return {
99
266
  ...widget,
100
- ...((widget as any).options || {})
267
+ ...options
101
268
  };
102
269
  };
103
270
 
104
271
  const componentSchema = getComponentSchema();
105
- const isSelfContained = (widget as any).type === 'metric';
272
+ const isSelfContained = widget.type === 'metric';
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;
106
298
 
107
299
  if (isSelfContained) {
108
300
  return (
109
301
  <div
110
- key={widget.id || widget.title}
111
- className={cn("h-full w-full", isMobile && "w-[85vw] shrink-0 snap-center")}
112
- style={!isMobile && widget.layout ? {
113
- gridColumn: `span ${widget.layout.w}`,
114
- gridRow: `span ${widget.layout.h}`
302
+ key={widgetKey}
303
+ className={cn("h-full w-full", designMode && "relative", selectionClasses)}
304
+ style={!isMobile && clampedLayout ? {
305
+ gridColumn: `span ${clampedLayout.w}`,
306
+ gridRow: `span ${clampedLayout.h}`
115
307
  }: undefined}
308
+ {...designModeProps}
116
309
  >
117
- <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" />}
118
312
  </div>
119
313
  );
120
314
  }
121
315
 
122
316
  return (
123
317
  <Card
124
- key={widget.id || widget.title}
318
+ key={widgetKey}
125
319
  className={cn(
126
320
  "overflow-hidden border-border/50 shadow-sm transition-all hover:shadow-md",
127
321
  "bg-card/50 backdrop-blur-sm",
128
- isMobile && "w-[85vw] shrink-0 snap-center"
322
+ forceMobileFullWidth && "w-full",
323
+ designMode && "relative",
324
+ selectionClasses
129
325
  )}
130
- style={!isMobile && widget.layout ? {
131
- gridColumn: `span ${widget.layout.w}`,
132
- gridRow: `span ${widget.layout.h}`
326
+ style={!isMobile && clampedLayout ? {
327
+ gridColumn: `span ${clampedLayout.w}`,
328
+ gridRow: `span ${clampedLayout.h}`
133
329
  }: undefined}
330
+ {...designModeProps}
134
331
  >
135
- {widget.title && (
332
+ {resolvedTitle && (
136
333
  <CardHeader className="pb-2 border-b border-border/40 bg-muted/20 px-3 sm:px-6">
137
- <CardTitle className="text-sm sm:text-base font-medium tracking-tight truncate" title={widget.title}>
138
- {widget.title}
334
+ <CardTitle className="text-sm sm:text-base font-medium tracking-tight truncate" title={resolvedTitle}>
335
+ {resolvedTitle}
139
336
  </CardTitle>
337
+ {resolvedDescription && (
338
+ <p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{resolvedDescription}</p>
339
+ )}
140
340
  </CardHeader>
141
341
  )}
142
342
  <CardContent className="p-0">
143
- <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")}>
144
344
  <SchemaRenderer schema={componentSchema} />
145
345
  </div>
146
346
  </CardContent>
347
+ {designMode && <div className="absolute inset-0 z-10" aria-hidden="true" data-testid="widget-click-overlay" />}
147
348
  </Card>
148
349
  );
149
350
  };
150
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
+
151
380
  const refreshButton = onRefresh && (
152
- <div className={cn(isMobile ? "flex justify-end mb-2" : "col-span-full flex justify-end mb-2")}>
381
+ <div className={cn("flex items-center justify-end gap-3 mb-2", !isMobile && "col-span-full")}>
382
+ {recordCountBadge}
153
383
  <Button
154
384
  variant="outline"
155
385
  size="sm"
@@ -164,15 +394,28 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
164
394
  );
165
395
 
166
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
+
167
401
  return (
168
- <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}
169
404
  {refreshButton}
170
- <div
171
- className="flex overflow-x-auto snap-x snap-mandatory gap-3 pb-4 [-webkit-overflow-scrolling:touch]"
172
- style={{ scrollPaddingLeft: '0.75rem' }}
173
- >
174
- {schema.widgets?.map((widget: DashboardWidgetSchema) => renderWidget(widget))}
175
- </div>
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
+ )}
176
419
  </div>
177
420
  );
178
421
  }
@@ -189,10 +432,13 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
189
432
  ...(columns > 4 && { gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }),
190
433
  gap: `${gap * 0.25}rem`
191
434
  }}
435
+ data-user-actions={userActionsAttr}
436
+ onClick={handleBackgroundClick}
192
437
  {...props}
193
438
  >
439
+ {headerSection}
194
440
  {refreshButton}
195
- {schema.widgets?.map((widget: DashboardWidgetSchema) => renderWidget(widget))}
441
+ {schema.widgets?.map((widget: DashboardWidgetSchema, index: number) => renderWidget(widget, index))}
196
442
  </div>
197
443
  );
198
444
  }
@@ -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
+ }
@@ -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>