@object-ui/plugin-dashboard 3.3.0 → 3.3.2

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 (48) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +21 -1
  3. package/dist/index.js +876 -797
  4. package/dist/index.umd.cjs +4 -4
  5. package/dist/packages/plugin-dashboard/src/DashboardRenderer.d.ts +5 -0
  6. package/dist/packages/plugin-dashboard/src/DashboardRenderer.d.ts.map +1 -1
  7. package/dist/packages/plugin-dashboard/src/MetricCard.d.ts.map +1 -1
  8. package/dist/packages/plugin-dashboard/src/MetricWidget.d.ts +4 -1
  9. package/dist/packages/plugin-dashboard/src/MetricWidget.d.ts.map +1 -1
  10. package/dist/packages/plugin-dashboard/src/ObjectMetricWidget.d.ts +2 -0
  11. package/dist/packages/plugin-dashboard/src/ObjectMetricWidget.d.ts.map +1 -1
  12. package/dist/packages/plugin-dashboard/src/index.d.ts +1 -1
  13. package/package.json +40 -7
  14. package/.turbo/turbo-build.log +0 -41
  15. package/src/DashboardConfigPanel.stories.tsx +0 -164
  16. package/src/DashboardConfigPanel.tsx +0 -158
  17. package/src/DashboardGridLayout.tsx +0 -367
  18. package/src/DashboardRenderer.stories.tsx +0 -173
  19. package/src/DashboardRenderer.tsx +0 -479
  20. package/src/DashboardWithConfig.tsx +0 -211
  21. package/src/MetricCard.tsx +0 -102
  22. package/src/MetricWidget.tsx +0 -96
  23. package/src/ObjectDataTable.tsx +0 -226
  24. package/src/ObjectMetricWidget.tsx +0 -159
  25. package/src/ObjectPivotTable.tsx +0 -160
  26. package/src/PivotTable.tsx +0 -262
  27. package/src/WidgetConfigPanel.tsx +0 -540
  28. package/src/__tests__/DashboardConfigPanel.test.tsx +0 -206
  29. package/src/__tests__/DashboardGridLayout.test.tsx +0 -199
  30. package/src/__tests__/DashboardRenderer.autoRefresh.test.tsx +0 -124
  31. package/src/__tests__/DashboardRenderer.designMode.test.tsx +0 -386
  32. package/src/__tests__/DashboardRenderer.header.test.tsx +0 -114
  33. package/src/__tests__/DashboardRenderer.mobile.test.tsx +0 -214
  34. package/src/__tests__/DashboardRenderer.widgetData.test.tsx +0 -1411
  35. package/src/__tests__/DashboardWithConfig.test.tsx +0 -276
  36. package/src/__tests__/MetricCard.test.tsx +0 -107
  37. package/src/__tests__/ObjectDataTable.test.tsx +0 -211
  38. package/src/__tests__/ObjectMetricWidget.test.tsx +0 -196
  39. package/src/__tests__/ObjectPivotTable.test.tsx +0 -192
  40. package/src/__tests__/PivotTable.test.tsx +0 -162
  41. package/src/__tests__/WidgetConfigPanel.test.tsx +0 -492
  42. package/src/__tests__/ensureWidgetIds.test.tsx +0 -103
  43. package/src/index.tsx +0 -236
  44. package/src/utils.ts +0 -17
  45. package/tsconfig.json +0 -19
  46. package/vite.config.ts +0 -64
  47. package/vitest.config.ts +0 -9
  48. package/vitest.setup.tsx +0 -18
@@ -1,479 +0,0 @@
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 type { DashboardSchema, DashboardWidgetSchema } from '@object-ui/types';
10
- import { SchemaRenderer } from '@object-ui/react';
11
- import { cn, Card, CardHeader, CardTitle, CardContent, Button } from '@object-ui/components';
12
- import { forwardRef, useState, useEffect, useCallback, useRef } from 'react';
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
- }
22
-
23
- // Color palette for charts
24
- const CHART_COLORS = [
25
- 'hsl(var(--chart-1))',
26
- 'hsl(var(--chart-2))',
27
- 'hsl(var(--chart-3))',
28
- 'hsl(var(--chart-4))',
29
- 'hsl(var(--chart-5))',
30
- ];
31
-
32
- export interface DashboardRendererProps {
33
- schema: DashboardSchema;
34
- className?: string;
35
- /** Callback invoked when dashboard refresh is triggered (manual or auto) */
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;
47
- [key: string]: any;
48
- }
49
-
50
- export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererProps>(
51
- ({ schema, className, dataSource, onRefresh, recordCount, userActions, designMode, selectedWidgetId, onWidgetClick, ...props }, ref) => {
52
- const columns = schema.columns || 4; // Default to 4 columns for better density
53
- const gap = schema.gap || 4;
54
- const [refreshing, setRefreshing] = useState(false);
55
- const [isMobile, setIsMobile] = useState(false);
56
- const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
57
-
58
- useEffect(() => {
59
- const checkMobile = () => setIsMobile(window.innerWidth < 768);
60
- checkMobile();
61
- window.addEventListener('resize', checkMobile);
62
- return () => window.removeEventListener('resize', checkMobile);
63
- }, []);
64
-
65
- const handleRefresh = useCallback(() => {
66
- if (!onRefresh) return;
67
- setRefreshing(true);
68
- onRefresh();
69
- // Reset refreshing indicator after a short delay
70
- setTimeout(() => setRefreshing(false), 600);
71
- }, [onRefresh]);
72
-
73
- // Auto-refresh interval
74
- useEffect(() => {
75
- if (!schema.refreshInterval || schema.refreshInterval <= 0 || !onRefresh) return;
76
- intervalRef.current = setInterval(handleRefresh, schema.refreshInterval * 1000);
77
- return () => {
78
- if (intervalRef.current) clearInterval(intervalRef.current);
79
- };
80
- }, [schema.refreshInterval, onRefresh, handleRefresh]);
81
-
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
-
121
- const getComponentSchema = () => {
122
- if (widget.component) return widget.component;
123
-
124
- // Handle Shorthand Registry Mappings
125
- const widgetType = widget.type;
126
- const options = (widget.options || {}) as Record<string, any>;
127
-
128
- // Metric widgets with object binding — delegate to ObjectMetricWidget
129
- // for async data loading with proper error/loading states.
130
- // Static metric options (label, value, trend, icon) are passed as
131
- // fallback values that render only when no dataSource is available.
132
- if (widgetType === 'metric' && widget.object) {
133
- const widgetData = options.data;
134
- const aggregate = isObjectProvider(widgetData) && widgetData.aggregate
135
- ? {
136
- field: widget.valueField || widgetData.aggregate.field,
137
- function: widget.aggregate || widgetData.aggregate.function,
138
- // Prefer explicit categoryField or aggregate.groupBy; otherwise, default to a single bucket.
139
- groupBy: widget.categoryField ?? widgetData.aggregate.groupBy ?? '_all',
140
- }
141
- : widget.aggregate ? {
142
- field: widget.valueField || 'value',
143
- function: widget.aggregate,
144
- // Default to a single group unless the user explicitly configures a categoryField.
145
- groupBy: widget.categoryField || '_all',
146
- } : undefined;
147
-
148
- return {
149
- type: 'object-metric',
150
- objectName: widget.object || (isObjectProvider(widgetData) ? widgetData.object : undefined),
151
- aggregate,
152
- filter: (isObjectProvider(widgetData) ? widgetData.filter : undefined) || widget.filter,
153
- label: options.label || resolveLabel(widget.title) || '',
154
- fallbackValue: options.value,
155
- trend: options.trend,
156
- icon: options.icon,
157
- description: options.description,
158
- };
159
- }
160
-
161
- if (widgetType === 'bar' || widgetType === 'line' || widgetType === 'area' || widgetType === 'pie' || widgetType === 'donut' || widgetType === 'scatter') {
162
- // Support data at widget level or nested inside options
163
- const widgetData = (widget as any).data || options.data;
164
- // Widget-level fields (from config panel) override options-level fields
165
- const xAxisKey = widget.categoryField || options.xField || 'name';
166
- const yField = widget.valueField || options.yField || 'value';
167
-
168
- // provider: 'object' — delegate to ObjectChart for async data loading
169
- if (isObjectProvider(widgetData)) {
170
- // Merge widget-level fields with data provider config.
171
- // Widget-level fields take precedence so that config panel
172
- // edits are immediately reflected in the live preview.
173
- const providerAgg = widgetData.aggregate;
174
- const effectiveAggregate = providerAgg ? {
175
- field: widget.valueField || providerAgg.field,
176
- function: widget.aggregate || providerAgg.function,
177
- groupBy: widget.categoryField || providerAgg.groupBy,
178
- } : undefined;
179
- const effectiveYField = effectiveAggregate?.field || yField;
180
- return {
181
- type: 'object-chart',
182
- chartType: widgetType,
183
- objectName: widget.object || widgetData.object,
184
- aggregate: effectiveAggregate,
185
- xAxisKey: xAxisKey,
186
- series: [{ dataKey: effectiveYField }],
187
- colors: CHART_COLORS,
188
- className: "h-[200px] sm:h-[250px] md:h-[300px]"
189
- };
190
- }
191
-
192
- // No explicit data provider but widget has object binding
193
- // (e.g. newly created widget via config panel) — build object-chart
194
- if (!widgetData && widget.object) {
195
- const aggregate = widget.aggregate ? {
196
- field: widget.valueField || 'value',
197
- function: widget.aggregate,
198
- groupBy: widget.categoryField || 'name',
199
- } : undefined;
200
- return {
201
- type: 'object-chart',
202
- chartType: widgetType,
203
- objectName: widget.object,
204
- aggregate,
205
- xAxisKey: xAxisKey,
206
- series: [{ dataKey: widget.valueField || 'value' }],
207
- colors: CHART_COLORS,
208
- className: "h-[200px] sm:h-[250px] md:h-[300px]"
209
- };
210
- }
211
-
212
- const dataItems = Array.isArray(widgetData) ? widgetData : widgetData?.items || [];
213
-
214
- return {
215
- type: 'chart',
216
- chartType: widgetType,
217
- data: dataItems,
218
- xAxisKey: xAxisKey,
219
- series: [{ dataKey: yField }],
220
- colors: CHART_COLORS,
221
- className: "h-[200px] sm:h-[250px] md:h-[300px]"
222
- };
223
- }
224
-
225
- if (widgetType === 'table') {
226
- // Support data at widget level or nested inside options
227
- const widgetData = (widget as any).data || options.data;
228
-
229
- // provider: 'object' — use ObjectDataTable for async data loading
230
- if (isObjectProvider(widgetData)) {
231
- const { data: _data, ...restOptions } = options;
232
- return {
233
- type: 'object-data-table',
234
- ...restOptions,
235
- objectName: widget.object || widgetData.object,
236
- dataProvider: widgetData,
237
- filter: widgetData.filter || widget.filter,
238
- searchable: widget.searchable ?? false,
239
- pagination: widget.pagination ?? false,
240
- className: "border-0"
241
- };
242
- }
243
-
244
- // No explicit data provider but widget has object binding
245
- if (!widgetData && widget.object) {
246
- return {
247
- type: 'object-data-table',
248
- ...options,
249
- objectName: widget.object,
250
- filter: widget.filter,
251
- searchable: widget.searchable ?? false,
252
- pagination: widget.pagination ?? false,
253
- className: "border-0"
254
- };
255
- }
256
-
257
- return {
258
- type: 'data-table',
259
- ...options,
260
- data: widgetData?.items || [],
261
- searchable: false,
262
- pagination: false,
263
- className: "border-0"
264
- };
265
- }
266
-
267
- if (widgetType === 'pivot') {
268
- const widgetData = (widget as any).data || options.data;
269
-
270
- // provider: 'object' — use ObjectPivotTable for async data loading
271
- if (isObjectProvider(widgetData)) {
272
- const { data: _data, ...restOptions } = options;
273
- return {
274
- type: 'object-pivot',
275
- ...restOptions,
276
- objectName: widget.object || widgetData.object,
277
- dataProvider: widgetData,
278
- filter: widgetData.filter || widget.filter,
279
- };
280
- }
281
-
282
- // No explicit data provider but widget has object binding
283
- if (!widgetData && widget.object) {
284
- return {
285
- type: 'object-pivot',
286
- ...options,
287
- objectName: widget.object,
288
- filter: widget.filter,
289
- };
290
- }
291
-
292
- return {
293
- type: 'pivot',
294
- ...options,
295
- data: Array.isArray(widgetData) ? widgetData : widgetData?.items || [],
296
- };
297
- }
298
-
299
- return {
300
- ...widget,
301
- ...options
302
- };
303
- };
304
-
305
- const componentSchema = getComponentSchema();
306
- const isSelfContained = widget.type === 'metric';
307
- const resolvedTitle = resolveLabel(widget.title);
308
- const resolvedDescription = resolveLabel(widget.description);
309
- const widgetKey = widget.id || resolvedTitle || `widget-${index}`;
310
- const isSelected = designMode && selectedWidgetId === widget.id;
311
-
312
- const designModeProps = designMode ? {
313
- 'data-testid': `dashboard-preview-widget-${widget.id}`,
314
- 'data-widget-id': widget.id,
315
- role: 'button' as const,
316
- tabIndex: 0,
317
- 'aria-selected': isSelected,
318
- 'aria-label': `Widget: ${resolvedTitle || `Widget ${index + 1}`}`,
319
- onClick: (e: React.MouseEvent) => handleWidgetClick(e, widget.id),
320
- onKeyDown: (e: React.KeyboardEvent) => handleWidgetKeyDown(e, widget.id, index),
321
- } : {};
322
-
323
- const selectionClasses = designMode
324
- ? cn(
325
- "cursor-pointer rounded-lg transition-all outline-none",
326
- "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
327
- isSelected
328
- ? "ring-2 ring-primary shadow-md bg-primary/5 dark:bg-primary/10"
329
- : "hover:ring-2 hover:ring-primary/40 hover:shadow-sm"
330
- )
331
- : undefined;
332
-
333
- if (isSelfContained) {
334
- return (
335
- <div
336
- key={widgetKey}
337
- className={cn("h-full w-full", designMode && "relative", selectionClasses)}
338
- style={!isMobile && clampedLayout ? {
339
- gridColumn: `span ${clampedLayout.w}`,
340
- gridRow: `span ${clampedLayout.h}`
341
- }: undefined}
342
- {...designModeProps}
343
- >
344
- <SchemaRenderer schema={componentSchema} className={cn("h-full w-full", designMode && "pointer-events-none")} />
345
- {designMode && <div className="absolute inset-0 z-10" aria-hidden="true" data-testid="widget-click-overlay" />}
346
- </div>
347
- );
348
- }
349
-
350
- return (
351
- <Card
352
- key={widgetKey}
353
- className={cn(
354
- "overflow-hidden border-border/50 shadow-sm transition-all hover:shadow-md",
355
- "bg-card/50 backdrop-blur-sm",
356
- forceMobileFullWidth && "w-full",
357
- designMode && "relative",
358
- selectionClasses
359
- )}
360
- style={!isMobile && clampedLayout ? {
361
- gridColumn: `span ${clampedLayout.w}`,
362
- gridRow: `span ${clampedLayout.h}`
363
- }: undefined}
364
- {...designModeProps}
365
- >
366
- {resolvedTitle && (
367
- <CardHeader className="pb-2 border-b border-border/40 bg-muted/20 px-3 sm:px-6">
368
- <CardTitle className="text-sm sm:text-base font-medium tracking-tight truncate" title={resolvedTitle}>
369
- {resolvedTitle}
370
- </CardTitle>
371
- {resolvedDescription && (
372
- <p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{resolvedDescription}</p>
373
- )}
374
- </CardHeader>
375
- )}
376
- <CardContent className="p-0">
377
- <div className={cn("h-full w-full", "p-3 sm:p-4 md:p-6", designMode && "pointer-events-none")}>
378
- <SchemaRenderer schema={componentSchema} />
379
- </div>
380
- </CardContent>
381
- {designMode && <div className="absolute inset-0 z-10" aria-hidden="true" data-testid="widget-click-overlay" />}
382
- </Card>
383
- );
384
- };
385
-
386
- const headerSection = schema.header && (
387
- <div className="col-span-full mb-4">
388
- {schema.header.showTitle !== false && schema.title && (
389
- <h2 className="text-lg font-semibold tracking-tight">{resolveLabel(schema.title)}</h2>
390
- )}
391
- {schema.header.showDescription !== false && schema.description && (
392
- <p className="text-sm text-muted-foreground mt-1">{resolveLabel(schema.description)}</p>
393
- )}
394
- {schema.header.actions && schema.header.actions.length > 0 && (
395
- <div className="flex gap-2 mt-3">
396
- {schema.header.actions.map((action: { label: string; actionUrl?: string; actionType?: string; icon?: string }, i: number) => (
397
- <Button key={i} variant="outline" size="sm">
398
- {action.label}
399
- </Button>
400
- ))}
401
- </div>
402
- )}
403
- </div>
404
- );
405
-
406
- const recordCountBadge = recordCount !== undefined && (
407
- <span className="text-xs text-muted-foreground">
408
- {recordCount.toLocaleString()} records
409
- </span>
410
- );
411
-
412
- const userActionsAttr = userActions ? JSON.stringify(userActions) : undefined;
413
-
414
- const refreshButton = onRefresh && (
415
- <div className={cn("flex items-center justify-end gap-3 mb-2", !isMobile && "col-span-full")}>
416
- {recordCountBadge}
417
- <Button
418
- variant="outline"
419
- size="sm"
420
- onClick={handleRefresh}
421
- disabled={refreshing}
422
- aria-label="Refresh dashboard"
423
- >
424
- <RefreshCw className={cn("h-4 w-4 mr-2", refreshing && "animate-spin")} />
425
- {refreshing ? 'Refreshing…' : 'Refresh All'}
426
- </Button>
427
- </div>
428
- );
429
-
430
- if (isMobile) {
431
- // Separate metric widgets from other widgets for better mobile layout
432
- const metricWidgets = schema.widgets?.filter((w: DashboardWidgetSchema) => w.type === 'metric') || [];
433
- const otherWidgets = schema.widgets?.filter((w: DashboardWidgetSchema) => w.type !== 'metric') || [];
434
-
435
- return (
436
- <div ref={ref} className={cn("flex flex-col gap-4 px-4", className)} data-user-actions={userActionsAttr} onClick={handleBackgroundClick} {...props}>
437
- {headerSection}
438
- {refreshButton}
439
-
440
- {/* Metric cards: 2-column grid */}
441
- {metricWidgets.length > 0 && (
442
- <div className="grid grid-cols-2 gap-3" onClick={handleBackgroundClick}>
443
- {metricWidgets.map((widget: DashboardWidgetSchema, index: number) => renderWidget(widget, index))}
444
- </div>
445
- )}
446
-
447
- {/* Other widgets (charts, tables): full-width vertical stack */}
448
- {otherWidgets.length > 0 && (
449
- <div className="flex flex-col gap-4" onClick={handleBackgroundClick}>
450
- {otherWidgets.map((widget: DashboardWidgetSchema, index: number) => renderWidget(widget, index, true))}
451
- </div>
452
- )}
453
- </div>
454
- );
455
- }
456
-
457
- return (
458
- <div
459
- ref={ref}
460
- className={cn(
461
- "grid auto-rows-min",
462
- "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4",
463
- className
464
- )}
465
- style={{
466
- ...(columns > 4 && { gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }),
467
- gap: `${gap * 0.25}rem`
468
- }}
469
- data-user-actions={userActionsAttr}
470
- onClick={handleBackgroundClick}
471
- {...props}
472
- >
473
- {headerSection}
474
- {refreshButton}
475
- {schema.widgets?.map((widget: DashboardWidgetSchema, index: number) => renderWidget(widget, index))}
476
- </div>
477
- );
478
- }
479
- );
@@ -1,211 +0,0 @@
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
- }