@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.
@@ -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', () => {