@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
@@ -0,0 +1,206 @@
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 { describe, it, expect, vi } from 'vitest';
10
+ import { render, screen, fireEvent } from '@testing-library/react';
11
+ import { DashboardConfigPanel } from '../DashboardConfigPanel';
12
+
13
+ const defaultConfig = {
14
+ columns: 3,
15
+ gap: 4,
16
+ rowHeight: '120',
17
+ refreshInterval: '0',
18
+ title: 'My Dashboard',
19
+ showDescription: true,
20
+ theme: 'auto',
21
+ };
22
+
23
+ describe('DashboardConfigPanel', () => {
24
+ it('should render nothing when closed', () => {
25
+ const { container } = render(
26
+ <DashboardConfigPanel
27
+ open={false}
28
+ onClose={vi.fn()}
29
+ config={defaultConfig}
30
+ onSave={vi.fn()}
31
+ />,
32
+ );
33
+ expect(container.innerHTML).toBe('');
34
+ });
35
+
36
+ it('should render panel when open', () => {
37
+ render(
38
+ <DashboardConfigPanel
39
+ open={true}
40
+ onClose={vi.fn()}
41
+ config={defaultConfig}
42
+ onSave={vi.fn()}
43
+ />,
44
+ );
45
+ expect(screen.getByTestId('config-panel')).toBeDefined();
46
+ });
47
+
48
+ it('should display dashboard breadcrumb', () => {
49
+ render(
50
+ <DashboardConfigPanel
51
+ open={true}
52
+ onClose={vi.fn()}
53
+ config={defaultConfig}
54
+ onSave={vi.fn()}
55
+ />,
56
+ );
57
+ expect(screen.getByText('Dashboard')).toBeDefined();
58
+ expect(screen.getByText('Configuration')).toBeDefined();
59
+ });
60
+
61
+ it('should display layout section fields', () => {
62
+ render(
63
+ <DashboardConfigPanel
64
+ open={true}
65
+ onClose={vi.fn()}
66
+ config={defaultConfig}
67
+ onSave={vi.fn()}
68
+ />,
69
+ );
70
+ expect(screen.getByText('Layout')).toBeDefined();
71
+ expect(screen.getByText('Columns')).toBeDefined();
72
+ expect(screen.getByText('Gap')).toBeDefined();
73
+ expect(screen.getByText('Row height')).toBeDefined();
74
+ });
75
+
76
+ it('should display data section', () => {
77
+ render(
78
+ <DashboardConfigPanel
79
+ open={true}
80
+ onClose={vi.fn()}
81
+ config={defaultConfig}
82
+ onSave={vi.fn()}
83
+ />,
84
+ );
85
+ expect(screen.getByText('Data')).toBeDefined();
86
+ expect(screen.getByText('Refresh interval')).toBeDefined();
87
+ });
88
+
89
+ it('should have appearance section collapsed by default', () => {
90
+ render(
91
+ <DashboardConfigPanel
92
+ open={true}
93
+ onClose={vi.fn()}
94
+ config={defaultConfig}
95
+ onSave={vi.fn()}
96
+ />,
97
+ );
98
+ expect(screen.getByText('Appearance')).toBeDefined();
99
+ // Title field should be hidden since appearance is collapsed by default
100
+ // Note: 'My Dashboard' is the title field value from the config, not the section title.
101
+ // The input label 'Title' should not be visible while collapsed
102
+ expect(screen.queryByText('Show description')).toBeNull();
103
+ });
104
+
105
+ it('should expand appearance section on click', () => {
106
+ render(
107
+ <DashboardConfigPanel
108
+ open={true}
109
+ onClose={vi.fn()}
110
+ config={defaultConfig}
111
+ onSave={vi.fn()}
112
+ />,
113
+ );
114
+ // Click to expand
115
+ fireEvent.click(screen.getByTestId('section-header-appearance'));
116
+ expect(screen.getByText('Show description')).toBeDefined();
117
+ expect(screen.getByText('Theme')).toBeDefined();
118
+ });
119
+
120
+ it('should call onClose when close button clicked', () => {
121
+ const onClose = vi.fn();
122
+ render(
123
+ <DashboardConfigPanel
124
+ open={true}
125
+ onClose={onClose}
126
+ config={defaultConfig}
127
+ onSave={vi.fn()}
128
+ />,
129
+ );
130
+ fireEvent.click(screen.getByTestId('config-panel-close'));
131
+ expect(onClose).toHaveBeenCalledTimes(1);
132
+ });
133
+
134
+ it('should show save/discard footer after editing a field', () => {
135
+ render(
136
+ <DashboardConfigPanel
137
+ open={true}
138
+ onClose={vi.fn()}
139
+ config={defaultConfig}
140
+ onSave={vi.fn()}
141
+ />,
142
+ );
143
+ // Edit the row height input
144
+ const rowHeightInput = screen.getByTestId('config-field-rowHeight');
145
+ fireEvent.change(rowHeightInput, { target: { value: '200' } });
146
+ expect(screen.getByTestId('config-panel-footer')).toBeDefined();
147
+ });
148
+
149
+ it('should call onSave with updated draft', () => {
150
+ const onSave = vi.fn();
151
+ render(
152
+ <DashboardConfigPanel
153
+ open={true}
154
+ onClose={vi.fn()}
155
+ config={defaultConfig}
156
+ onSave={onSave}
157
+ />,
158
+ );
159
+ // Modify a field
160
+ fireEvent.change(screen.getByTestId('config-field-rowHeight'), {
161
+ target: { value: '200' },
162
+ });
163
+ // Save
164
+ fireEvent.click(screen.getByTestId('config-panel-save'));
165
+ expect(onSave).toHaveBeenCalledTimes(1);
166
+ const savedDraft = onSave.mock.calls[0][0];
167
+ expect(savedDraft.rowHeight).toBe('200');
168
+ });
169
+
170
+ it('should revert changes on discard', () => {
171
+ render(
172
+ <DashboardConfigPanel
173
+ open={true}
174
+ onClose={vi.fn()}
175
+ config={defaultConfig}
176
+ onSave={vi.fn()}
177
+ />,
178
+ );
179
+ const input = screen.getByTestId('config-field-rowHeight') as HTMLInputElement;
180
+ fireEvent.change(input, { target: { value: '999' } });
181
+ expect(input.value).toBe('999');
182
+
183
+ // Discard
184
+ fireEvent.click(screen.getByTestId('config-panel-discard'));
185
+
186
+ // Footer should disappear (no longer dirty)
187
+ expect(screen.queryByTestId('config-panel-footer')).toBeNull();
188
+ });
189
+
190
+ it('should call onFieldChange for live updates', () => {
191
+ const onFieldChange = vi.fn();
192
+ render(
193
+ <DashboardConfigPanel
194
+ open={true}
195
+ onClose={vi.fn()}
196
+ config={defaultConfig}
197
+ onSave={vi.fn()}
198
+ onFieldChange={onFieldChange}
199
+ />,
200
+ );
201
+ fireEvent.change(screen.getByTestId('config-field-rowHeight'), {
202
+ target: { value: '150' },
203
+ });
204
+ expect(onFieldChange).toHaveBeenCalledWith('rowHeight', '150');
205
+ });
206
+ });
@@ -0,0 +1,386 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, screen, fireEvent } from '@testing-library/react';
3
+ import { DashboardRenderer } from '../DashboardRenderer';
4
+ import type { DashboardSchema } from '@object-ui/types';
5
+
6
+ // Mock SchemaRenderer to avoid pulling in the full renderer tree.
7
+ // Forwards className and includes an interactive child to simulate real chart content.
8
+ vi.mock('@object-ui/react', () => ({
9
+ SchemaRenderer: ({ schema, className }: { schema: any; className?: string }) => (
10
+ <div data-testid="schema-renderer" className={className}>
11
+ <button data-testid={`interactive-child-${schema?.type ?? 'unknown'}`}>
12
+ {schema?.type ?? 'unknown'}
13
+ </button>
14
+ </div>
15
+ ),
16
+ }));
17
+
18
+ const DASHBOARD_WITH_WIDGETS: DashboardSchema = {
19
+ type: 'dashboard',
20
+ title: 'Test Dashboard',
21
+ columns: 2,
22
+ widgets: [
23
+ { id: 'w1', title: 'Revenue', type: 'metric' },
24
+ { id: 'w2', title: 'Sales Chart', type: 'bar' },
25
+ { id: 'w3', title: 'Orders Table', type: 'table' },
26
+ ],
27
+ };
28
+
29
+ describe('DashboardRenderer design mode', () => {
30
+ describe('Widget selection', () => {
31
+ it('should render widget test IDs in design mode', () => {
32
+ const onWidgetClick = vi.fn();
33
+ render(
34
+ <DashboardRenderer
35
+ schema={DASHBOARD_WITH_WIDGETS}
36
+ designMode
37
+ selectedWidgetId={null}
38
+ onWidgetClick={onWidgetClick}
39
+ />,
40
+ );
41
+
42
+ expect(screen.getByTestId('dashboard-preview-widget-w1')).toBeInTheDocument();
43
+ expect(screen.getByTestId('dashboard-preview-widget-w2')).toBeInTheDocument();
44
+ expect(screen.getByTestId('dashboard-preview-widget-w3')).toBeInTheDocument();
45
+ });
46
+
47
+ it('should not render widget test IDs when not in design mode', () => {
48
+ render(<DashboardRenderer schema={DASHBOARD_WITH_WIDGETS} />);
49
+
50
+ expect(screen.queryByTestId('dashboard-preview-widget-w1')).not.toBeInTheDocument();
51
+ expect(screen.queryByTestId('dashboard-preview-widget-w2')).not.toBeInTheDocument();
52
+ });
53
+
54
+ it('should call onWidgetClick when a widget is clicked', () => {
55
+ const onWidgetClick = vi.fn();
56
+ render(
57
+ <DashboardRenderer
58
+ schema={DASHBOARD_WITH_WIDGETS}
59
+ designMode
60
+ selectedWidgetId={null}
61
+ onWidgetClick={onWidgetClick}
62
+ />,
63
+ );
64
+
65
+ fireEvent.click(screen.getByTestId('dashboard-preview-widget-w1'));
66
+ expect(onWidgetClick).toHaveBeenCalledWith('w1');
67
+ });
68
+
69
+ it('should set aria-selected on the selected widget', () => {
70
+ render(
71
+ <DashboardRenderer
72
+ schema={DASHBOARD_WITH_WIDGETS}
73
+ designMode
74
+ selectedWidgetId="w2"
75
+ onWidgetClick={vi.fn()}
76
+ />,
77
+ );
78
+
79
+ expect(screen.getByTestId('dashboard-preview-widget-w2')).toHaveAttribute('aria-selected', 'true');
80
+ expect(screen.getByTestId('dashboard-preview-widget-w1')).toHaveAttribute('aria-selected', 'false');
81
+ });
82
+
83
+ it('should apply selection styling classes on selected widget', () => {
84
+ const { container } = render(
85
+ <DashboardRenderer
86
+ schema={DASHBOARD_WITH_WIDGETS}
87
+ designMode
88
+ selectedWidgetId="w1"
89
+ onWidgetClick={vi.fn()}
90
+ />,
91
+ );
92
+
93
+ const selectedWidget = screen.getByTestId('dashboard-preview-widget-w1');
94
+ expect(selectedWidget.className).toContain('ring-2');
95
+ expect(selectedWidget.className).toContain('ring-primary');
96
+ });
97
+
98
+ it('should have role="button" on widgets in design mode', () => {
99
+ render(
100
+ <DashboardRenderer
101
+ schema={DASHBOARD_WITH_WIDGETS}
102
+ designMode
103
+ selectedWidgetId={null}
104
+ onWidgetClick={vi.fn()}
105
+ />,
106
+ );
107
+
108
+ expect(screen.getByTestId('dashboard-preview-widget-w1')).toHaveAttribute('role', 'button');
109
+ });
110
+
111
+ it('should have tabIndex=0 for keyboard accessibility in design mode', () => {
112
+ render(
113
+ <DashboardRenderer
114
+ schema={DASHBOARD_WITH_WIDGETS}
115
+ designMode
116
+ selectedWidgetId={null}
117
+ onWidgetClick={vi.fn()}
118
+ />,
119
+ );
120
+
121
+ expect(screen.getByTestId('dashboard-preview-widget-w1')).toHaveAttribute('tabindex', '0');
122
+ });
123
+
124
+ it('should have aria-label on widgets in design mode', () => {
125
+ render(
126
+ <DashboardRenderer
127
+ schema={DASHBOARD_WITH_WIDGETS}
128
+ designMode
129
+ selectedWidgetId={null}
130
+ onWidgetClick={vi.fn()}
131
+ />,
132
+ );
133
+
134
+ expect(screen.getByTestId('dashboard-preview-widget-w1')).toHaveAttribute(
135
+ 'aria-label',
136
+ 'Widget: Revenue',
137
+ );
138
+ });
139
+ });
140
+
141
+ describe('Click deselection', () => {
142
+ it('should call onWidgetClick(null) when clicking background', () => {
143
+ const onWidgetClick = vi.fn();
144
+ const { container } = render(
145
+ <DashboardRenderer
146
+ schema={DASHBOARD_WITH_WIDGETS}
147
+ designMode
148
+ selectedWidgetId="w1"
149
+ onWidgetClick={onWidgetClick}
150
+ />,
151
+ );
152
+
153
+ // Click on the outer grid container (background)
154
+ const gridContainer = container.firstElementChild as HTMLElement;
155
+ fireEvent.click(gridContainer);
156
+ expect(onWidgetClick).toHaveBeenCalledWith(null);
157
+ });
158
+ });
159
+
160
+ describe('Keyboard navigation', () => {
161
+ it('should select next widget with ArrowRight', () => {
162
+ const onWidgetClick = vi.fn();
163
+ render(
164
+ <DashboardRenderer
165
+ schema={DASHBOARD_WITH_WIDGETS}
166
+ designMode
167
+ selectedWidgetId="w1"
168
+ onWidgetClick={onWidgetClick}
169
+ />,
170
+ );
171
+
172
+ fireEvent.keyDown(screen.getByTestId('dashboard-preview-widget-w1'), { key: 'ArrowRight' });
173
+ expect(onWidgetClick).toHaveBeenCalledWith('w2');
174
+ });
175
+
176
+ it('should select previous widget with ArrowLeft', () => {
177
+ const onWidgetClick = vi.fn();
178
+ render(
179
+ <DashboardRenderer
180
+ schema={DASHBOARD_WITH_WIDGETS}
181
+ designMode
182
+ selectedWidgetId="w2"
183
+ onWidgetClick={onWidgetClick}
184
+ />,
185
+ );
186
+
187
+ fireEvent.keyDown(screen.getByTestId('dashboard-preview-widget-w2'), { key: 'ArrowLeft' });
188
+ expect(onWidgetClick).toHaveBeenCalledWith('w1');
189
+ });
190
+
191
+ it('should deselect with Escape', () => {
192
+ const onWidgetClick = vi.fn();
193
+ render(
194
+ <DashboardRenderer
195
+ schema={DASHBOARD_WITH_WIDGETS}
196
+ designMode
197
+ selectedWidgetId="w1"
198
+ onWidgetClick={onWidgetClick}
199
+ />,
200
+ );
201
+
202
+ fireEvent.keyDown(screen.getByTestId('dashboard-preview-widget-w1'), { key: 'Escape' });
203
+ expect(onWidgetClick).toHaveBeenCalledWith(null);
204
+ });
205
+
206
+ it('should select with Enter key', () => {
207
+ const onWidgetClick = vi.fn();
208
+ render(
209
+ <DashboardRenderer
210
+ schema={DASHBOARD_WITH_WIDGETS}
211
+ designMode
212
+ selectedWidgetId={null}
213
+ onWidgetClick={onWidgetClick}
214
+ />,
215
+ );
216
+
217
+ fireEvent.keyDown(screen.getByTestId('dashboard-preview-widget-w2'), { key: 'Enter' });
218
+ expect(onWidgetClick).toHaveBeenCalledWith('w2');
219
+ });
220
+
221
+ it('should select with Space key', () => {
222
+ const onWidgetClick = vi.fn();
223
+ render(
224
+ <DashboardRenderer
225
+ schema={DASHBOARD_WITH_WIDGETS}
226
+ designMode
227
+ selectedWidgetId={null}
228
+ onWidgetClick={onWidgetClick}
229
+ />,
230
+ );
231
+
232
+ fireEvent.keyDown(screen.getByTestId('dashboard-preview-widget-w1'), { key: ' ' });
233
+ expect(onWidgetClick).toHaveBeenCalledWith('w1');
234
+ });
235
+ });
236
+
237
+ describe('Content pointer-events in design mode', () => {
238
+ it('should apply pointer-events-none to widget content in design mode', () => {
239
+ render(
240
+ <DashboardRenderer
241
+ schema={DASHBOARD_WITH_WIDGETS}
242
+ designMode
243
+ selectedWidgetId={null}
244
+ onWidgetClick={vi.fn()}
245
+ />,
246
+ );
247
+
248
+ // Card widget (bar chart) — content wrapper should have pointer-events-none
249
+ const barWidget = screen.getByTestId('dashboard-preview-widget-w2');
250
+ const contentWrapper = barWidget.querySelector('.pointer-events-none');
251
+ expect(contentWrapper).toBeInTheDocument();
252
+ });
253
+
254
+ it('should apply pointer-events-none to self-contained (metric) widget content', () => {
255
+ render(
256
+ <DashboardRenderer
257
+ schema={DASHBOARD_WITH_WIDGETS}
258
+ designMode
259
+ selectedWidgetId={null}
260
+ onWidgetClick={vi.fn()}
261
+ />,
262
+ );
263
+
264
+ // Metric widget — SchemaRenderer receives pointer-events-none className
265
+ const metricWidget = screen.getByTestId('dashboard-preview-widget-w1');
266
+ const contentWrapper = metricWidget.querySelector('.pointer-events-none');
267
+ expect(contentWrapper).toBeInTheDocument();
268
+ });
269
+
270
+ it('should NOT apply pointer-events-none when not in design mode', () => {
271
+ const { container } = render(<DashboardRenderer schema={DASHBOARD_WITH_WIDGETS} />);
272
+
273
+ // No design-mode content wrapper should have pointer-events-none class
274
+ // (data-table ghost rows have pointer-events-none internally, which is unrelated to design mode)
275
+ expect(container.querySelector('.pointer-events-none:not([data-testid="ghost-row"])')).not.toBeInTheDocument();
276
+ });
277
+
278
+ it('should still call onWidgetClick when clicking on Card-based widget content area', () => {
279
+ const onWidgetClick = vi.fn();
280
+ render(
281
+ <DashboardRenderer
282
+ schema={DASHBOARD_WITH_WIDGETS}
283
+ designMode
284
+ selectedWidgetId={null}
285
+ onWidgetClick={onWidgetClick}
286
+ />,
287
+ );
288
+
289
+ // Click on the bar chart widget (Card-based)
290
+ fireEvent.click(screen.getByTestId('dashboard-preview-widget-w2'));
291
+ expect(onWidgetClick).toHaveBeenCalledWith('w2');
292
+ });
293
+
294
+ it('should still call onWidgetClick when clicking on table widget', () => {
295
+ const onWidgetClick = vi.fn();
296
+ render(
297
+ <DashboardRenderer
298
+ schema={DASHBOARD_WITH_WIDGETS}
299
+ designMode
300
+ selectedWidgetId={null}
301
+ onWidgetClick={onWidgetClick}
302
+ />,
303
+ );
304
+
305
+ // Click on the table widget (Card-based)
306
+ fireEvent.click(screen.getByTestId('dashboard-preview-widget-w3'));
307
+ expect(onWidgetClick).toHaveBeenCalledWith('w3');
308
+ });
309
+ });
310
+
311
+ describe('Click-capture overlay in design mode', () => {
312
+ it('should render a click-capture overlay on Card-based widgets in design mode', () => {
313
+ render(
314
+ <DashboardRenderer
315
+ schema={DASHBOARD_WITH_WIDGETS}
316
+ designMode
317
+ selectedWidgetId={null}
318
+ onWidgetClick={vi.fn()}
319
+ />,
320
+ );
321
+
322
+ // Card widget (bar chart) — should have an absolute overlay div
323
+ const barWidget = screen.getByTestId('dashboard-preview-widget-w2');
324
+ const overlay = barWidget.querySelector('[data-testid="widget-click-overlay"]');
325
+ expect(overlay).toBeInTheDocument();
326
+ expect(overlay?.className).toContain('absolute');
327
+ expect(overlay?.className).toContain('inset-0');
328
+ expect(overlay?.className).toContain('z-10');
329
+ });
330
+
331
+ it('should render a click-capture overlay on self-contained widgets in design mode', () => {
332
+ render(
333
+ <DashboardRenderer
334
+ schema={DASHBOARD_WITH_WIDGETS}
335
+ designMode
336
+ selectedWidgetId={null}
337
+ onWidgetClick={vi.fn()}
338
+ />,
339
+ );
340
+
341
+ // Metric widget — should have an absolute overlay div
342
+ const metricWidget = screen.getByTestId('dashboard-preview-widget-w1');
343
+ const overlay = metricWidget.querySelector('[data-testid="widget-click-overlay"]');
344
+ expect(overlay).toBeInTheDocument();
345
+ expect(overlay?.className).toContain('absolute');
346
+ expect(overlay?.className).toContain('inset-0');
347
+ expect(overlay?.className).toContain('z-10');
348
+ });
349
+
350
+ it('should NOT render overlays when not in design mode', () => {
351
+ const { container } = render(<DashboardRenderer schema={DASHBOARD_WITH_WIDGETS} />);
352
+
353
+ expect(container.querySelector('[data-testid="widget-click-overlay"]')).not.toBeInTheDocument();
354
+ });
355
+
356
+ it('should apply relative positioning to widget container in design mode', () => {
357
+ render(
358
+ <DashboardRenderer
359
+ schema={DASHBOARD_WITH_WIDGETS}
360
+ designMode
361
+ selectedWidgetId={null}
362
+ onWidgetClick={vi.fn()}
363
+ />,
364
+ );
365
+
366
+ // Card widget should have relative for overlay positioning
367
+ const barWidget = screen.getByTestId('dashboard-preview-widget-w2');
368
+ expect(barWidget.className).toContain('relative');
369
+
370
+ // Metric widget should have relative for overlay positioning
371
+ const metricWidget = screen.getByTestId('dashboard-preview-widget-w1');
372
+ expect(metricWidget.className).toContain('relative');
373
+ });
374
+ });
375
+
376
+ describe('Non-design mode behavior', () => {
377
+ it('should not add design mode attributes when designMode is off', () => {
378
+ const { container } = render(<DashboardRenderer schema={DASHBOARD_WITH_WIDGETS} />);
379
+
380
+ // No widget should have data-widget-id
381
+ expect(container.querySelector('[data-widget-id]')).not.toBeInTheDocument();
382
+ // No widget should have role=button
383
+ expect(container.querySelector('[role="button"]')).not.toBeInTheDocument();
384
+ });
385
+ });
386
+ });
@@ -0,0 +1,114 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, screen } from '@testing-library/react';
3
+ import { DashboardRenderer } from '../DashboardRenderer';
4
+ import type { DashboardSchema } from '@object-ui/types';
5
+
6
+ // Mock SchemaRenderer to avoid pulling in the full renderer tree
7
+ vi.mock('@object-ui/react', () => ({
8
+ SchemaRenderer: ({ schema }: { schema: any }) => (
9
+ <div data-testid="schema-renderer">{schema?.type ?? 'unknown'}</div>
10
+ ),
11
+ }));
12
+
13
+ describe('DashboardRenderer header', () => {
14
+ const baseSchema: DashboardSchema = {
15
+ type: 'dashboard',
16
+ name: 'test_dashboard',
17
+ title: 'Sales Dashboard',
18
+ description: 'Monthly overview of sales data',
19
+ widgets: [],
20
+ };
21
+
22
+ it('should render title when showTitle is not false', () => {
23
+ const schema: DashboardSchema = {
24
+ ...baseSchema,
25
+ header: { showTitle: true },
26
+ };
27
+ render(<DashboardRenderer schema={schema} />);
28
+
29
+ expect(screen.getByText('Sales Dashboard')).toBeInTheDocument();
30
+ });
31
+
32
+ it('should not render title when showTitle is false', () => {
33
+ const schema: DashboardSchema = {
34
+ ...baseSchema,
35
+ header: { showTitle: false },
36
+ };
37
+ render(<DashboardRenderer schema={schema} />);
38
+
39
+ expect(screen.queryByText('Sales Dashboard')).not.toBeInTheDocument();
40
+ });
41
+
42
+ it('should render description when showDescription is not false', () => {
43
+ const schema: DashboardSchema = {
44
+ ...baseSchema,
45
+ header: { showDescription: true },
46
+ };
47
+ render(<DashboardRenderer schema={schema} />);
48
+
49
+ expect(screen.getByText('Monthly overview of sales data')).toBeInTheDocument();
50
+ });
51
+
52
+ it('should not render description when showDescription is false', () => {
53
+ const schema: DashboardSchema = {
54
+ ...baseSchema,
55
+ header: { showDescription: false },
56
+ };
57
+ render(<DashboardRenderer schema={schema} />);
58
+
59
+ expect(screen.queryByText('Monthly overview of sales data')).not.toBeInTheDocument();
60
+ });
61
+
62
+ it('should render action buttons', () => {
63
+ const schema: DashboardSchema = {
64
+ ...baseSchema,
65
+ header: {
66
+ actions: [
67
+ { label: 'Export', action: 'export' },
68
+ { label: 'Share', action: 'share' },
69
+ ],
70
+ },
71
+ };
72
+ render(<DashboardRenderer schema={schema} />);
73
+
74
+ expect(screen.getByText('Export')).toBeInTheDocument();
75
+ expect(screen.getByText('Share')).toBeInTheDocument();
76
+ });
77
+
78
+ it('should render recordCount badge when prop provided', () => {
79
+ const schema: DashboardSchema = {
80
+ ...baseSchema,
81
+ header: {},
82
+ };
83
+ const onRefresh = vi.fn();
84
+ render(<DashboardRenderer schema={schema} onRefresh={onRefresh} recordCount={1234} />);
85
+
86
+ expect(screen.getByText('1,234 records')).toBeInTheDocument();
87
+ });
88
+
89
+ it('should set data-user-actions attribute when userActions provided', () => {
90
+ const schema: DashboardSchema = {
91
+ ...baseSchema,
92
+ header: {},
93
+ };
94
+ const userActions = { sort: true, search: false, filter: true };
95
+ const { container } = render(
96
+ <DashboardRenderer schema={schema} userActions={userActions} />
97
+ );
98
+
99
+ const root = container.firstElementChild as HTMLElement;
100
+ expect(root.getAttribute('data-user-actions')).toBe(JSON.stringify(userActions));
101
+ });
102
+
103
+ it('should not render header section when schema.header is undefined', () => {
104
+ const schema: DashboardSchema = {
105
+ ...baseSchema,
106
+ header: undefined,
107
+ };
108
+ render(<DashboardRenderer schema={schema} />);
109
+
110
+ // Neither title nor description should appear in a header context
111
+ // The title text itself should not be rendered since there is no header
112
+ expect(screen.queryByText('Sales Dashboard')).not.toBeInTheDocument();
113
+ });
114
+ });