-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathsectionedFormValues.test.tsx
More file actions
493 lines (432 loc) · 16.9 KB
/
Copy pathsectionedFormValues.test.tsx
File metadata and controls
493 lines (432 loc) · 16.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* A sectioned create/edit form must keep EVERY section's input (#2153, #2959).
*
* The explicit-`sections` path used to render one SchemaRenderer — i.e. one
* react-hook-form instance and one `<form>` element — PER section, all sharing
* the same `formId`. Two independent failures followed:
*
* 1. the footer submit button (`form={formId}`) can only be associated with the
* FIRST of those forms, so section 2+ never reached the payload; and
* 2. in the `tabbed` variant Radix unmounted the inactive panel, destroying
* that tab's form state outright — so the reported HotCRM flow (fill tab 1,
* submit, server rejects a required field on tab 3, fill tab 3, submit
* again) came back with EVERY earlier value missing.
*
* These tests pin the contract that fixes both: one `<form>` for all sections,
* with the tab panels force-mounted so a tab the user left keeps its values AND
* its validation.
*
* `split` is the same defect in a different layout: its two resizable panels
* each held their own `<form>`, so submitting from one panel's action bar
* dropped everything typed in the other and no condition could see across the
* divider. Its panels are covered at the bottom of this file.
*/
import React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
import { registerAllFields } from '@object-ui/fields';
import { ModalForm } from './ModalForm';
import { TabbedForm } from './TabbedForm';
import { SplitForm } from './SplitForm';
import { ObjectForm } from './ObjectForm';
registerAllFields();
const makeDataSource = () => ({
getObjectSchema: vi.fn().mockResolvedValue({
name: 'case',
fields: {
subject: { type: 'text', label: 'Subject' },
status: { type: 'text', label: 'Status' },
priority: { type: 'text', label: 'Priority' },
// The field that made the reported flow destructive: required, and parked
// on the last tab.
description: { type: 'text', label: 'Description', required: true },
},
}),
create: vi.fn().mockResolvedValue({ id: 'case-1' }),
update: vi.fn(),
findOne: vi.fn(),
});
const SECTIONS = [
{ name: 'basics', label: 'Basics', fields: ['subject'] },
{ name: 'triage', label: 'Triage', fields: ['status', 'priority'] },
{ name: 'detail', label: 'Detail', fields: ['description'] },
];
/** Type into a field by its metadata locator, wherever it is laid out. */
const fill = (name: string, value: string) => {
const input = document.body.querySelector<HTMLInputElement>(`[data-field="${name}"] input`);
if (!input) throw new Error(`field not rendered: ${name}`);
fireEvent.change(input, { target: { value } });
};
const valueOf = (name: string) =>
document.body.querySelector<HTMLInputElement>(`[data-field="${name}"] input`)?.value;
const tab = (label: string) => screen.getByRole('tab', { name: new RegExp(label) });
const submitForm = () => {
const form = document.body.querySelector('form');
if (!form) throw new Error('no <form> rendered');
// The footer button is associated by `form={formId}`; submitting the form
// element directly exercises the same handler without depending on the DOM
// implementation's support for out-of-form submit buttons.
fireEvent.submit(form);
};
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
cleanup();
});
describe('ModalForm tabbed sections — per-tab form state (#2959)', () => {
it('submits every tab’s values after the user moves between tabs', async () => {
const dataSource = makeDataSource();
render(
<ModalForm
schema={{
type: 'object-form',
formType: 'modal',
objectName: 'case',
mode: 'create',
title: 'New Case',
contentLayout: 'tabbed',
sections: SECTIONS,
}}
dataSource={dataSource as any}
/>,
);
await waitFor(() => expect(screen.getByTestId('modal-form-footer')).toBeTruthy());
// ONE form for all three sections — the shared-`formId` duplicate <form>
// elements are what stranded section 2+ outside the submit (#2153).
expect(document.body.querySelectorAll('form')).toHaveLength(1);
// Fill tab 1, walk to the last tab, fill it there.
fill('subject', 'Printer on fire');
fireEvent.click(tab('Triage'));
fill('status', 'open');
fill('priority', 'high');
fireEvent.click(tab('Detail'));
fill('description', 'Smoke coming out of tray 2');
submitForm();
await waitFor(() => expect(dataSource.create).toHaveBeenCalledTimes(1));
expect(dataSource.create.mock.calls[0][1]).toMatchObject({
subject: 'Printer on fire',
status: 'open',
priority: 'high',
description: 'Smoke coming out of tray 2',
});
});
it('keeps a tab’s input when the user leaves and comes back', async () => {
render(
<ModalForm
schema={{
type: 'object-form',
formType: 'modal',
objectName: 'case',
mode: 'create',
contentLayout: 'tabbed',
sections: SECTIONS,
}}
dataSource={makeDataSource() as any}
/>,
);
await waitFor(() => expect(screen.getByTestId('modal-form-footer')).toBeTruthy());
fill('subject', 'Kept across tabs');
fireEvent.click(tab('Triage'));
fireEvent.click(tab('Basics'));
// Radix used to unmount the inactive panel, which reset the field to its
// defaultValue on the way back.
expect(valueOf('subject')).toBe('Kept across tabs');
});
it('blocks the submit and activates the tab holding a missing required field', async () => {
const dataSource = makeDataSource();
render(
<ModalForm
schema={{
type: 'object-form',
formType: 'modal',
objectName: 'case',
mode: 'create',
contentLayout: 'tabbed',
sections: SECTIONS,
}}
dataSource={dataSource as any}
/>,
);
await waitFor(() => expect(screen.getByTestId('modal-form-footer')).toBeTruthy());
// Only tab 1 is filled; `description` (tab 3) is required and empty. An
// unmounted field is skipped by react-hook-form, so this used to reach the
// server and come back as a 400 that named no tab.
fill('subject', 'Only the first tab');
submitForm();
await waitFor(() => expect(tab('Detail')).toHaveAttribute('data-state', 'active'));
expect(dataSource.create).not.toHaveBeenCalled();
// The offending tab is marked, so the failure is discoverable from any tab.
expect(tab('Detail')).toHaveAttribute('data-error', 'true');
expect(tab('Basics')).not.toHaveAttribute('data-error');
});
});
describe('ModalForm stacked sections — one form for all sections (#2153)', () => {
it('submits the 2nd+ section’s values', async () => {
const dataSource = makeDataSource();
render(
<ModalForm
schema={{
type: 'object-form',
formType: 'modal',
objectName: 'case',
mode: 'create',
sections: SECTIONS,
}}
dataSource={dataSource as any}
/>,
);
await waitFor(() => expect(screen.getByTestId('modal-form-footer')).toBeTruthy());
expect(document.body.querySelectorAll('form')).toHaveLength(1);
fill('subject', 'S1');
fill('status', 'S2-status');
fill('priority', 'S2-priority');
fill('description', 'S3');
submitForm();
await waitFor(() => expect(dataSource.create).toHaveBeenCalledTimes(1));
expect(dataSource.create.mock.calls[0][1]).toMatchObject({
subject: 'S1',
status: 'S2-status',
priority: 'S2-priority',
description: 'S3',
});
});
it('renders each section’s header and description inline', async () => {
render(
<ModalForm
schema={{
type: 'object-form',
formType: 'modal',
objectName: 'case',
mode: 'create',
sections: [
{ name: 'basics', label: 'Basics', description: 'Who is asking', fields: ['subject'] },
{ name: 'triage', label: 'Triage', fields: ['status'] },
],
}}
dataSource={makeDataSource() as any}
/>,
);
expect(await screen.findByText('Basics')).toBeTruthy();
expect(screen.getByText('Triage')).toBeTruthy();
// A section's authored blurb survives the move to inline headers.
expect(screen.getByText('Who is asking')).toBeTruthy();
});
});
describe('SplitForm — one form across BOTH panels (#2153)', () => {
it('submits the values typed in both panels', async () => {
const dataSource = makeDataSource();
render(
<SplitForm
schema={{
type: 'object-form',
formType: 'split',
objectName: 'case',
mode: 'create',
// Section 1 lands in the left panel, 2+ in the right one.
sections: SECTIONS,
}}
dataSource={dataSource as any}
/>,
);
await waitFor(() => expect(document.body.querySelector('form')).toBeTruthy());
fill('subject', 'Printer on fire'); // left panel
fill('status', 'open'); // right panel
fill('priority', 'high'); // right panel
fill('description', 'Smoke coming out of tray 2'); // right panel
submitForm();
await waitFor(() => expect(dataSource.create).toHaveBeenCalledTimes(1));
expect(dataSource.create.mock.calls[0][1]).toMatchObject({
subject: 'Printer on fire',
status: 'open',
priority: 'high',
description: 'Smoke coming out of tray 2',
});
// ONE form spanning both panels. A `<form>` per panel (per SECTION, in
// fact) is what stranded the other panel's input outside the submit: each
// one owned an isolated react-hook-form instance, so the payload above
// arrived holding only the section whose action bar was clicked.
expect(document.body.querySelectorAll('form')).toHaveLength(1);
});
it('keeps every section’s header, in the panel that owns it', async () => {
render(
<SplitForm
schema={{
type: 'object-form',
formType: 'split',
objectName: 'case',
mode: 'create',
sections: [
{ name: 'basics', label: 'Basics', description: 'Who is asking', fields: ['subject'] },
{ name: 'triage', label: 'Triage', fields: ['status'] },
],
}}
dataSource={makeDataSource() as any}
/>,
);
// Headers survive the move to inline `section-divider` rows...
expect(await screen.findByText('Basics')).toBeTruthy();
expect(screen.getByText('Who is asking')).toBeTruthy();
expect(screen.getByText('Triage')).toBeTruthy();
// ...and each stays on its own side of the split.
const panes = document.body.querySelectorAll('[data-testid^="form-pane:"]');
expect(panes).toHaveLength(2);
expect(panes[0].textContent).toContain('Basics');
expect(panes[0].querySelector('[data-field="subject"]')).toBeTruthy();
expect(panes[1].textContent).toContain('Triage');
expect(panes[1].querySelector('[data-field="status"]')).toBeTruthy();
});
it('evaluates a right-panel field’s condition against a left-panel field', async () => {
const dataSource = {
...makeDataSource(),
getObjectSchema: vi.fn().mockResolvedValue({
name: 'case',
fields: {
subject: { type: 'text', label: 'Subject' },
status: { type: 'text', label: 'Status' },
// Cross-panel condition: it watches a field in the OTHER panel, which
// only one shared react-hook-form instance can see.
escalation: {
type: 'text',
label: 'Escalation',
visibleWhen: "record.subject == 'urgent'",
},
},
}),
};
render(
<SplitForm
schema={{
type: 'object-form',
formType: 'split',
objectName: 'case',
mode: 'create',
sections: [
{ name: 'basics', label: 'Basics', fields: ['subject'] },
{ name: 'triage', label: 'Triage', fields: ['status', 'escalation'] },
],
}}
dataSource={dataSource as any}
/>,
);
await waitFor(() => expect(valueOf('subject')).toBe(''));
expect(document.body.querySelector('[data-field="escalation"]')).toBeNull();
fill('subject', 'urgent');
await waitFor(() =>
expect(document.body.querySelector('[data-field="escalation"]')).toBeTruthy(),
);
});
});
describe('SplitForm — explicit `section.pane` placement (spec FormSection.pane)', () => {
const paneEl = (key: string) =>
document.body.querySelector(`[data-testid="form-pane:${key}"]`)!;
const PANED_SECTIONS = [
// Declared order deliberately disagrees with the panes: the SECOND and
// THIRD sections are the primary pane. Impossible to express before —
// the renderer hardcoded first-section-left / rest-right.
{ name: 'triage', label: 'Triage', pane: 'secondary' as const, fields: ['status'] },
{ name: 'basics', label: 'Basics', pane: 'primary' as const, fields: ['subject'] },
{ name: 'detail', label: 'Detail', pane: 'primary' as const, fields: ['description'] },
];
it('groups sections by their declared pane, not by array position', async () => {
render(
<SplitForm
schema={{
type: 'object-form',
formType: 'split',
objectName: 'case',
mode: 'create',
sections: PANED_SECTIONS,
}}
dataSource={makeDataSource() as any}
/>,
);
await waitFor(() => expect(document.body.querySelector('form')).toBeTruthy());
// Two sections side by side in the primary pane…
expect(paneEl('primary').querySelector('[data-field="subject"]')).toBeTruthy();
expect(paneEl('primary').querySelector('[data-field="description"]')).toBeTruthy();
// …and the first-declared section sits in the secondary pane.
expect(paneEl('secondary').querySelector('[data-field="status"]')).toBeTruthy();
expect(paneEl('secondary').querySelector('[data-field="subject"]')).toBeNull();
});
it('reordering sections does not move them across the divider', async () => {
render(
<SplitForm
schema={{
type: 'object-form',
formType: 'split',
objectName: 'case',
mode: 'create',
// Same sections, reversed — the hazard the key exists to kill: with
// the positional rule this reorder silently relaid the whole form.
sections: [...PANED_SECTIONS].reverse(),
}}
dataSource={makeDataSource() as any}
/>,
);
await waitFor(() => expect(document.body.querySelector('form')).toBeTruthy());
expect(paneEl('primary').querySelector('[data-field="subject"]')).toBeTruthy();
expect(paneEl('primary').querySelector('[data-field="description"]')).toBeTruthy();
expect(paneEl('secondary').querySelector('[data-field="status"]')).toBeTruthy();
});
it('ObjectForm forwards `pane` through its split mapping', async () => {
// The dispatch mapping rebuilds sections key by key — a key it does not
// copy is silently dropped (how `visibleOn` once vanished). Pin the copy.
render(
<ObjectForm
schema={{
type: 'object-form',
formType: 'split',
objectName: 'case',
mode: 'create',
sections: [
{ name: 'triage', label: 'Triage', pane: 'secondary', fields: ['status'] },
{ name: 'basics', label: 'Basics', pane: 'primary', fields: ['subject'] },
],
} as any}
dataSource={makeDataSource() as any}
/>,
);
await waitFor(() => expect(document.body.querySelector('form')).toBeTruthy());
expect(paneEl('primary').querySelector('[data-field="subject"]')).toBeTruthy();
expect(paneEl('secondary').querySelector('[data-field="status"]')).toBeTruthy();
});
});
describe('TabbedForm — one form for all tabs (#2959)', () => {
it('submits every tab’s values after the user moves between tabs', async () => {
const dataSource = makeDataSource();
render(
<TabbedForm
schema={{
type: 'object-form',
formType: 'tabbed',
objectName: 'case',
mode: 'create',
sections: SECTIONS,
}}
dataSource={dataSource as any}
/>,
);
await waitFor(() => expect(document.body.querySelector('form')).toBeTruthy());
expect(document.body.querySelectorAll('form')).toHaveLength(1);
fill('subject', 'T1');
fireEvent.click(tab('Triage'));
fill('status', 'T2');
fireEvent.click(tab('Detail'));
fill('description', 'T3');
submitForm();
await waitFor(() => expect(dataSource.create).toHaveBeenCalledTimes(1));
expect(dataSource.create.mock.calls[0][1]).toMatchObject({
subject: 'T1',
status: 'T2',
description: 'T3',
});
});
});