@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
|
@@ -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
|
+
});
|