@object-ui/react 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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +8 -0
- package/dist/SchemaRenderer.js +61 -2
- package/dist/context/SchemaRendererContext.d.ts +4 -1
- package/dist/context/SchemaRendererContext.js +6 -4
- package/dist/hooks/index.d.ts +3 -0
- package/dist/hooks/index.js +3 -0
- package/dist/hooks/useActionEngine.d.ts +27 -0
- package/dist/hooks/useActionEngine.js +39 -0
- package/dist/hooks/useDebugMode.d.ts +43 -0
- package/dist/hooks/useDebugMode.js +71 -0
- package/dist/hooks/useGlobalUndo.d.ts +35 -0
- package/dist/hooks/useGlobalUndo.js +86 -0
- package/dist/hooks/useNavigationOverlay.d.ts +2 -0
- package/dist/hooks/useNavigationOverlay.js +12 -5
- package/dist/index.d.ts +3 -1
- package/dist/index.js +4 -1
- package/dist/spec-bridge/SpecBridge.d.ts +26 -0
- package/dist/spec-bridge/SpecBridge.js +62 -0
- package/dist/spec-bridge/bridges/dashboard.d.ts +81 -0
- package/dist/spec-bridge/bridges/dashboard.js +121 -0
- package/dist/spec-bridge/bridges/form-view.d.ts +44 -0
- package/dist/spec-bridge/bridges/form-view.js +77 -0
- package/dist/spec-bridge/bridges/list-view.d.ts +82 -0
- package/dist/spec-bridge/bridges/list-view.js +154 -0
- package/dist/spec-bridge/bridges/page.d.ts +68 -0
- package/dist/spec-bridge/bridges/page.js +81 -0
- package/dist/spec-bridge/index.d.ts +13 -0
- package/dist/spec-bridge/index.js +12 -0
- package/dist/spec-bridge/types.d.ts +34 -0
- package/{src/index.test.ts → dist/spec-bridge/types.js} +1 -8
- package/package.json +7 -7
- package/src/SchemaRenderer.tsx +66 -0
- package/src/__tests__/LazyPluginLoader.test.tsx +1 -1
- package/src/__tests__/SchemaRenderer.debug.test.tsx +69 -0
- package/src/__tests__/SchemaRenderer.expressions.test.tsx +131 -0
- package/src/__tests__/useNavigationOverlay.test.ts +162 -0
- package/src/context/SchemaRendererContext.tsx +9 -4
- package/src/context/__tests__/useDataScope.test.tsx +91 -0
- package/src/hooks/__tests__/useActionEngine.test.ts +170 -0
- package/src/hooks/__tests__/useCrudShortcuts.test.ts +85 -0
- package/src/hooks/__tests__/useDebugMode.test.ts +83 -0
- package/src/hooks/__tests__/useFocusTrap.test.ts +247 -0
- package/src/hooks/__tests__/usePageTransition.test.ts +158 -0
- package/src/hooks/__tests__/useReducedMotion.test.ts +104 -0
- package/src/hooks/index.ts +3 -0
- package/src/hooks/useActionEngine.ts +84 -0
- package/src/hooks/useDebugMode.ts +95 -0
- package/src/hooks/useGlobalUndo.ts +98 -0
- package/src/hooks/useNavigationOverlay.ts +14 -5
- package/src/index.ts +6 -0
- package/src/spec-bridge/SpecBridge.ts +64 -0
- package/src/spec-bridge/__tests__/P1SpecBridge.test.ts +855 -0
- package/src/spec-bridge/__tests__/SpecBridge.test.ts +550 -0
- package/src/spec-bridge/bridges/dashboard.ts +180 -0
- package/src/spec-bridge/bridges/form-view.ts +109 -0
- package/src/spec-bridge/bridges/list-view.ts +187 -0
- package/src/spec-bridge/bridges/page.ts +136 -0
- package/src/spec-bridge/index.ts +14 -0
- package/src/spec-bridge/types.ts +38 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,855 @@
|
|
|
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
|
+
/**
|
|
10
|
+
* P1 SpecBridge Protocol Alignment Tests
|
|
11
|
+
* Tests for the enhanced bridge transformations: ListView, FormView, Dashboard, Page
|
|
12
|
+
*/
|
|
13
|
+
import { describe, it, expect } from 'vitest';
|
|
14
|
+
import { SpecBridge } from '../SpecBridge';
|
|
15
|
+
|
|
16
|
+
describe('P1 SpecBridge Protocol Alignment', () => {
|
|
17
|
+
// ========================================================================
|
|
18
|
+
// P1.1 ListView Bridge Enhancements
|
|
19
|
+
// ========================================================================
|
|
20
|
+
describe('P1.1 ListView bridge enhancements', () => {
|
|
21
|
+
it('should pass through rowActions and bulkActions', () => {
|
|
22
|
+
const bridge = new SpecBridge();
|
|
23
|
+
const node = bridge.transformListView({
|
|
24
|
+
name: 'accounts',
|
|
25
|
+
columns: [{ field: 'name', label: 'Name' }],
|
|
26
|
+
rowActions: ['edit', 'delete', 'clone'],
|
|
27
|
+
bulkActions: ['delete', 'assign'],
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
expect(node.rowActions).toEqual(['edit', 'delete', 'clone']);
|
|
31
|
+
expect(node.bulkActions).toEqual(['delete', 'assign']);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should pass through virtualScroll', () => {
|
|
35
|
+
const bridge = new SpecBridge();
|
|
36
|
+
const node = bridge.transformListView({
|
|
37
|
+
name: 'large_dataset',
|
|
38
|
+
virtualScroll: true,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
expect(node.virtualScroll).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should pass through conditionalFormatting', () => {
|
|
45
|
+
const bridge = new SpecBridge();
|
|
46
|
+
const node = bridge.transformListView({
|
|
47
|
+
name: 'styled',
|
|
48
|
+
conditionalFormatting: [
|
|
49
|
+
{ condition: '${data.amount > 10000}', style: { backgroundColor: '#fee2e2' } },
|
|
50
|
+
],
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(node.conditionalFormatting).toHaveLength(1);
|
|
54
|
+
expect(node.conditionalFormatting[0].condition).toBe('${data.amount > 10000}');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should pass through inlineEdit', () => {
|
|
58
|
+
const bridge = new SpecBridge();
|
|
59
|
+
const node = bridge.transformListView({
|
|
60
|
+
name: 'editable',
|
|
61
|
+
inlineEdit: true,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
expect(node.inlineEdit).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should pass through exportOptions', () => {
|
|
68
|
+
const bridge = new SpecBridge();
|
|
69
|
+
const node = bridge.transformListView({
|
|
70
|
+
name: 'exportable',
|
|
71
|
+
exportOptions: {
|
|
72
|
+
formats: ['csv', 'xlsx'],
|
|
73
|
+
maxRecords: 10000,
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(node.exportOptions).toBeDefined();
|
|
78
|
+
expect(node.exportOptions.formats).toEqual(['csv', 'xlsx']);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should pass through emptyState', () => {
|
|
82
|
+
const bridge = new SpecBridge();
|
|
83
|
+
const node = bridge.transformListView({
|
|
84
|
+
name: 'empty',
|
|
85
|
+
emptyState: { title: 'No Data', message: 'Add records to get started', icon: 'Database' },
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
expect(node.emptyState).toBeDefined();
|
|
89
|
+
expect(node.emptyState.title).toBe('No Data');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should pass through showRecordCount and allowPrinting', () => {
|
|
93
|
+
const bridge = new SpecBridge();
|
|
94
|
+
const node = bridge.transformListView({
|
|
95
|
+
name: 'full_featured',
|
|
96
|
+
showRecordCount: true,
|
|
97
|
+
allowPrinting: true,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expect(node.showRecordCount).toBe(true);
|
|
101
|
+
expect(node.allowPrinting).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should pass through userActions', () => {
|
|
105
|
+
const bridge = new SpecBridge();
|
|
106
|
+
const node = bridge.transformListView({
|
|
107
|
+
name: 'actions_test',
|
|
108
|
+
userActions: { sort: true, search: true, filter: false },
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(node.userActions).toBeDefined();
|
|
112
|
+
expect(node.userActions.sort).toBe(true);
|
|
113
|
+
expect(node.userActions.filter).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('should pass through aria properties', () => {
|
|
117
|
+
const bridge = new SpecBridge();
|
|
118
|
+
const node = bridge.transformListView({
|
|
119
|
+
name: 'accessible',
|
|
120
|
+
aria: { ariaLabel: 'Accounts List', role: 'grid' },
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
expect(node.aria).toBeDefined();
|
|
124
|
+
expect(node.aria.ariaLabel).toBe('Accounts List');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('should pass through hiddenFields and fieldOrder', () => {
|
|
128
|
+
const bridge = new SpecBridge();
|
|
129
|
+
const node = bridge.transformListView({
|
|
130
|
+
name: 'ordered',
|
|
131
|
+
hiddenFields: ['internal_id', 'sys_created_at'],
|
|
132
|
+
fieldOrder: ['name', 'email', 'status'],
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
expect(node.hiddenFields).toEqual(['internal_id', 'sys_created_at']);
|
|
136
|
+
expect(node.fieldOrder).toEqual(['name', 'email', 'status']);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('should map short rowHeight to compact density', () => {
|
|
140
|
+
const bridge = new SpecBridge();
|
|
141
|
+
const node = bridge.transformListView({
|
|
142
|
+
name: 'density_test',
|
|
143
|
+
rowHeight: 'short',
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
expect(node.density).toBe('compact');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('should map extra_tall rowHeight to spacious density', () => {
|
|
150
|
+
const bridge = new SpecBridge();
|
|
151
|
+
const node = bridge.transformListView({
|
|
152
|
+
name: 'density_test',
|
|
153
|
+
rowHeight: 'extra_tall',
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
expect(node.density).toBe('spacious');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('should map tall rowHeight to spacious density', () => {
|
|
160
|
+
const bridge = new SpecBridge();
|
|
161
|
+
const node = bridge.transformListView({
|
|
162
|
+
name: 'density_test',
|
|
163
|
+
rowHeight: 'tall',
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
expect(node.density).toBe('spacious');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('should pass through filterableFields', () => {
|
|
170
|
+
const bridge = new SpecBridge();
|
|
171
|
+
const node = bridge.transformListView({
|
|
172
|
+
name: 'filterable',
|
|
173
|
+
filterableFields: ['status', 'priority', 'assignee'],
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
expect(node.filterableFields).toEqual(['status', 'priority', 'assignee']);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('should pass through resizable, striped, bordered', () => {
|
|
180
|
+
const bridge = new SpecBridge();
|
|
181
|
+
const node = bridge.transformListView({
|
|
182
|
+
name: 'styled',
|
|
183
|
+
resizable: true,
|
|
184
|
+
striped: true,
|
|
185
|
+
bordered: true,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
expect(node.resizable).toBe(true);
|
|
189
|
+
expect(node.striped).toBe(true);
|
|
190
|
+
expect(node.bordered).toBe(true);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('should pass through view-type configs (kanban, calendar, gantt, gallery, timeline)', () => {
|
|
194
|
+
const bridge = new SpecBridge();
|
|
195
|
+
const node = bridge.transformListView({
|
|
196
|
+
name: 'multi_view',
|
|
197
|
+
kanban: { groupField: 'status' },
|
|
198
|
+
calendar: { startDateField: 'date' },
|
|
199
|
+
gantt: { startDateField: 'start', endDateField: 'end' },
|
|
200
|
+
gallery: { coverField: 'photo', cardSize: 'medium' },
|
|
201
|
+
timeline: { startDateField: 'created', titleField: 'name' },
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
expect(node.kanban).toEqual({ groupField: 'status' });
|
|
205
|
+
expect(node.calendar).toEqual({ startDateField: 'date' });
|
|
206
|
+
expect(node.gantt).toEqual({ startDateField: 'start', endDateField: 'end' });
|
|
207
|
+
expect(node.gallery).toEqual({ coverField: 'photo', cardSize: 'medium' });
|
|
208
|
+
expect(node.timeline).toEqual({ startDateField: 'created', titleField: 'name' });
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('should pass through navigation config', () => {
|
|
212
|
+
const bridge = new SpecBridge();
|
|
213
|
+
const node = bridge.transformListView({
|
|
214
|
+
name: 'navigable',
|
|
215
|
+
navigation: { mode: 'drawer', width: '400px' },
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
expect(node.navigation).toEqual({ mode: 'drawer', width: '400px' });
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// ========================================================================
|
|
223
|
+
// P1.2 FormView Bridge Enhancements
|
|
224
|
+
// ========================================================================
|
|
225
|
+
describe('P1.2 FormView bridge enhancements', () => {
|
|
226
|
+
it('should map formType from spec type', () => {
|
|
227
|
+
const bridge = new SpecBridge();
|
|
228
|
+
const node = bridge.transformFormView({
|
|
229
|
+
type: 'tabbed',
|
|
230
|
+
sections: [
|
|
231
|
+
{ label: 'Tab 1', fields: [{ field: 'name' }] },
|
|
232
|
+
{ label: 'Tab 2', fields: [{ field: 'email' }] },
|
|
233
|
+
],
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
expect(node.formType).toBe('tabbed');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('should map wizard formType', () => {
|
|
240
|
+
const bridge = new SpecBridge();
|
|
241
|
+
const node = bridge.transformFormView({
|
|
242
|
+
type: 'wizard',
|
|
243
|
+
sections: [
|
|
244
|
+
{ label: 'Step 1', fields: [{ field: 'name' }] },
|
|
245
|
+
{ label: 'Step 2', fields: [{ field: 'email' }] },
|
|
246
|
+
],
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
expect(node.formType).toBe('wizard');
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('should map modal formType', () => {
|
|
253
|
+
const bridge = new SpecBridge();
|
|
254
|
+
const node = bridge.transformFormView({ type: 'modal' });
|
|
255
|
+
expect(node.formType).toBe('modal');
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('should map split formType', () => {
|
|
259
|
+
const bridge = new SpecBridge();
|
|
260
|
+
const node = bridge.transformFormView({ type: 'split' });
|
|
261
|
+
expect(node.formType).toBe('split');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('should map drawer formType', () => {
|
|
265
|
+
const bridge = new SpecBridge();
|
|
266
|
+
const node = bridge.transformFormView({ type: 'drawer' });
|
|
267
|
+
expect(node.formType).toBe('drawer');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('should not set formType for unknown types', () => {
|
|
271
|
+
const bridge = new SpecBridge();
|
|
272
|
+
const node = bridge.transformFormView({ type: 'unknown_type' });
|
|
273
|
+
expect(node.formType).toBeUndefined();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('should map FormSection with columns', () => {
|
|
277
|
+
const bridge = new SpecBridge();
|
|
278
|
+
const node = bridge.transformFormView({
|
|
279
|
+
sections: [
|
|
280
|
+
{
|
|
281
|
+
label: 'Contact Info',
|
|
282
|
+
columns: 2,
|
|
283
|
+
collapsible: true,
|
|
284
|
+
collapsed: false,
|
|
285
|
+
fields: [
|
|
286
|
+
{ field: 'name', label: 'Full Name', required: true },
|
|
287
|
+
{ field: 'email', label: 'Email', placeholder: 'user@example.com' },
|
|
288
|
+
],
|
|
289
|
+
},
|
|
290
|
+
],
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const sections = node.sections as any[];
|
|
294
|
+
expect(sections).toHaveLength(1);
|
|
295
|
+
expect(sections[0].label).toBe('Contact Info');
|
|
296
|
+
expect(sections[0].columns).toBe(2);
|
|
297
|
+
expect(sections[0].collapsible).toBe(true);
|
|
298
|
+
expect(sections[0].fields).toHaveLength(2);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('should map FormField properties: widget, dependsOn, visibleOn, colSpan', () => {
|
|
302
|
+
const bridge = new SpecBridge();
|
|
303
|
+
const node = bridge.transformFormView({
|
|
304
|
+
sections: [
|
|
305
|
+
{
|
|
306
|
+
fields: [
|
|
307
|
+
{
|
|
308
|
+
field: 'sub_industry',
|
|
309
|
+
label: 'Sub-Industry',
|
|
310
|
+
widget: 'industry-picker',
|
|
311
|
+
dependsOn: ['industry'],
|
|
312
|
+
visibleOn: '${data.industry != null}',
|
|
313
|
+
colSpan: 2,
|
|
314
|
+
},
|
|
315
|
+
],
|
|
316
|
+
},
|
|
317
|
+
],
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
const field = (node.sections as any[])[0].fields[0];
|
|
321
|
+
expect(field.widget).toBe('industry-picker');
|
|
322
|
+
expect(field.dependsOn).toEqual(['industry']);
|
|
323
|
+
expect(field.visibleOn).toBe('${data.industry != null}');
|
|
324
|
+
expect(field.colSpan).toBe(2);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('should pass through aria properties', () => {
|
|
328
|
+
const bridge = new SpecBridge();
|
|
329
|
+
const node = bridge.transformFormView({
|
|
330
|
+
type: 'simple',
|
|
331
|
+
aria: { ariaLabel: 'Create Account Form' },
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
expect(node.aria).toBeDefined();
|
|
335
|
+
expect(node.aria.ariaLabel).toBe('Create Account Form');
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// ========================================================================
|
|
340
|
+
// P1.3 Dashboard Bridge Enhancements
|
|
341
|
+
// ========================================================================
|
|
342
|
+
describe('P1.3 Dashboard bridge enhancements', () => {
|
|
343
|
+
it('should map widget data binding properties', () => {
|
|
344
|
+
const bridge = new SpecBridge();
|
|
345
|
+
const node = bridge.transformDashboard({
|
|
346
|
+
name: 'sales',
|
|
347
|
+
widgets: [
|
|
348
|
+
{
|
|
349
|
+
title: 'Revenue',
|
|
350
|
+
type: 'bar-chart',
|
|
351
|
+
object: 'Opportunity',
|
|
352
|
+
filter: [['stage', '=', 'Closed Won']],
|
|
353
|
+
categoryField: 'region',
|
|
354
|
+
valueField: 'amount',
|
|
355
|
+
aggregate: 'sum',
|
|
356
|
+
},
|
|
357
|
+
],
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
const widgetGrid = (node.body as any[]).find((b: any) => b.type === 'widget-grid');
|
|
361
|
+
const widgets = widgetGrid.body as any[];
|
|
362
|
+
expect(widgets[0].object).toBe('Opportunity');
|
|
363
|
+
expect(widgets[0].categoryField).toBe('region');
|
|
364
|
+
expect(widgets[0].valueField).toBe('amount');
|
|
365
|
+
expect(widgets[0].aggregate).toBe('sum');
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('should map widget color variants', () => {
|
|
369
|
+
const bridge = new SpecBridge();
|
|
370
|
+
const node = bridge.transformDashboard({
|
|
371
|
+
name: 'colored',
|
|
372
|
+
widgets: [
|
|
373
|
+
{ title: 'Success', type: 'metric', colorVariant: 'success' },
|
|
374
|
+
{ title: 'Danger', type: 'metric', colorVariant: 'danger' },
|
|
375
|
+
],
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
const widgetGrid = (node.body as any[]).find((b: any) => b.type === 'widget-grid');
|
|
379
|
+
const widgets = widgetGrid.body as any[];
|
|
380
|
+
expect(widgets[0].colorVariant).toBe('success');
|
|
381
|
+
expect(widgets[1].colorVariant).toBe('danger');
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('should map widget measures (pivot/matrix)', () => {
|
|
385
|
+
const bridge = new SpecBridge();
|
|
386
|
+
const node = bridge.transformDashboard({
|
|
387
|
+
name: 'pivot',
|
|
388
|
+
widgets: [
|
|
389
|
+
{
|
|
390
|
+
title: 'Matrix',
|
|
391
|
+
type: 'pivot',
|
|
392
|
+
measures: [
|
|
393
|
+
{ valueField: 'amount', aggregate: 'sum', label: 'Total', format: '$0,0' },
|
|
394
|
+
],
|
|
395
|
+
},
|
|
396
|
+
],
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
const widgetGrid = (node.body as any[]).find((b: any) => b.type === 'widget-grid');
|
|
400
|
+
const widgets = widgetGrid.body as any[];
|
|
401
|
+
expect(widgets[0].measures).toHaveLength(1);
|
|
402
|
+
expect(widgets[0].measures[0].label).toBe('Total');
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('should map globalFilters with optionsFrom', () => {
|
|
406
|
+
const bridge = new SpecBridge();
|
|
407
|
+
const node = bridge.transformDashboard({
|
|
408
|
+
name: 'filtered',
|
|
409
|
+
widgets: [],
|
|
410
|
+
globalFilters: [
|
|
411
|
+
{
|
|
412
|
+
field: 'region',
|
|
413
|
+
label: 'Region',
|
|
414
|
+
type: 'select',
|
|
415
|
+
optionsFrom: {
|
|
416
|
+
object: 'Region',
|
|
417
|
+
valueField: 'id',
|
|
418
|
+
labelField: 'name',
|
|
419
|
+
},
|
|
420
|
+
targetWidgets: ['widget-0'],
|
|
421
|
+
},
|
|
422
|
+
],
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
const filterBar = (node.body as any[]).find((b: any) => b.type === 'filter-bar');
|
|
426
|
+
expect(filterBar).toBeDefined();
|
|
427
|
+
expect(filterBar.filters[0].field).toBe('region');
|
|
428
|
+
expect(filterBar.filters[0].optionsFrom.object).toBe('Region');
|
|
429
|
+
expect(filterBar.filters[0].targetWidgets).toEqual(['widget-0']);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it('should map dateRange filter', () => {
|
|
433
|
+
const bridge = new SpecBridge();
|
|
434
|
+
const node = bridge.transformDashboard({
|
|
435
|
+
name: 'dated',
|
|
436
|
+
widgets: [],
|
|
437
|
+
dateRange: {
|
|
438
|
+
field: 'created_at',
|
|
439
|
+
defaultRange: 'last_30_days',
|
|
440
|
+
allowCustomRange: true,
|
|
441
|
+
},
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
const dateFilter = (node.body as any[]).find((b: any) => b.type === 'date-range-filter');
|
|
445
|
+
expect(dateFilter).toBeDefined();
|
|
446
|
+
expect(dateFilter.field).toBe('created_at');
|
|
447
|
+
expect(dateFilter.defaultRange).toBe('last_30_days');
|
|
448
|
+
expect(dateFilter.allowCustomRange).toBe(true);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it('should map DashboardHeader with actions', () => {
|
|
452
|
+
const bridge = new SpecBridge();
|
|
453
|
+
const node = bridge.transformDashboard({
|
|
454
|
+
name: 'with_header',
|
|
455
|
+
widgets: [],
|
|
456
|
+
header: {
|
|
457
|
+
showTitle: true,
|
|
458
|
+
actions: [
|
|
459
|
+
{ label: 'Refresh', actionType: 'refresh', icon: 'RefreshCw' },
|
|
460
|
+
],
|
|
461
|
+
},
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
const header = (node.body as any[]).find((b: any) => b.type === 'dashboard-header');
|
|
465
|
+
expect(header).toBeDefined();
|
|
466
|
+
expect(header.showTitle).toBe(true);
|
|
467
|
+
expect(header.actions).toHaveLength(1);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it('should pass through dashboard description and refreshInterval', () => {
|
|
471
|
+
const bridge = new SpecBridge();
|
|
472
|
+
const node = bridge.transformDashboard({
|
|
473
|
+
name: 'auto_refresh',
|
|
474
|
+
label: 'Live Dashboard',
|
|
475
|
+
description: 'Real-time sales data',
|
|
476
|
+
refreshInterval: 30,
|
|
477
|
+
widgets: [],
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
expect(node.description).toBe('Real-time sales data');
|
|
481
|
+
expect(node.refreshInterval).toBe(30);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it('should pass through dashboard aria', () => {
|
|
485
|
+
const bridge = new SpecBridge();
|
|
486
|
+
const node = bridge.transformDashboard({
|
|
487
|
+
name: 'accessible',
|
|
488
|
+
widgets: [],
|
|
489
|
+
aria: { ariaLabel: 'Sales Dashboard', role: 'region' },
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
expect(node.aria).toBeDefined();
|
|
493
|
+
expect(node.aria.ariaLabel).toBe('Sales Dashboard');
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it('should map widget action properties', () => {
|
|
497
|
+
const bridge = new SpecBridge();
|
|
498
|
+
const node = bridge.transformDashboard({
|
|
499
|
+
name: 'clickable',
|
|
500
|
+
widgets: [
|
|
501
|
+
{
|
|
502
|
+
title: 'Open Deals',
|
|
503
|
+
type: 'metric',
|
|
504
|
+
actionUrl: '/opportunities?status=open',
|
|
505
|
+
actionType: 'navigate',
|
|
506
|
+
actionIcon: 'ArrowRight',
|
|
507
|
+
},
|
|
508
|
+
],
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
const widgetGrid = (node.body as any[]).find((b: any) => b.type === 'widget-grid');
|
|
512
|
+
const widgets = widgetGrid.body as any[];
|
|
513
|
+
expect(widgets[0].actionUrl).toBe('/opportunities?status=open');
|
|
514
|
+
expect(widgets[0].actionType).toBe('navigate');
|
|
515
|
+
expect(widgets[0].actionIcon).toBe('ArrowRight');
|
|
516
|
+
});
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
// ========================================================================
|
|
520
|
+
// P1.4 Page Bridge Enhancements
|
|
521
|
+
// ========================================================================
|
|
522
|
+
describe('P1.4 Page bridge enhancements', () => {
|
|
523
|
+
it('should map expanded page types', () => {
|
|
524
|
+
const bridge = new SpecBridge();
|
|
525
|
+
const pageTypes = [
|
|
526
|
+
'record', 'home', 'app', 'utility',
|
|
527
|
+
'dashboard', 'grid', 'list', 'gallery',
|
|
528
|
+
'kanban', 'calendar', 'timeline', 'form',
|
|
529
|
+
'record_detail', 'record_review', 'overview', 'blank',
|
|
530
|
+
];
|
|
531
|
+
|
|
532
|
+
pageTypes.forEach((pageType) => {
|
|
533
|
+
const node = bridge.transformPage({
|
|
534
|
+
name: `${pageType}_page`,
|
|
535
|
+
type: pageType,
|
|
536
|
+
});
|
|
537
|
+
expect(node.pageType).toBe(pageType);
|
|
538
|
+
});
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it('should map blank page layout', () => {
|
|
542
|
+
const bridge = new SpecBridge();
|
|
543
|
+
const node = bridge.transformPage({
|
|
544
|
+
name: 'blank_page',
|
|
545
|
+
type: 'blank',
|
|
546
|
+
blankLayout: {
|
|
547
|
+
columns: 12,
|
|
548
|
+
rowHeight: 60,
|
|
549
|
+
gap: 8,
|
|
550
|
+
items: [
|
|
551
|
+
{ componentId: 'header', x: 0, y: 0, width: 12, height: 2 },
|
|
552
|
+
{ componentId: 'chart', x: 0, y: 2, width: 6, height: 4 },
|
|
553
|
+
],
|
|
554
|
+
},
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
expect(node.blankLayout).toBeDefined();
|
|
558
|
+
expect(node.blankLayout.columns).toBe(12);
|
|
559
|
+
expect(node.blankLayout.items).toHaveLength(2);
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
it('should map page variables with source', () => {
|
|
563
|
+
const bridge = new SpecBridge();
|
|
564
|
+
const node = bridge.transformPage({
|
|
565
|
+
name: 'record_page',
|
|
566
|
+
type: 'record',
|
|
567
|
+
variables: [
|
|
568
|
+
{ name: 'recordId', type: 'record_id', source: 'url_param' },
|
|
569
|
+
{ name: 'tab', type: 'string', defaultValue: 'details' },
|
|
570
|
+
],
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
expect(node.variables).toHaveLength(2);
|
|
574
|
+
expect(node.variables[0].name).toBe('recordId');
|
|
575
|
+
expect(node.variables[0].type).toBe('record_id');
|
|
576
|
+
expect(node.variables[0].source).toBe('url_param');
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it('should map event handlers on page components', () => {
|
|
580
|
+
const bridge = new SpecBridge();
|
|
581
|
+
const node = bridge.transformPage({
|
|
582
|
+
name: 'interactive',
|
|
583
|
+
regions: [
|
|
584
|
+
{
|
|
585
|
+
name: 'main',
|
|
586
|
+
components: [
|
|
587
|
+
{
|
|
588
|
+
type: 'element:button',
|
|
589
|
+
id: 'save-btn',
|
|
590
|
+
label: 'Save',
|
|
591
|
+
events: {
|
|
592
|
+
onClick: '${actions.save()}',
|
|
593
|
+
onHover: '${actions.highlight()}',
|
|
594
|
+
},
|
|
595
|
+
},
|
|
596
|
+
],
|
|
597
|
+
},
|
|
598
|
+
],
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
const region = (node.body as any[])[0];
|
|
602
|
+
const component = region.body[0];
|
|
603
|
+
expect(component.events).toBeDefined();
|
|
604
|
+
expect(component.events.onClick).toBe('${actions.save()}');
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it('should map responsive config on page components', () => {
|
|
608
|
+
const bridge = new SpecBridge();
|
|
609
|
+
const node = bridge.transformPage({
|
|
610
|
+
name: 'responsive',
|
|
611
|
+
regions: [
|
|
612
|
+
{
|
|
613
|
+
name: 'main',
|
|
614
|
+
components: [
|
|
615
|
+
{
|
|
616
|
+
type: 'element:text',
|
|
617
|
+
id: 'heading',
|
|
618
|
+
responsive: {
|
|
619
|
+
sm: { hidden: true },
|
|
620
|
+
md: { hidden: false },
|
|
621
|
+
},
|
|
622
|
+
},
|
|
623
|
+
],
|
|
624
|
+
},
|
|
625
|
+
],
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
const region = (node.body as any[])[0];
|
|
629
|
+
const component = region.body[0];
|
|
630
|
+
expect(component.responsive).toBeDefined();
|
|
631
|
+
expect(component.responsive.sm.hidden).toBe(true);
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
it('should pass through page description and icon', () => {
|
|
635
|
+
const bridge = new SpecBridge();
|
|
636
|
+
const node = bridge.transformPage({
|
|
637
|
+
name: 'detailed',
|
|
638
|
+
label: 'Home',
|
|
639
|
+
description: 'Main landing page',
|
|
640
|
+
icon: 'Home',
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
expect(node.description).toBe('Main landing page');
|
|
644
|
+
expect(node.icon).toBe('Home');
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
it('should pass through page aria', () => {
|
|
648
|
+
const bridge = new SpecBridge();
|
|
649
|
+
const node = bridge.transformPage({
|
|
650
|
+
name: 'accessible',
|
|
651
|
+
aria: { ariaLabel: 'Home Page', role: 'main' },
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
expect(node.aria).toBeDefined();
|
|
655
|
+
expect(node.aria.ariaLabel).toBe('Home Page');
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
it('should map region width', () => {
|
|
659
|
+
const bridge = new SpecBridge();
|
|
660
|
+
const node = bridge.transformPage({
|
|
661
|
+
name: 'sidebar_page',
|
|
662
|
+
regions: [
|
|
663
|
+
{ name: 'sidebar', width: 'small', components: [] },
|
|
664
|
+
{ name: 'main', width: 'large', components: [] },
|
|
665
|
+
],
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
const regions = node.body as any[];
|
|
669
|
+
expect(regions[0].width).toBe('small');
|
|
670
|
+
expect(regions[1].width).toBe('large');
|
|
671
|
+
});
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
// ========================================================================
|
|
675
|
+
// QuickFilters Bridge Normalization
|
|
676
|
+
// ========================================================================
|
|
677
|
+
describe('quickFilters bridge normalization', () => {
|
|
678
|
+
it('should normalize spec format { field, operator, value } in bridge', () => {
|
|
679
|
+
const bridge = new SpecBridge();
|
|
680
|
+
const node = bridge.transformListView({
|
|
681
|
+
name: 'contacts',
|
|
682
|
+
quickFilters: [
|
|
683
|
+
{ field: 'status', operator: 'eq', value: 'active', label: 'Active' },
|
|
684
|
+
],
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
expect(node.quickFilters).toHaveLength(1);
|
|
688
|
+
const qf = (node.quickFilters as any[])[0];
|
|
689
|
+
expect(qf.id).toBe('status-eq-active');
|
|
690
|
+
expect(qf.label).toBe('Active');
|
|
691
|
+
expect(qf.filters).toEqual([['status', '=', 'active']]);
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
it('should pass through ObjectUI format { id, label, filters[] } unchanged', () => {
|
|
695
|
+
const bridge = new SpecBridge();
|
|
696
|
+
const node = bridge.transformListView({
|
|
697
|
+
name: 'contacts',
|
|
698
|
+
quickFilters: [
|
|
699
|
+
{ id: 'active', label: 'Active', filters: [['status', '=', 'active']] },
|
|
700
|
+
],
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
expect(node.quickFilters).toHaveLength(1);
|
|
704
|
+
const qf = (node.quickFilters as any[])[0];
|
|
705
|
+
expect(qf.id).toBe('active');
|
|
706
|
+
expect(qf.label).toBe('Active');
|
|
707
|
+
expect(qf.filters).toEqual([['status', '=', 'active']]);
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
it('should normalize mixed format arrays in bridge', () => {
|
|
711
|
+
const bridge = new SpecBridge();
|
|
712
|
+
const node = bridge.transformListView({
|
|
713
|
+
name: 'contacts',
|
|
714
|
+
quickFilters: [
|
|
715
|
+
{ id: 'vip', label: 'VIP', filters: [['vip', '=', true]] },
|
|
716
|
+
{ field: 'priority', operator: 'gt', value: 5, label: 'High Priority' },
|
|
717
|
+
],
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
expect(node.quickFilters).toHaveLength(2);
|
|
721
|
+
const [qf1, qf2] = node.quickFilters as any[];
|
|
722
|
+
expect(qf1.id).toBe('vip');
|
|
723
|
+
expect(qf2.id).toBe('priority-gt-5');
|
|
724
|
+
expect(qf2.filters).toEqual([['priority', '>', 5]]);
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
it('should auto-generate label when omitted in spec format', () => {
|
|
728
|
+
const bridge = new SpecBridge();
|
|
729
|
+
const node = bridge.transformListView({
|
|
730
|
+
name: 'contacts',
|
|
731
|
+
quickFilters: [
|
|
732
|
+
{ field: 'status', operator: 'eq', value: 'active' },
|
|
733
|
+
],
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
const qf = (node.quickFilters as any[])[0];
|
|
737
|
+
expect(qf.label).toBe('status eq active');
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
it('should handle null value in spec format', () => {
|
|
741
|
+
const bridge = new SpecBridge();
|
|
742
|
+
const node = bridge.transformListView({
|
|
743
|
+
name: 'contacts',
|
|
744
|
+
quickFilters: [
|
|
745
|
+
{ field: 'archived', operator: 'eq', value: null, label: 'Not Archived' },
|
|
746
|
+
],
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
const qf = (node.quickFilters as any[])[0];
|
|
750
|
+
expect(qf.id).toBe('archived-eq-');
|
|
751
|
+
expect(qf.label).toBe('Not Archived');
|
|
752
|
+
expect(qf.filters).toEqual([['archived', '=', null]]);
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
it('should preserve defaultActive and icon from spec format', () => {
|
|
756
|
+
const bridge = new SpecBridge();
|
|
757
|
+
const node = bridge.transformListView({
|
|
758
|
+
name: 'contacts',
|
|
759
|
+
quickFilters: [
|
|
760
|
+
{ field: 'status', operator: 'eq', value: 'active', label: 'Active', icon: 'check', defaultActive: true },
|
|
761
|
+
],
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
const qf = (node.quickFilters as any[])[0];
|
|
765
|
+
expect(qf.icon).toBe('check');
|
|
766
|
+
expect(qf.defaultActive).toBe(true);
|
|
767
|
+
});
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
// ========================================================================
|
|
771
|
+
// P2 Sharing / ExportOptions / Pagination Protocol Alignment
|
|
772
|
+
// ========================================================================
|
|
773
|
+
describe('P2 sharing/exportOptions/pagination alignment', () => {
|
|
774
|
+
it('should normalize spec sharing { type: personal } to { visibility: private, enabled: true }', () => {
|
|
775
|
+
const bridge = new SpecBridge();
|
|
776
|
+
const node = bridge.transformListView({
|
|
777
|
+
name: 'personal_view',
|
|
778
|
+
sharing: { type: 'personal', lockedBy: 'admin@example.com' },
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
expect(node.sharing).toBeDefined();
|
|
782
|
+
expect(node.sharing.visibility).toBe('private');
|
|
783
|
+
expect(node.sharing.enabled).toBe(true);
|
|
784
|
+
expect(node.sharing.type).toBe('personal');
|
|
785
|
+
expect(node.sharing.lockedBy).toBe('admin@example.com');
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
it('should normalize spec sharing { type: collaborative } to { visibility: team, enabled: true }', () => {
|
|
789
|
+
const bridge = new SpecBridge();
|
|
790
|
+
const node = bridge.transformListView({
|
|
791
|
+
name: 'collab_view',
|
|
792
|
+
sharing: { type: 'collaborative' },
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
expect(node.sharing.visibility).toBe('team');
|
|
796
|
+
expect(node.sharing.enabled).toBe(true);
|
|
797
|
+
expect(node.sharing.type).toBe('collaborative');
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
it('should preserve ObjectUI sharing format without overriding', () => {
|
|
801
|
+
const bridge = new SpecBridge();
|
|
802
|
+
const node = bridge.transformListView({
|
|
803
|
+
name: 'objectui_view',
|
|
804
|
+
sharing: { visibility: 'organization', enabled: true },
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
expect(node.sharing.visibility).toBe('organization');
|
|
808
|
+
expect(node.sharing.enabled).toBe(true);
|
|
809
|
+
expect(node.sharing.type).toBeUndefined();
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
it('should not override explicit visibility when type is set', () => {
|
|
813
|
+
const bridge = new SpecBridge();
|
|
814
|
+
const node = bridge.transformListView({
|
|
815
|
+
name: 'mixed_view',
|
|
816
|
+
sharing: { type: 'collaborative', visibility: 'public' },
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
expect(node.sharing.visibility).toBe('public');
|
|
820
|
+
expect(node.sharing.type).toBe('collaborative');
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
it('should pass through exportOptions string[] format', () => {
|
|
824
|
+
const bridge = new SpecBridge();
|
|
825
|
+
const node = bridge.transformListView({
|
|
826
|
+
name: 'export_spec',
|
|
827
|
+
exportOptions: ['csv', 'xlsx'],
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
expect(node.exportOptions).toEqual(['csv', 'xlsx']);
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
it('should pass through exportOptions object format', () => {
|
|
834
|
+
const bridge = new SpecBridge();
|
|
835
|
+
const node = bridge.transformListView({
|
|
836
|
+
name: 'export_obj',
|
|
837
|
+
exportOptions: { formats: ['csv', 'json'], maxRecords: 5000 },
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
expect(node.exportOptions.formats).toEqual(['csv', 'json']);
|
|
841
|
+
expect(node.exportOptions.maxRecords).toBe(5000);
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
it('should pass through pagination with pageSizeOptions', () => {
|
|
845
|
+
const bridge = new SpecBridge();
|
|
846
|
+
const node = bridge.transformListView({
|
|
847
|
+
name: 'paginated',
|
|
848
|
+
pagination: { pageSize: 25, pageSizeOptions: [10, 25, 50, 100] },
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
expect(node.pagination.pageSize).toBe(25);
|
|
852
|
+
expect(node.pagination.pageSizeOptions).toEqual([10, 25, 50, 100]);
|
|
853
|
+
});
|
|
854
|
+
});
|
|
855
|
+
});
|