@object-ui/plugin-form 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 +6 -6
- package/CHANGELOG.md +11 -0
- package/dist/index.js +1217 -861
- package/dist/index.umd.cjs +2 -2
- package/dist/plugin-form/src/EmbeddableForm.d.ts +49 -0
- package/dist/plugin-form/src/FormAnalytics.d.ts +38 -0
- package/dist/plugin-form/src/FormSection.d.ts +6 -0
- package/dist/plugin-form/src/autoLayout.d.ts +60 -0
- package/dist/plugin-form/src/index.d.ts +5 -0
- package/package.json +8 -8
- package/src/DrawerForm.tsx +49 -24
- package/src/EmbeddableForm.tsx +240 -0
- package/src/FormAnalytics.tsx +209 -0
- package/src/FormSection.tsx +9 -1
- package/src/ModalForm.tsx +145 -45
- package/src/ObjectForm.tsx +12 -4
- package/src/SplitForm.tsx +3 -2
- package/src/TabbedForm.tsx +3 -2
- package/src/WizardForm.tsx +3 -2
- package/src/__tests__/EmbeddableFormPrefill.test.tsx +186 -0
- package/src/__tests__/MobileUX.test.tsx +433 -0
- package/src/__tests__/NewVariants.test.tsx +196 -0
- package/src/__tests__/autoLayout.test.ts +342 -0
- package/src/autoLayout.ts +168 -0
- package/src/index.tsx +52 -0
|
@@ -0,0 +1,433 @@
|
|
|
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
|
+
* Mobile UX Tests for ModalForm
|
|
11
|
+
*
|
|
12
|
+
* Validates mobile-specific optimizations:
|
|
13
|
+
* - Skeleton loading state (replaces spinner)
|
|
14
|
+
* - Flex layout structure with sticky header/footer
|
|
15
|
+
* - Full-screen modal on mobile (h-[100dvh])
|
|
16
|
+
* - Close button touch target (min 44×44px)
|
|
17
|
+
* - Sticky footer with action buttons outside scroll area
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
21
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
22
|
+
import React from 'react';
|
|
23
|
+
import { ModalForm } from '../ModalForm';
|
|
24
|
+
|
|
25
|
+
const mockObjectSchema = {
|
|
26
|
+
name: 'events',
|
|
27
|
+
fields: {
|
|
28
|
+
subject: { label: 'Subject', type: 'text', required: true },
|
|
29
|
+
start: { label: 'Start', type: 'datetime', required: true },
|
|
30
|
+
end: { label: 'End', type: 'datetime', required: true },
|
|
31
|
+
location: { label: 'Location', type: 'text', required: false },
|
|
32
|
+
description: { label: 'Description', type: 'textarea', required: false },
|
|
33
|
+
participants: { label: 'Participants', type: 'lookup', required: false },
|
|
34
|
+
type: { label: 'Type', type: 'select', required: false, options: [{ value: 'meeting', label: 'Meeting' }] },
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const createMockDataSource = () => ({
|
|
39
|
+
getObjectSchema: vi.fn().mockResolvedValue(mockObjectSchema),
|
|
40
|
+
findOne: vi.fn().mockResolvedValue({ id: '1', subject: 'Test Event' }),
|
|
41
|
+
find: vi.fn().mockResolvedValue([]),
|
|
42
|
+
create: vi.fn().mockResolvedValue({ id: '1' }),
|
|
43
|
+
update: vi.fn().mockResolvedValue({ id: '1' }),
|
|
44
|
+
delete: vi.fn().mockResolvedValue(true),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('ModalForm Mobile UX', () => {
|
|
48
|
+
it('renders skeleton loading instead of spinner', () => {
|
|
49
|
+
const mockDataSource = createMockDataSource();
|
|
50
|
+
// Make getObjectSchema hang (never resolve) to keep loading state
|
|
51
|
+
mockDataSource.getObjectSchema.mockReturnValue(new Promise(() => {}));
|
|
52
|
+
|
|
53
|
+
render(
|
|
54
|
+
<ModalForm
|
|
55
|
+
schema={{
|
|
56
|
+
type: 'object-form',
|
|
57
|
+
formType: 'modal',
|
|
58
|
+
objectName: 'events',
|
|
59
|
+
mode: 'create',
|
|
60
|
+
title: 'Create Event',
|
|
61
|
+
open: true,
|
|
62
|
+
}}
|
|
63
|
+
dataSource={mockDataSource as any}
|
|
64
|
+
/>
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
// Should show skeleton loading, not the old spinner text
|
|
68
|
+
expect(screen.queryByText('Loading form...')).not.toBeInTheDocument();
|
|
69
|
+
expect(screen.getByTestId('modal-form-skeleton')).toBeInTheDocument();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('renders with full-screen mobile classes and flex layout on MobileDialogContent', async () => {
|
|
73
|
+
const mockDataSource = createMockDataSource();
|
|
74
|
+
|
|
75
|
+
render(
|
|
76
|
+
<ModalForm
|
|
77
|
+
schema={{
|
|
78
|
+
type: 'object-form',
|
|
79
|
+
formType: 'modal',
|
|
80
|
+
objectName: 'events',
|
|
81
|
+
mode: 'create',
|
|
82
|
+
title: 'Create Event',
|
|
83
|
+
description: 'Add a new Event to your database.',
|
|
84
|
+
open: true,
|
|
85
|
+
}}
|
|
86
|
+
dataSource={mockDataSource as any}
|
|
87
|
+
/>
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
await waitFor(() => {
|
|
91
|
+
expect(screen.getByText('Create Event')).toBeInTheDocument();
|
|
92
|
+
});
|
|
93
|
+
expect(screen.getByText('Add a new Event to your database.')).toBeInTheDocument();
|
|
94
|
+
|
|
95
|
+
// MobileDialogContent should be rendered via portal
|
|
96
|
+
const dialogContent = document.querySelector('[role="dialog"]');
|
|
97
|
+
expect(dialogContent).not.toBeNull();
|
|
98
|
+
const cls = dialogContent!.className;
|
|
99
|
+
// Mobile full-screen
|
|
100
|
+
expect(cls).toContain('h-[100dvh]');
|
|
101
|
+
// Flex column layout for sticky header/footer
|
|
102
|
+
expect(cls).toContain('flex');
|
|
103
|
+
expect(cls).toContain('flex-col');
|
|
104
|
+
// Overflow hidden (scroll is on the body area)
|
|
105
|
+
expect(cls).toContain('overflow-hidden');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('close button has accessible touch target (≥44×44px on mobile)', async () => {
|
|
109
|
+
const mockDataSource = createMockDataSource();
|
|
110
|
+
|
|
111
|
+
render(
|
|
112
|
+
<ModalForm
|
|
113
|
+
schema={{
|
|
114
|
+
type: 'object-form',
|
|
115
|
+
formType: 'modal',
|
|
116
|
+
objectName: 'events',
|
|
117
|
+
mode: 'create',
|
|
118
|
+
title: 'Create Event',
|
|
119
|
+
open: true,
|
|
120
|
+
}}
|
|
121
|
+
dataSource={mockDataSource as any}
|
|
122
|
+
/>
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
await waitFor(() => {
|
|
126
|
+
expect(screen.getByText('Create Event')).toBeInTheDocument();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Close button should have WCAG-compliant touch target classes
|
|
130
|
+
const closeButton = screen.getByRole('button', { name: /close/i });
|
|
131
|
+
expect(closeButton).toBeInTheDocument();
|
|
132
|
+
const cls = closeButton.className;
|
|
133
|
+
expect(cls).toContain('min-h-[44px]');
|
|
134
|
+
expect(cls).toContain('min-w-[44px]');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('renders sticky footer with action buttons outside scroll area', async () => {
|
|
138
|
+
const mockDataSource = createMockDataSource();
|
|
139
|
+
|
|
140
|
+
render(
|
|
141
|
+
<ModalForm
|
|
142
|
+
schema={{
|
|
143
|
+
type: 'object-form',
|
|
144
|
+
formType: 'modal',
|
|
145
|
+
objectName: 'events',
|
|
146
|
+
mode: 'create',
|
|
147
|
+
title: 'Create Event',
|
|
148
|
+
open: true,
|
|
149
|
+
showSubmit: true,
|
|
150
|
+
showCancel: true,
|
|
151
|
+
submitText: 'Save Record',
|
|
152
|
+
cancelText: 'Cancel',
|
|
153
|
+
}}
|
|
154
|
+
dataSource={mockDataSource as any}
|
|
155
|
+
/>
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
// Footer should exist as a sibling to the scroll area, not inside it
|
|
159
|
+
const footer = await screen.findByTestId('modal-form-footer');
|
|
160
|
+
expect(footer.className).toContain('border-t');
|
|
161
|
+
expect(footer.className).toContain('shrink-0');
|
|
162
|
+
|
|
163
|
+
// Action buttons should be in the footer
|
|
164
|
+
const saveButton = screen.getByRole('button', { name: /save record/i });
|
|
165
|
+
const cancelButton = screen.getByRole('button', { name: /cancel/i });
|
|
166
|
+
expect(footer.contains(saveButton)).toBe(true);
|
|
167
|
+
expect(footer.contains(cancelButton)).toBe(true);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('does not show footer during loading or error states', () => {
|
|
171
|
+
const mockDataSource = createMockDataSource();
|
|
172
|
+
mockDataSource.getObjectSchema.mockReturnValue(new Promise(() => {}));
|
|
173
|
+
|
|
174
|
+
render(
|
|
175
|
+
<ModalForm
|
|
176
|
+
schema={{
|
|
177
|
+
type: 'object-form',
|
|
178
|
+
formType: 'modal',
|
|
179
|
+
objectName: 'events',
|
|
180
|
+
mode: 'create',
|
|
181
|
+
title: 'Create Event',
|
|
182
|
+
open: true,
|
|
183
|
+
showSubmit: true,
|
|
184
|
+
showCancel: true,
|
|
185
|
+
}}
|
|
186
|
+
dataSource={mockDataSource as any}
|
|
187
|
+
/>
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
// While loading, footer should not be rendered
|
|
191
|
+
expect(screen.queryByTestId('modal-form-footer')).not.toBeInTheDocument();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe('ModalForm Container Query Layout', () => {
|
|
196
|
+
/** CSS selector for the @container query context element */
|
|
197
|
+
const CONTAINER_SELECTOR = '.\\@container';
|
|
198
|
+
|
|
199
|
+
it('applies @container class on scrollable content area', async () => {
|
|
200
|
+
const mockDataSource = createMockDataSource();
|
|
201
|
+
|
|
202
|
+
render(
|
|
203
|
+
<ModalForm
|
|
204
|
+
schema={{
|
|
205
|
+
type: 'object-form',
|
|
206
|
+
formType: 'modal',
|
|
207
|
+
objectName: 'events',
|
|
208
|
+
mode: 'create',
|
|
209
|
+
title: 'Create Event',
|
|
210
|
+
open: true,
|
|
211
|
+
}}
|
|
212
|
+
dataSource={mockDataSource as any}
|
|
213
|
+
/>
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
await waitFor(() => {
|
|
217
|
+
expect(screen.getByText('Create Event')).toBeInTheDocument();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// The scrollable content wrapper should be a @container query context
|
|
221
|
+
const dialogContent = document.querySelector('[role="dialog"]');
|
|
222
|
+
expect(dialogContent).not.toBeNull();
|
|
223
|
+
const scrollArea = dialogContent!.querySelector(CONTAINER_SELECTOR);
|
|
224
|
+
expect(scrollArea).not.toBeNull();
|
|
225
|
+
expect(scrollArea!.className).toContain('overflow-y-auto');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('uses container-query grid classes for multi-column flat fields', async () => {
|
|
229
|
+
// Mock schema with enough fields to trigger auto-layout 2-column
|
|
230
|
+
const manyFieldsSchema = {
|
|
231
|
+
name: 'contacts',
|
|
232
|
+
fields: {
|
|
233
|
+
name: { label: 'Name', type: 'text', required: true },
|
|
234
|
+
email: { label: 'Email', type: 'email', required: false },
|
|
235
|
+
phone: { label: 'Phone', type: 'phone', required: false },
|
|
236
|
+
company: { label: 'Company', type: 'text', required: false },
|
|
237
|
+
department: { label: 'Department', type: 'text', required: false },
|
|
238
|
+
title: { label: 'Title', type: 'text', required: false },
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
const mockDataSource = createMockDataSource();
|
|
242
|
+
mockDataSource.getObjectSchema.mockResolvedValue(manyFieldsSchema);
|
|
243
|
+
|
|
244
|
+
render(
|
|
245
|
+
<ModalForm
|
|
246
|
+
schema={{
|
|
247
|
+
type: 'object-form',
|
|
248
|
+
formType: 'modal',
|
|
249
|
+
objectName: 'contacts',
|
|
250
|
+
mode: 'create',
|
|
251
|
+
title: 'Create Contact',
|
|
252
|
+
open: true,
|
|
253
|
+
}}
|
|
254
|
+
dataSource={mockDataSource as any}
|
|
255
|
+
/>
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
await waitFor(() => {
|
|
259
|
+
expect(screen.getByText('Create Contact')).toBeInTheDocument();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// Wait for fields to render
|
|
263
|
+
await waitFor(() => {
|
|
264
|
+
expect(screen.getByText('Name')).toBeInTheDocument();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// The form field container should use container-query classes (@md:grid-cols-2)
|
|
268
|
+
// instead of viewport-based classes (md:grid-cols-2)
|
|
269
|
+
const dialogContent = document.querySelector('[role="dialog"]');
|
|
270
|
+
const containerEl = dialogContent!.querySelector(CONTAINER_SELECTOR);
|
|
271
|
+
expect(containerEl).not.toBeNull();
|
|
272
|
+
|
|
273
|
+
// Look for the grid container with @md:grid-cols-2
|
|
274
|
+
const gridEl = containerEl!.querySelector('[class*="@md:grid-cols-2"]');
|
|
275
|
+
expect(gridEl).not.toBeNull();
|
|
276
|
+
expect(gridEl!.className).toContain('@md:grid-cols-2');
|
|
277
|
+
// Should NOT use viewport-based md:grid-cols-2 (without @ prefix)
|
|
278
|
+
expect(gridEl!.className).not.toContain(' md:grid-cols-2');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('single-column forms do not get container grid override', async () => {
|
|
282
|
+
// Only 3 fields → auto-layout stays at 1 column
|
|
283
|
+
const fewFieldsSchema = {
|
|
284
|
+
name: 'notes',
|
|
285
|
+
fields: {
|
|
286
|
+
title: { label: 'Title', type: 'text', required: true },
|
|
287
|
+
body: { label: 'Body', type: 'textarea', required: false },
|
|
288
|
+
status: { label: 'Status', type: 'select', required: false, options: [{ value: 'draft', label: 'Draft' }] },
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
const mockDataSource = createMockDataSource();
|
|
292
|
+
mockDataSource.getObjectSchema.mockResolvedValue(fewFieldsSchema);
|
|
293
|
+
|
|
294
|
+
render(
|
|
295
|
+
<ModalForm
|
|
296
|
+
schema={{
|
|
297
|
+
type: 'object-form',
|
|
298
|
+
formType: 'modal',
|
|
299
|
+
objectName: 'notes',
|
|
300
|
+
mode: 'create',
|
|
301
|
+
title: 'Create Note',
|
|
302
|
+
open: true,
|
|
303
|
+
}}
|
|
304
|
+
dataSource={mockDataSource as any}
|
|
305
|
+
/>
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
await waitFor(() => {
|
|
309
|
+
expect(screen.getByText('Create Note')).toBeInTheDocument();
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
await waitFor(() => {
|
|
313
|
+
expect(screen.getByText('Title')).toBeInTheDocument();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// Single column form should not have @md:grid-cols-2
|
|
317
|
+
const dialogContent = document.querySelector('[role="dialog"]');
|
|
318
|
+
const containerEl = dialogContent!.querySelector(CONTAINER_SELECTOR);
|
|
319
|
+
expect(containerEl).not.toBeNull();
|
|
320
|
+
const gridEl = containerEl!.querySelector('[class*="@md:grid-cols"]');
|
|
321
|
+
expect(gridEl).toBeNull();
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
describe('ModalForm Sections — Modal Size Auto-Upgrade', () => {
|
|
326
|
+
it('auto-upgrades modal to lg when sections use 2-column layout', async () => {
|
|
327
|
+
const mockDataSource = createMockDataSource();
|
|
328
|
+
|
|
329
|
+
render(
|
|
330
|
+
<ModalForm
|
|
331
|
+
schema={{
|
|
332
|
+
type: 'object-form',
|
|
333
|
+
formType: 'modal',
|
|
334
|
+
objectName: 'events',
|
|
335
|
+
mode: 'create',
|
|
336
|
+
title: 'Create Task',
|
|
337
|
+
open: true,
|
|
338
|
+
sections: [
|
|
339
|
+
{
|
|
340
|
+
label: 'Task Information',
|
|
341
|
+
columns: 2,
|
|
342
|
+
fields: ['subject', 'start', 'end', 'location'],
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
label: 'Details',
|
|
346
|
+
columns: 1,
|
|
347
|
+
fields: ['description'],
|
|
348
|
+
},
|
|
349
|
+
],
|
|
350
|
+
}}
|
|
351
|
+
dataSource={mockDataSource as any}
|
|
352
|
+
/>
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
await waitFor(() => {
|
|
356
|
+
expect(screen.getByText('Create Task')).toBeInTheDocument();
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
// Dialog should auto-upgrade to lg (max-w-2xl) because sections have columns: 2
|
|
360
|
+
const dialogContent = document.querySelector('[role="dialog"]');
|
|
361
|
+
expect(dialogContent).not.toBeNull();
|
|
362
|
+
expect(dialogContent!.className).toContain('max-w-2xl');
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('keeps default size when all sections use 1-column layout', async () => {
|
|
366
|
+
const mockDataSource = createMockDataSource();
|
|
367
|
+
|
|
368
|
+
render(
|
|
369
|
+
<ModalForm
|
|
370
|
+
schema={{
|
|
371
|
+
type: 'object-form',
|
|
372
|
+
formType: 'modal',
|
|
373
|
+
objectName: 'events',
|
|
374
|
+
mode: 'create',
|
|
375
|
+
title: 'Create Task',
|
|
376
|
+
open: true,
|
|
377
|
+
sections: [
|
|
378
|
+
{
|
|
379
|
+
label: 'Basic Info',
|
|
380
|
+
columns: 1,
|
|
381
|
+
fields: ['subject', 'start'],
|
|
382
|
+
},
|
|
383
|
+
],
|
|
384
|
+
}}
|
|
385
|
+
dataSource={mockDataSource as any}
|
|
386
|
+
/>
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
await waitFor(() => {
|
|
390
|
+
expect(screen.getByText('Create Task')).toBeInTheDocument();
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// Dialog should remain at default size (max-w-lg)
|
|
394
|
+
const dialogContent = document.querySelector('[role="dialog"]');
|
|
395
|
+
expect(dialogContent).not.toBeNull();
|
|
396
|
+
expect(dialogContent!.className).toContain('max-w-lg');
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('respects explicit modalSize over section auto-upgrade', async () => {
|
|
400
|
+
const mockDataSource = createMockDataSource();
|
|
401
|
+
|
|
402
|
+
render(
|
|
403
|
+
<ModalForm
|
|
404
|
+
schema={{
|
|
405
|
+
type: 'object-form',
|
|
406
|
+
formType: 'modal',
|
|
407
|
+
objectName: 'events',
|
|
408
|
+
mode: 'create',
|
|
409
|
+
title: 'Create Task',
|
|
410
|
+
open: true,
|
|
411
|
+
modalSize: 'sm',
|
|
412
|
+
sections: [
|
|
413
|
+
{
|
|
414
|
+
label: 'Task Information',
|
|
415
|
+
columns: 2,
|
|
416
|
+
fields: ['subject', 'start', 'end', 'location'],
|
|
417
|
+
},
|
|
418
|
+
],
|
|
419
|
+
}}
|
|
420
|
+
dataSource={mockDataSource as any}
|
|
421
|
+
/>
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
await waitFor(() => {
|
|
425
|
+
expect(screen.getByText('Create Task')).toBeInTheDocument();
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
// Explicit modalSize: 'sm' should override section auto-upgrade
|
|
429
|
+
const dialogContent = document.querySelector('[role="dialog"]');
|
|
430
|
+
expect(dialogContent).not.toBeNull();
|
|
431
|
+
expect(dialogContent!.className).toContain('max-w-sm');
|
|
432
|
+
});
|
|
433
|
+
});
|
|
@@ -468,6 +468,202 @@ describe('ObjectForm routing to new variants', () => {
|
|
|
468
468
|
});
|
|
469
469
|
});
|
|
470
470
|
|
|
471
|
+
// ─── Auto-Layout Integration Tests ──────────────────────────────────────
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* These tests verify that ModalForm and DrawerForm apply applyAutoLayout
|
|
475
|
+
* when rendering flat fields (no sections), consistent with SimpleObjectForm.
|
|
476
|
+
*/
|
|
477
|
+
describe('ModalForm auto-layout integration', () => {
|
|
478
|
+
// Schema with 6 fields (>3) should trigger 2-column inference
|
|
479
|
+
const manyFieldsSchema = {
|
|
480
|
+
name: 'contacts',
|
|
481
|
+
fields: {
|
|
482
|
+
firstName: { label: 'First Name', type: 'text', required: true },
|
|
483
|
+
lastName: { label: 'Last Name', type: 'text', required: false },
|
|
484
|
+
email: { label: 'Email', type: 'email', required: true },
|
|
485
|
+
phone: { label: 'Phone', type: 'phone', required: false },
|
|
486
|
+
street: { label: 'Street', type: 'text', required: false },
|
|
487
|
+
city: { label: 'City', type: 'text', required: false },
|
|
488
|
+
},
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
it('applies auto-layout with 2 columns for 6 flat fields', async () => {
|
|
492
|
+
const mockDataSource = createMockDataSource();
|
|
493
|
+
mockDataSource.getObjectSchema.mockResolvedValue(manyFieldsSchema);
|
|
494
|
+
|
|
495
|
+
render(
|
|
496
|
+
<ModalForm
|
|
497
|
+
schema={{
|
|
498
|
+
type: 'object-form',
|
|
499
|
+
formType: 'modal',
|
|
500
|
+
objectName: 'contacts',
|
|
501
|
+
mode: 'create',
|
|
502
|
+
title: 'Auto Layout Modal',
|
|
503
|
+
open: true,
|
|
504
|
+
}}
|
|
505
|
+
dataSource={mockDataSource as any}
|
|
506
|
+
/>
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
await waitFor(() => {
|
|
510
|
+
expect(screen.getByText('Auto Layout Modal')).toBeInTheDocument();
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
// With 6 fields (>3), auto-layout should infer columns=2
|
|
514
|
+
// The form element receives columns="2" as an attribute
|
|
515
|
+
await waitFor(() => {
|
|
516
|
+
const formEl = document.querySelector('form[columns="2"]');
|
|
517
|
+
expect(formEl).not.toBeNull();
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it('does not apply multi-column for 3 or fewer fields', async () => {
|
|
522
|
+
const fewFieldsSchema = {
|
|
523
|
+
name: 'simple',
|
|
524
|
+
fields: {
|
|
525
|
+
firstName: { label: 'First Name', type: 'text', required: true },
|
|
526
|
+
lastName: { label: 'Last Name', type: 'text', required: false },
|
|
527
|
+
},
|
|
528
|
+
};
|
|
529
|
+
const mockDataSource = createMockDataSource();
|
|
530
|
+
mockDataSource.getObjectSchema.mockResolvedValue(fewFieldsSchema);
|
|
531
|
+
|
|
532
|
+
render(
|
|
533
|
+
<ModalForm
|
|
534
|
+
schema={{
|
|
535
|
+
type: 'object-form',
|
|
536
|
+
formType: 'modal',
|
|
537
|
+
objectName: 'simple',
|
|
538
|
+
mode: 'create',
|
|
539
|
+
title: 'Few Fields Modal',
|
|
540
|
+
open: true,
|
|
541
|
+
}}
|
|
542
|
+
dataSource={mockDataSource as any}
|
|
543
|
+
/>
|
|
544
|
+
);
|
|
545
|
+
|
|
546
|
+
await waitFor(() => {
|
|
547
|
+
expect(screen.getByText('Few Fields Modal')).toBeInTheDocument();
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
// With 2 fields (≤3), auto-layout should infer columns=1
|
|
551
|
+
await waitFor(() => {
|
|
552
|
+
const formEl = document.querySelector('form[columns="2"]');
|
|
553
|
+
expect(formEl).toBeNull();
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it('respects explicit columns override', async () => {
|
|
558
|
+
const mockDataSource = createMockDataSource();
|
|
559
|
+
mockDataSource.getObjectSchema.mockResolvedValue(manyFieldsSchema);
|
|
560
|
+
|
|
561
|
+
render(
|
|
562
|
+
<ModalForm
|
|
563
|
+
schema={{
|
|
564
|
+
type: 'object-form',
|
|
565
|
+
formType: 'modal',
|
|
566
|
+
objectName: 'contacts',
|
|
567
|
+
mode: 'create',
|
|
568
|
+
title: 'Explicit Columns Modal',
|
|
569
|
+
open: true,
|
|
570
|
+
columns: 3,
|
|
571
|
+
}}
|
|
572
|
+
dataSource={mockDataSource as any}
|
|
573
|
+
/>
|
|
574
|
+
);
|
|
575
|
+
|
|
576
|
+
await waitFor(() => {
|
|
577
|
+
expect(screen.getByText('Explicit Columns Modal')).toBeInTheDocument();
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
// User specified columns=3
|
|
581
|
+
await waitFor(() => {
|
|
582
|
+
const formEl = document.querySelector('form[columns="3"]');
|
|
583
|
+
expect(formEl).not.toBeNull();
|
|
584
|
+
});
|
|
585
|
+
});
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
describe('DrawerForm auto-layout integration', () => {
|
|
589
|
+
const manyFieldsSchema = {
|
|
590
|
+
name: 'contacts',
|
|
591
|
+
fields: {
|
|
592
|
+
firstName: { label: 'First Name', type: 'text', required: true },
|
|
593
|
+
lastName: { label: 'Last Name', type: 'text', required: false },
|
|
594
|
+
email: { label: 'Email', type: 'email', required: true },
|
|
595
|
+
phone: { label: 'Phone', type: 'phone', required: false },
|
|
596
|
+
street: { label: 'Street', type: 'text', required: false },
|
|
597
|
+
city: { label: 'City', type: 'text', required: false },
|
|
598
|
+
},
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
it('applies auto-layout with 2 columns for 6 flat fields', async () => {
|
|
602
|
+
const mockDataSource = createMockDataSource();
|
|
603
|
+
mockDataSource.getObjectSchema.mockResolvedValue(manyFieldsSchema);
|
|
604
|
+
|
|
605
|
+
render(
|
|
606
|
+
<DrawerForm
|
|
607
|
+
schema={{
|
|
608
|
+
type: 'object-form',
|
|
609
|
+
formType: 'drawer',
|
|
610
|
+
objectName: 'contacts',
|
|
611
|
+
mode: 'create',
|
|
612
|
+
title: 'Auto Layout Drawer',
|
|
613
|
+
open: true,
|
|
614
|
+
}}
|
|
615
|
+
dataSource={mockDataSource as any}
|
|
616
|
+
/>
|
|
617
|
+
);
|
|
618
|
+
|
|
619
|
+
await waitFor(() => {
|
|
620
|
+
expect(screen.getByText('Auto Layout Drawer')).toBeInTheDocument();
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
// With 6 fields (>3), auto-layout should infer columns=2
|
|
624
|
+
await waitFor(() => {
|
|
625
|
+
const formEl = document.querySelector('form[columns="2"]');
|
|
626
|
+
expect(formEl).not.toBeNull();
|
|
627
|
+
});
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
it('does not apply multi-column for 3 or fewer fields', async () => {
|
|
631
|
+
const fewFieldsSchema = {
|
|
632
|
+
name: 'simple',
|
|
633
|
+
fields: {
|
|
634
|
+
firstName: { label: 'First Name', type: 'text', required: true },
|
|
635
|
+
lastName: { label: 'Last Name', type: 'text', required: false },
|
|
636
|
+
},
|
|
637
|
+
};
|
|
638
|
+
const mockDataSource = createMockDataSource();
|
|
639
|
+
mockDataSource.getObjectSchema.mockResolvedValue(fewFieldsSchema);
|
|
640
|
+
|
|
641
|
+
render(
|
|
642
|
+
<DrawerForm
|
|
643
|
+
schema={{
|
|
644
|
+
type: 'object-form',
|
|
645
|
+
formType: 'drawer',
|
|
646
|
+
objectName: 'simple',
|
|
647
|
+
mode: 'create',
|
|
648
|
+
title: 'Few Fields Drawer',
|
|
649
|
+
open: true,
|
|
650
|
+
}}
|
|
651
|
+
dataSource={mockDataSource as any}
|
|
652
|
+
/>
|
|
653
|
+
);
|
|
654
|
+
|
|
655
|
+
await waitFor(() => {
|
|
656
|
+
expect(screen.getByText('Few Fields Drawer')).toBeInTheDocument();
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
// With 2 fields (≤3), should remain 1 column
|
|
660
|
+
await waitFor(() => {
|
|
661
|
+
const formEl = document.querySelector('form[columns="2"]');
|
|
662
|
+
expect(formEl).toBeNull();
|
|
663
|
+
});
|
|
664
|
+
});
|
|
665
|
+
});
|
|
666
|
+
|
|
471
667
|
// ─── Export Tests ────────────────────────────────────────────────────────
|
|
472
668
|
|
|
473
669
|
describe('New variant exports', () => {
|