@object-ui/plugin-dashboard 3.0.3 → 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 (54) hide show
  1. package/.turbo/turbo-build.log +40 -7
  2. package/dist/index.js +3848 -2635
  3. package/dist/index.umd.cjs +5 -5
  4. package/dist/src/DashboardConfigPanel.d.ts +28 -0
  5. package/dist/src/DashboardConfigPanel.d.ts.map +1 -0
  6. package/dist/src/DashboardConfigPanel.stories.d.ts +14 -0
  7. package/dist/src/DashboardConfigPanel.stories.d.ts.map +1 -0
  8. package/dist/src/DashboardGridLayout.d.ts.map +1 -1
  9. package/dist/src/DashboardRenderer.d.ts +14 -0
  10. package/dist/src/DashboardRenderer.d.ts.map +1 -1
  11. package/dist/src/DashboardWithConfig.d.ts +32 -0
  12. package/dist/src/DashboardWithConfig.d.ts.map +1 -0
  13. package/dist/src/MetricCard.d.ts +8 -2
  14. package/dist/src/MetricCard.d.ts.map +1 -1
  15. package/dist/src/MetricWidget.d.ts +12 -3
  16. package/dist/src/MetricWidget.d.ts.map +1 -1
  17. package/dist/src/ObjectDataTable.d.ts +39 -0
  18. package/dist/src/ObjectDataTable.d.ts.map +1 -0
  19. package/dist/src/ObjectPivotTable.d.ts +29 -0
  20. package/dist/src/ObjectPivotTable.d.ts.map +1 -0
  21. package/dist/src/PivotTable.d.ts +14 -0
  22. package/dist/src/PivotTable.d.ts.map +1 -0
  23. package/dist/src/WidgetConfigPanel.d.ts +43 -0
  24. package/dist/src/WidgetConfigPanel.d.ts.map +1 -0
  25. package/dist/src/index.d.ts +13 -1
  26. package/dist/src/index.d.ts.map +1 -1
  27. package/dist/src/utils.d.ts +14 -0
  28. package/dist/src/utils.d.ts.map +1 -0
  29. package/package.json +7 -7
  30. package/src/DashboardConfigPanel.stories.tsx +164 -0
  31. package/src/DashboardConfigPanel.tsx +158 -0
  32. package/src/DashboardGridLayout.tsx +101 -3
  33. package/src/DashboardRenderer.tsx +269 -28
  34. package/src/DashboardWithConfig.tsx +211 -0
  35. package/src/MetricCard.tsx +11 -4
  36. package/src/MetricWidget.tsx +18 -11
  37. package/src/ObjectDataTable.tsx +191 -0
  38. package/src/ObjectPivotTable.tsx +160 -0
  39. package/src/PivotTable.tsx +262 -0
  40. package/src/WidgetConfigPanel.tsx +540 -0
  41. package/src/__tests__/DashboardConfigPanel.test.tsx +206 -0
  42. package/src/__tests__/DashboardRenderer.designMode.test.tsx +386 -0
  43. package/src/__tests__/DashboardRenderer.header.test.tsx +114 -0
  44. package/src/__tests__/DashboardRenderer.mobile.test.tsx +214 -0
  45. package/src/__tests__/DashboardRenderer.widgetData.test.tsx +1022 -0
  46. package/src/__tests__/DashboardWithConfig.test.tsx +276 -0
  47. package/src/__tests__/MetricCard.test.tsx +23 -0
  48. package/src/__tests__/ObjectDataTable.test.tsx +122 -0
  49. package/src/__tests__/ObjectPivotTable.test.tsx +192 -0
  50. package/src/__tests__/PivotTable.test.tsx +162 -0
  51. package/src/__tests__/WidgetConfigPanel.test.tsx +492 -0
  52. package/src/__tests__/ensureWidgetIds.test.tsx +103 -0
  53. package/src/index.tsx +107 -1
  54. package/src/utils.ts +17 -0
@@ -0,0 +1,162 @@
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 } from 'vitest';
10
+ import { render, screen } from '@testing-library/react';
11
+ import '@testing-library/jest-dom';
12
+ import { PivotTable } from '../PivotTable';
13
+ import type { PivotTableSchema } from '@object-ui/types';
14
+
15
+ const SAMPLE_DATA = [
16
+ { owner: 'Alice', stage: 'Discovery', amount: 1000 },
17
+ { owner: 'Alice', stage: 'Proposal', amount: 2000 },
18
+ { owner: 'Alice', stage: 'Discovery', amount: 500 },
19
+ { owner: 'Bob', stage: 'Discovery', amount: 3000 },
20
+ { owner: 'Bob', stage: 'Closed', amount: 5000 },
21
+ { owner: 'Carol', stage: 'Proposal', amount: 4000 },
22
+ ];
23
+
24
+ function makeSchema(overrides?: Partial<PivotTableSchema>): PivotTableSchema {
25
+ return {
26
+ type: 'pivot',
27
+ rowField: 'owner',
28
+ columnField: 'stage',
29
+ valueField: 'amount',
30
+ data: SAMPLE_DATA,
31
+ ...overrides,
32
+ };
33
+ }
34
+
35
+ describe('PivotTable', () => {
36
+ it('should render row and column headers', () => {
37
+ render(<PivotTable schema={makeSchema()} />);
38
+
39
+ // Row headers
40
+ expect(screen.getByText('Alice')).toBeInTheDocument();
41
+ expect(screen.getByText('Bob')).toBeInTheDocument();
42
+ expect(screen.getByText('Carol')).toBeInTheDocument();
43
+
44
+ // Column headers
45
+ expect(screen.getByText('Discovery')).toBeInTheDocument();
46
+ expect(screen.getByText('Proposal')).toBeInTheDocument();
47
+ expect(screen.getByText('Closed')).toBeInTheDocument();
48
+ });
49
+
50
+ it('should aggregate values with sum by default', () => {
51
+ render(<PivotTable schema={makeSchema()} />);
52
+
53
+ // Alice + Discovery = 1000 + 500 = 1500
54
+ expect(screen.getByText('1500')).toBeInTheDocument();
55
+ // Alice + Proposal = 2000
56
+ expect(screen.getByText('2000')).toBeInTheDocument();
57
+ // Bob + Discovery = 3000
58
+ expect(screen.getByText('3000')).toBeInTheDocument();
59
+ // Bob + Closed = 5000
60
+ expect(screen.getByText('5000')).toBeInTheDocument();
61
+ // Carol + Proposal = 4000
62
+ expect(screen.getByText('4000')).toBeInTheDocument();
63
+ });
64
+
65
+ it('should support count aggregation', () => {
66
+ render(<PivotTable schema={makeSchema({ aggregation: 'count' })} />);
67
+
68
+ // Alice + Discovery = 2 items
69
+ expect(screen.getByText('2')).toBeInTheDocument();
70
+ });
71
+
72
+ it('should support avg aggregation', () => {
73
+ render(<PivotTable schema={makeSchema({ aggregation: 'avg' })} />);
74
+
75
+ // Alice + Discovery = avg(1000, 500) = 750
76
+ expect(screen.getByText('750')).toBeInTheDocument();
77
+ });
78
+
79
+ it('should render title when provided', () => {
80
+ render(<PivotTable schema={makeSchema({ title: 'Revenue Pivot' })} />);
81
+
82
+ expect(screen.getByText('Revenue Pivot')).toBeInTheDocument();
83
+ });
84
+
85
+ it('should show row totals when showRowTotals is true', () => {
86
+ render(<PivotTable schema={makeSchema({ showRowTotals: true })} />);
87
+
88
+ // Header "Total" column
89
+ const totalHeaders = screen.getAllByText('Total');
90
+ expect(totalHeaders.length).toBeGreaterThanOrEqual(1);
91
+
92
+ // Alice total = 1000 + 2000 + 500 = 3500
93
+ expect(screen.getByText('3500')).toBeInTheDocument();
94
+ // Bob total = 3000 + 5000 = 8000
95
+ expect(screen.getByText('8000')).toBeInTheDocument();
96
+ });
97
+
98
+ it('should show column totals when showColumnTotals is true', () => {
99
+ render(<PivotTable schema={makeSchema({ showColumnTotals: true })} />);
100
+
101
+ // Footer row with "Total" label
102
+ const totalCells = screen.getAllByText('Total');
103
+ expect(totalCells.length).toBeGreaterThanOrEqual(1);
104
+
105
+ // Discovery total = 1000 + 500 + 3000 = 4500
106
+ expect(screen.getByText('4500')).toBeInTheDocument();
107
+ // Proposal total = 2000 + 4000 = 6000
108
+ expect(screen.getByText('6000')).toBeInTheDocument();
109
+ });
110
+
111
+ it('should show grand total when both row and column totals are enabled', () => {
112
+ render(<PivotTable schema={makeSchema({ showRowTotals: true, showColumnTotals: true })} />);
113
+
114
+ // Grand total = 1000 + 2000 + 500 + 3000 + 5000 + 4000 = 15500
115
+ expect(screen.getByText('15500')).toBeInTheDocument();
116
+ });
117
+
118
+ it('should apply format string', () => {
119
+ render(<PivotTable schema={makeSchema({ format: '$,.0f' })} />);
120
+
121
+ // Alice + Discovery = $1,500
122
+ expect(screen.getByText('$1,500')).toBeInTheDocument();
123
+ // Bob + Closed = $5,000
124
+ expect(screen.getByText('$5,000')).toBeInTheDocument();
125
+ });
126
+
127
+ it('should handle empty data gracefully', () => {
128
+ const { container } = render(<PivotTable schema={makeSchema({ data: [] })} />);
129
+
130
+ // Should render a friendly empty state instead of an empty table
131
+ const emptyState = container.querySelector('[data-testid="pivot-empty-state"]');
132
+ expect(emptyState).toBeInTheDocument();
133
+ expect(screen.getByText('No data available')).toBeInTheDocument();
134
+ });
135
+
136
+ it('should handle missing values in data as 0', () => {
137
+ const data = [
138
+ { owner: 'Alice', stage: 'A', amount: 10 },
139
+ { owner: 'Bob', stage: 'B', amount: 20 },
140
+ ];
141
+ render(<PivotTable schema={makeSchema({ data })} />);
142
+
143
+ // Alice × B = 0, Bob × A = 0 should appear
144
+ const zeroCells = screen.getAllByText('0');
145
+ expect(zeroCells.length).toBe(2);
146
+ });
147
+
148
+ it('should show title in empty state', () => {
149
+ render(<PivotTable schema={makeSchema({ data: [], title: 'Empty Pivot' })} />);
150
+
151
+ expect(screen.getByText('Empty Pivot')).toBeInTheDocument();
152
+ expect(screen.getByText('No data available')).toBeInTheDocument();
153
+ });
154
+
155
+ it('should treat non-array data (e.g. provider config) as empty', () => {
156
+ const schema = makeSchema({ data: { provider: 'object', object: 'sales' } as any });
157
+ const { container } = render(<PivotTable schema={schema} />);
158
+
159
+ const emptyState = container.querySelector('[data-testid="pivot-empty-state"]');
160
+ expect(emptyState).toBeInTheDocument();
161
+ });
162
+ });
@@ -0,0 +1,492 @@
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 { WidgetConfigPanel } from '../WidgetConfigPanel';
12
+
13
+ const defaultWidgetConfig = {
14
+ title: 'Revenue Chart',
15
+ description: 'Monthly revenue breakdown',
16
+ type: 'bar',
17
+ object: 'orders',
18
+ categoryField: 'month',
19
+ valueField: 'amount',
20
+ aggregate: 'sum',
21
+ colorVariant: 'blue',
22
+ actionUrl: '',
23
+ layoutW: 2,
24
+ layoutH: 1,
25
+ };
26
+
27
+ describe('WidgetConfigPanel', () => {
28
+ it('should render nothing when closed', () => {
29
+ const { container } = render(
30
+ <WidgetConfigPanel
31
+ open={false}
32
+ onClose={vi.fn()}
33
+ config={defaultWidgetConfig}
34
+ onSave={vi.fn()}
35
+ />,
36
+ );
37
+ expect(container.innerHTML).toBe('');
38
+ });
39
+
40
+ it('should render panel when open', () => {
41
+ render(
42
+ <WidgetConfigPanel
43
+ open={true}
44
+ onClose={vi.fn()}
45
+ config={defaultWidgetConfig}
46
+ onSave={vi.fn()}
47
+ />,
48
+ );
49
+ expect(screen.getByTestId('config-panel')).toBeDefined();
50
+ });
51
+
52
+ it('should display widget breadcrumb', () => {
53
+ render(
54
+ <WidgetConfigPanel
55
+ open={true}
56
+ onClose={vi.fn()}
57
+ config={defaultWidgetConfig}
58
+ onSave={vi.fn()}
59
+ />,
60
+ );
61
+ expect(screen.getByText('Dashboard')).toBeDefined();
62
+ // Breadcrumb adapts to widget type — 'bar' type shows "Chart"
63
+ expect(screen.getByText('Chart')).toBeDefined();
64
+ });
65
+
66
+ it('should display general section with title, description, and type fields', () => {
67
+ render(
68
+ <WidgetConfigPanel
69
+ open={true}
70
+ onClose={vi.fn()}
71
+ config={defaultWidgetConfig}
72
+ onSave={vi.fn()}
73
+ />,
74
+ );
75
+ expect(screen.getByText('General')).toBeDefined();
76
+ expect(screen.getByText('Title')).toBeDefined();
77
+ expect(screen.getByText('Description')).toBeDefined();
78
+ expect(screen.getByText('Widget type')).toBeDefined();
79
+ });
80
+
81
+ it('should display data binding section', () => {
82
+ render(
83
+ <WidgetConfigPanel
84
+ open={true}
85
+ onClose={vi.fn()}
86
+ config={defaultWidgetConfig}
87
+ onSave={vi.fn()}
88
+ />,
89
+ );
90
+ expect(screen.getByText('Data Binding')).toBeDefined();
91
+ expect(screen.getByText('Data source')).toBeDefined();
92
+ expect(screen.getByText('Category field')).toBeDefined();
93
+ expect(screen.getByText('Value field')).toBeDefined();
94
+ expect(screen.getByText('Aggregation')).toBeDefined();
95
+ });
96
+
97
+ it('should display layout section', () => {
98
+ render(
99
+ <WidgetConfigPanel
100
+ open={true}
101
+ onClose={vi.fn()}
102
+ config={defaultWidgetConfig}
103
+ onSave={vi.fn()}
104
+ />,
105
+ );
106
+ expect(screen.getByText('Layout')).toBeDefined();
107
+ expect(screen.getByText('Width (columns)')).toBeDefined();
108
+ expect(screen.getByText('Height (rows)')).toBeDefined();
109
+ });
110
+
111
+ it('should have appearance section collapsed by default', () => {
112
+ render(
113
+ <WidgetConfigPanel
114
+ open={true}
115
+ onClose={vi.fn()}
116
+ config={defaultWidgetConfig}
117
+ onSave={vi.fn()}
118
+ />,
119
+ );
120
+ expect(screen.getByText('Appearance')).toBeDefined();
121
+ // Fields inside collapsed section should not be visible
122
+ expect(screen.queryByText('Color variant')).toBeNull();
123
+ expect(screen.queryByText('Action URL')).toBeNull();
124
+ });
125
+
126
+ it('should expand appearance section on click', () => {
127
+ render(
128
+ <WidgetConfigPanel
129
+ open={true}
130
+ onClose={vi.fn()}
131
+ config={defaultWidgetConfig}
132
+ onSave={vi.fn()}
133
+ />,
134
+ );
135
+ fireEvent.click(screen.getByTestId('section-header-appearance'));
136
+ expect(screen.getByText('Color variant')).toBeDefined();
137
+ expect(screen.getByText('Action URL')).toBeDefined();
138
+ });
139
+
140
+ it('should call onClose when close button clicked', () => {
141
+ const onClose = vi.fn();
142
+ render(
143
+ <WidgetConfigPanel
144
+ open={true}
145
+ onClose={onClose}
146
+ config={defaultWidgetConfig}
147
+ onSave={vi.fn()}
148
+ />,
149
+ );
150
+ fireEvent.click(screen.getByTestId('config-panel-close'));
151
+ expect(onClose).toHaveBeenCalledTimes(1);
152
+ });
153
+
154
+ it('should show save/discard footer after editing a field', () => {
155
+ render(
156
+ <WidgetConfigPanel
157
+ open={true}
158
+ onClose={vi.fn()}
159
+ config={defaultWidgetConfig}
160
+ onSave={vi.fn()}
161
+ />,
162
+ );
163
+ const titleInput = screen.getByTestId('config-field-title');
164
+ fireEvent.change(titleInput, { target: { value: 'New Title' } });
165
+ expect(screen.getByTestId('config-panel-footer')).toBeDefined();
166
+ });
167
+
168
+ it('should call onSave with updated draft', () => {
169
+ const onSave = vi.fn();
170
+ render(
171
+ <WidgetConfigPanel
172
+ open={true}
173
+ onClose={vi.fn()}
174
+ config={defaultWidgetConfig}
175
+ onSave={onSave}
176
+ />,
177
+ );
178
+ fireEvent.change(screen.getByTestId('config-field-title'), {
179
+ target: { value: 'Updated Chart' },
180
+ });
181
+ fireEvent.click(screen.getByTestId('config-panel-save'));
182
+ expect(onSave).toHaveBeenCalledTimes(1);
183
+ const savedDraft = onSave.mock.calls[0][0];
184
+ expect(savedDraft.title).toBe('Updated Chart');
185
+ });
186
+
187
+ it('should revert changes on discard', () => {
188
+ render(
189
+ <WidgetConfigPanel
190
+ open={true}
191
+ onClose={vi.fn()}
192
+ config={defaultWidgetConfig}
193
+ onSave={vi.fn()}
194
+ />,
195
+ );
196
+ const input = screen.getByTestId('config-field-title') as HTMLInputElement;
197
+ fireEvent.change(input, { target: { value: 'Temp Title' } });
198
+ expect(input.value).toBe('Temp Title');
199
+
200
+ fireEvent.click(screen.getByTestId('config-panel-discard'));
201
+ expect(screen.queryByTestId('config-panel-footer')).toBeNull();
202
+ });
203
+
204
+ it('should call onFieldChange for live updates', () => {
205
+ const onFieldChange = vi.fn();
206
+ render(
207
+ <WidgetConfigPanel
208
+ open={true}
209
+ onClose={vi.fn()}
210
+ config={defaultWidgetConfig}
211
+ onSave={vi.fn()}
212
+ onFieldChange={onFieldChange}
213
+ />,
214
+ );
215
+ fireEvent.change(screen.getByTestId('config-field-title'), {
216
+ target: { value: 'Live Update' },
217
+ });
218
+ expect(onFieldChange).toHaveBeenCalledWith('title', 'Live Update');
219
+ });
220
+
221
+ it('should populate fields with provided config values', () => {
222
+ render(
223
+ <WidgetConfigPanel
224
+ open={true}
225
+ onClose={vi.fn()}
226
+ config={defaultWidgetConfig}
227
+ onSave={vi.fn()}
228
+ />,
229
+ );
230
+ const titleInput = screen.getByTestId('config-field-title') as HTMLInputElement;
231
+ expect(titleInput.value).toBe('Revenue Chart');
232
+ const descInput = screen.getByTestId('config-field-description') as HTMLInputElement;
233
+ expect(descInput.value).toBe('Monthly revenue breakdown');
234
+ });
235
+
236
+ // ---- Searchable combobox tests (availableObjects / availableFields) ------
237
+
238
+ describe('with availableObjects', () => {
239
+ const objects = [
240
+ { value: 'accounts', label: 'Accounts' },
241
+ { value: 'contacts', label: 'Contacts' },
242
+ { value: 'orders', label: 'Orders' },
243
+ ];
244
+
245
+ it('should render data source as a combobox when availableObjects provided', () => {
246
+ render(
247
+ <WidgetConfigPanel
248
+ open={true}
249
+ onClose={vi.fn()}
250
+ config={defaultWidgetConfig}
251
+ onSave={vi.fn()}
252
+ availableObjects={objects}
253
+ />,
254
+ );
255
+ // The data source field should be a combobox (not a text input)
256
+ const wrapper = screen.getByTestId('config-field-object');
257
+ const comboboxBtn = wrapper.querySelector('[role="combobox"]');
258
+ expect(comboboxBtn).toBeTruthy();
259
+ });
260
+
261
+ it('should render data source as an input when no availableObjects', () => {
262
+ render(
263
+ <WidgetConfigPanel
264
+ open={true}
265
+ onClose={vi.fn()}
266
+ config={defaultWidgetConfig}
267
+ onSave={vi.fn()}
268
+ />,
269
+ );
270
+ const objectField = screen.getByTestId('config-field-object');
271
+ expect(objectField.tagName).toBe('INPUT');
272
+ });
273
+
274
+ it('should render category/value fields as comboboxes when availableObjects provided', () => {
275
+ const fields = [
276
+ { value: 'name', label: 'Name' },
277
+ { value: 'status', label: 'Status' },
278
+ ];
279
+ render(
280
+ <WidgetConfigPanel
281
+ open={true}
282
+ onClose={vi.fn()}
283
+ config={defaultWidgetConfig}
284
+ onSave={vi.fn()}
285
+ availableObjects={objects}
286
+ availableFields={fields}
287
+ />,
288
+ );
289
+ const catWrapper = screen.getByTestId('config-field-categoryField');
290
+ expect(catWrapper.querySelector('[role="combobox"]')).toBeTruthy();
291
+ const valWrapper = screen.getByTestId('config-field-valueField');
292
+ expect(valWrapper.querySelector('[role="combobox"]')).toBeTruthy();
293
+ });
294
+
295
+ it('should disable field comboboxes when object is not selected', () => {
296
+ const configWithNoObject = { ...defaultWidgetConfig, object: '' };
297
+ render(
298
+ <WidgetConfigPanel
299
+ open={true}
300
+ onClose={vi.fn()}
301
+ config={configWithNoObject}
302
+ onSave={vi.fn()}
303
+ availableObjects={objects}
304
+ />,
305
+ );
306
+ const catWrapper = screen.getByTestId('config-field-categoryField');
307
+ const catBtn = catWrapper.querySelector('[role="combobox"]');
308
+ expect(catBtn).toHaveAttribute('disabled');
309
+ const valWrapper = screen.getByTestId('config-field-valueField');
310
+ const valBtn = valWrapper.querySelector('[role="combobox"]');
311
+ expect(valBtn).toHaveAttribute('disabled');
312
+ });
313
+
314
+ it('should not disable field comboboxes when object is selected', () => {
315
+ const fields = [
316
+ { value: 'name', label: 'Name' },
317
+ { value: 'amount', label: 'Amount' },
318
+ ];
319
+ render(
320
+ <WidgetConfigPanel
321
+ open={true}
322
+ onClose={vi.fn()}
323
+ config={defaultWidgetConfig}
324
+ onSave={vi.fn()}
325
+ availableObjects={objects}
326
+ availableFields={fields}
327
+ />,
328
+ );
329
+ const catWrapper = screen.getByTestId('config-field-categoryField');
330
+ const catBtn = catWrapper.querySelector('[role="combobox"]');
331
+ expect(catBtn).not.toHaveAttribute('disabled');
332
+ const valWrapper = screen.getByTestId('config-field-valueField');
333
+ const valBtn = valWrapper.querySelector('[role="combobox"]');
334
+ expect(valBtn).not.toHaveAttribute('disabled');
335
+ });
336
+ });
337
+
338
+ // ---- Context-aware sections (visibleWhen) ---------------------------------
339
+
340
+ describe('context-aware sections', () => {
341
+ it('should show pivot-specific sections when type is pivot', () => {
342
+ const pivotConfig = { ...defaultWidgetConfig, type: 'pivot' };
343
+ render(
344
+ <WidgetConfigPanel
345
+ open={true}
346
+ onClose={vi.fn()}
347
+ config={pivotConfig}
348
+ onSave={vi.fn()}
349
+ />,
350
+ );
351
+ // Pivot sections should be visible
352
+ expect(screen.getByText('Rows')).toBeDefined();
353
+ expect(screen.getByText('Columns')).toBeDefined();
354
+ expect(screen.getByText('Values')).toBeDefined();
355
+ // Data Binding (chart/metric section) should NOT be visible
356
+ expect(screen.queryByText('Data Binding')).toBeNull();
357
+ });
358
+
359
+ it('should show chart-specific sections when type is bar', () => {
360
+ render(
361
+ <WidgetConfigPanel
362
+ open={true}
363
+ onClose={vi.fn()}
364
+ config={defaultWidgetConfig}
365
+ onSave={vi.fn()}
366
+ />,
367
+ );
368
+ // Chart Axis & Series section should be visible
369
+ expect(screen.getByText('Axis & Series')).toBeDefined();
370
+ // Pivot sections should NOT be visible
371
+ expect(screen.queryByText('Rows')).toBeNull();
372
+ expect(screen.queryByText('Values')).toBeNull();
373
+ });
374
+
375
+ it('should show table-specific sections when type is table', () => {
376
+ const tableConfig = { ...defaultWidgetConfig, type: 'table' };
377
+ render(
378
+ <WidgetConfigPanel
379
+ open={true}
380
+ onClose={vi.fn()}
381
+ config={tableConfig}
382
+ onSave={vi.fn()}
383
+ />,
384
+ );
385
+ // Table Columns section should be visible
386
+ expect(screen.getByText('Columns')).toBeDefined();
387
+ // Chart Axis & Series should NOT be visible
388
+ expect(screen.queryByText('Axis & Series')).toBeNull();
389
+ // Pivot sections should NOT be visible
390
+ expect(screen.queryByText('Rows')).toBeNull();
391
+ });
392
+
393
+ it('should hide chart/pivot/table sections for metric type', () => {
394
+ const metricConfig = { ...defaultWidgetConfig, type: 'metric' };
395
+ render(
396
+ <WidgetConfigPanel
397
+ open={true}
398
+ onClose={vi.fn()}
399
+ config={metricConfig}
400
+ onSave={vi.fn()}
401
+ />,
402
+ );
403
+ expect(screen.queryByText('Axis & Series')).toBeNull();
404
+ expect(screen.queryByText('Rows')).toBeNull();
405
+ expect(screen.queryByText('Values')).toBeNull();
406
+ // Data Binding should still be visible
407
+ expect(screen.getByText('Data Binding')).toBeDefined();
408
+ });
409
+
410
+ it('should show breadcrumb "Pivot table" for pivot type', () => {
411
+ const pivotConfig = { ...defaultWidgetConfig, type: 'pivot' };
412
+ render(
413
+ <WidgetConfigPanel
414
+ open={true}
415
+ onClose={vi.fn()}
416
+ config={pivotConfig}
417
+ onSave={vi.fn()}
418
+ />,
419
+ );
420
+ expect(screen.getByText('Pivot table')).toBeDefined();
421
+ });
422
+
423
+ it('should show breadcrumb "Table" for table type', () => {
424
+ const tableConfig = { ...defaultWidgetConfig, type: 'table' };
425
+ render(
426
+ <WidgetConfigPanel
427
+ open={true}
428
+ onClose={vi.fn()}
429
+ config={tableConfig}
430
+ onSave={vi.fn()}
431
+ />,
432
+ );
433
+ // "Table" appears in both breadcrumb and the select option
434
+ const tableElements = screen.getAllByText('Table');
435
+ expect(tableElements.length).toBeGreaterThanOrEqual(2);
436
+ });
437
+ });
438
+
439
+ // ---- I18nLabel resolution -------------------------------------------------
440
+
441
+ describe('I18nLabel resolution', () => {
442
+ it('should resolve I18nLabel object for title field', () => {
443
+ const configWithI18n = {
444
+ ...defaultWidgetConfig,
445
+ title: { key: 'widget.title', defaultValue: 'Revenue Chart (i18n)' },
446
+ };
447
+ render(
448
+ <WidgetConfigPanel
449
+ open={true}
450
+ onClose={vi.fn()}
451
+ config={configWithI18n}
452
+ onSave={vi.fn()}
453
+ />,
454
+ );
455
+ const titleInput = screen.getByTestId('config-field-title') as HTMLInputElement;
456
+ expect(titleInput.value).toBe('Revenue Chart (i18n)');
457
+ });
458
+
459
+ it('should resolve I18nLabel object for description field', () => {
460
+ const configWithI18n = {
461
+ ...defaultWidgetConfig,
462
+ description: { key: 'widget.desc', defaultValue: 'Description from i18n' },
463
+ };
464
+ render(
465
+ <WidgetConfigPanel
466
+ open={true}
467
+ onClose={vi.fn()}
468
+ config={configWithI18n}
469
+ onSave={vi.fn()}
470
+ />,
471
+ );
472
+ const descInput = screen.getByTestId('config-field-description') as HTMLInputElement;
473
+ expect(descInput.value).toBe('Description from i18n');
474
+ });
475
+ });
476
+
477
+ // ---- Pivot Table type in widget options -----------------------------------
478
+
479
+ it('should include Pivot Table in widget type options', () => {
480
+ const pivotConfig = { ...defaultWidgetConfig, type: 'pivot' };
481
+ render(
482
+ <WidgetConfigPanel
483
+ open={true}
484
+ onClose={vi.fn()}
485
+ config={pivotConfig}
486
+ onSave={vi.fn()}
487
+ />,
488
+ );
489
+ // The select should contain pivot option
490
+ expect(screen.getByText('Pivot Table')).toBeDefined();
491
+ });
492
+ });