@remoteoss/json-schema-form 0.1.0-beta.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.
@@ -0,0 +1,3343 @@
1
+ import isNil from 'lodash/isNil';
2
+ import omitBy from 'lodash/omitBy';
3
+ import { object } from 'yup';
4
+
5
+ import { createHeadlessForm } from '../createHeadlessForm';
6
+
7
+ import {
8
+ JSONSchemaBuilder,
9
+ schemaInputTypeText,
10
+ schemaInputTypeRadioDeprecated,
11
+ schemaInputTypeRadio,
12
+ schemaInputTypeRadioRequiredAndOptional,
13
+ schemaInputTypeSelectSoloDeprecated,
14
+ schemaInputTypeSelectSolo,
15
+ schemaInputTypeSelectMultipleDeprecated,
16
+ schemaInputTypeSelectMultiple,
17
+ schemaInputTypeSelectMultipleOptional,
18
+ schemaInputTypeNumber,
19
+ schemaInputTypeDate,
20
+ schemaInputTypeEmail,
21
+ schemaInputWithStatement,
22
+ schemaInputTypeCheckbox,
23
+ schemaInputTypeCheckboxBooleans,
24
+ schemaWithOrderKeyword,
25
+ schemaWithPositionDeprecated,
26
+ schemaDynamicValidationConst,
27
+ schemaDynamicValidationMinimumMaximum,
28
+ schemaDynamicValidationMinLengthMaxLength,
29
+ schemaDynamicValidationContains,
30
+ schemaAnyOfValidation,
31
+ schemaWithoutInputTypes,
32
+ schemaWithoutTypes,
33
+ mockFileInput,
34
+ mockRadioCardInput,
35
+ mockRadioCardExpandableInput,
36
+ mockTextInput,
37
+ mockTextInputDeprecated,
38
+ mockNumberInput,
39
+ mockNumberInputWithPercentageAndCustomRange,
40
+ mockTextPatternInput,
41
+ mockTextMaxLengthInput,
42
+ mockFieldset,
43
+ mockNestedFieldset,
44
+ mockGroupArrayInput,
45
+ schemaFieldsetScopedCondition,
46
+ schemaWithConditionalPresentationProperties,
47
+ schemaWithConditionalReadOnlyProperty,
48
+ schemaWithWrongConditional,
49
+ schemaWithConditionalAcknowledgementProperty,
50
+ schemaInputTypeNumberWithPercentage,
51
+ schemaForErrorMessageSpecificity,
52
+ jsfConfigForErrorMessageSpecificity,
53
+ } from './helpers';
54
+
55
+ function buildJSONSchemaInput({ presentationFields, inputFields = {}, required }) {
56
+ return {
57
+ type: 'object',
58
+ properties: {
59
+ test: {
60
+ description: 'Test description',
61
+ presentation: {
62
+ ...presentationFields,
63
+ },
64
+ title: 'Test title',
65
+ type: 'number',
66
+ ...inputFields,
67
+ },
68
+ },
69
+ required: required ? ['test'] : [],
70
+ };
71
+ }
72
+
73
+ function friendlyError({ formErrors }) {
74
+ // destruct the formErrors directly
75
+ return formErrors;
76
+ }
77
+
78
+ beforeEach(() => {
79
+ jest.spyOn(console, 'error').mockImplementation(() => {});
80
+ });
81
+
82
+ afterEach(() => {
83
+ expect(console.error).not.toHaveBeenCalled();
84
+ console.error.mockRestore();
85
+ });
86
+
87
+ describe('createHeadlessForm', () => {
88
+ it('returns empty result given no schema', () => {
89
+ const result = createHeadlessForm();
90
+
91
+ expect(result).toMatchObject({
92
+ fields: [],
93
+ });
94
+ expect(result.isError).toBe(false);
95
+ expect(result.error).toBeFalsy();
96
+ });
97
+
98
+ it('returns an error given invalid schema', () => {
99
+ const result = createHeadlessForm({ foo: 1 });
100
+
101
+ expect(result.fields).toHaveLength(0);
102
+ expect(result.isError).toBe(true);
103
+
104
+ expect(console.error).toHaveBeenCalledWith(`JSON Schema invalid!`, expect.any(Error));
105
+ console.error.mockClear();
106
+
107
+ expect(result.error.message).toBe(`Cannot convert undefined or null to object`);
108
+ });
109
+
110
+ describe('field support fallback', () => {
111
+ it('sets type from presentation.inputType', () => {
112
+ const { fields } = createHeadlessForm({
113
+ properties: {
114
+ age: {
115
+ title: 'Age',
116
+ presentation: { inputType: 'number' },
117
+ type: 'number',
118
+ },
119
+ starting_time: {
120
+ title: 'Starting time',
121
+ presentation: {
122
+ inputType: 'hour', // Arbitrary types are accepted
123
+ set: 'AM', // And even any arbitrary presentation keys
124
+ },
125
+ type: 'string',
126
+ },
127
+ },
128
+ });
129
+
130
+ const { schema: yupSchema1, ...fieldAge } = omitBy(fields[0], isNil);
131
+ const { schema: yupSchema2, ...fieldTime } = omitBy(fields[1], isNil);
132
+
133
+ expect(yupSchema1).toEqual(expect.any(Object));
134
+ expect(fieldAge).toMatchObject({
135
+ inputType: 'number',
136
+ jsonType: 'number',
137
+ type: 'number',
138
+ });
139
+
140
+ expect(yupSchema1).toEqual(expect.any(Object));
141
+ expect(fieldTime).toMatchObject({
142
+ inputType: 'hour',
143
+ jsonType: 'string',
144
+ name: 'starting_time',
145
+ type: 'hour',
146
+ set: 'AM',
147
+ });
148
+ });
149
+
150
+ it('fails given a json schema without inputType', () => {
151
+ const { fields, error } = createHeadlessForm({
152
+ properties: {
153
+ test: { type: 'string' },
154
+ },
155
+ });
156
+
157
+ expect(fields).toHaveLength(0);
158
+ expect(error.message).toContain('Strict error: Missing inputType to field "test"');
159
+
160
+ expect(console.error).toHaveBeenCalledWith(`JSON Schema invalid!`, expect.any(Error));
161
+ console.error.mockClear();
162
+ });
163
+
164
+ function extractTypeOnly(listOfFields) {
165
+ const list = Array.isArray(listOfFields) ? listOfFields : listOfFields?.(); // handle fieldset + group-array
166
+ return list?.map(
167
+ ({ name, type, inputType, jsonType, label, options, fields: nestedFields }) => {
168
+ return omitBy(
169
+ {
170
+ name,
171
+ type, // @deprecated
172
+ inputType,
173
+ jsonType,
174
+ label,
175
+ options,
176
+ fields: extractTypeOnly(nestedFields),
177
+ },
178
+ isNil
179
+ );
180
+ }
181
+ );
182
+ }
183
+
184
+ it('given a json schema without inputType, sets type based on json type (when strictInputType:false)', () => {
185
+ const { fields } = createHeadlessForm(schemaWithoutInputTypes, {
186
+ strictInputType: false,
187
+ });
188
+
189
+ const fieldsByNameAndType = extractTypeOnly(fields);
190
+ expect(fieldsByNameAndType).toMatchInlineSnapshot(`
191
+ [
192
+ {
193
+ "inputType": "text",
194
+ "jsonType": "string",
195
+ "label": "A string -> text",
196
+ "name": "a_string",
197
+ "type": "text",
198
+ },
199
+ {
200
+ "inputType": "radio",
201
+ "jsonType": "string",
202
+ "label": "A string with oneOf -> radio",
203
+ "name": "a_string_oneOf",
204
+ "options": [
205
+ {
206
+ "label": "Yes",
207
+ "value": "yes",
208
+ },
209
+ {
210
+ "label": "No",
211
+ "value": "no",
212
+ },
213
+ ],
214
+ "type": "radio",
215
+ },
216
+ {
217
+ "inputType": "email",
218
+ "jsonType": "string",
219
+ "label": "A string with format:email -> email",
220
+ "name": "a_string_email",
221
+ "type": "email",
222
+ },
223
+ {
224
+ "inputType": "date",
225
+ "jsonType": "string",
226
+ "label": "A string with format:email -> date",
227
+ "name": "a_string_date",
228
+ "type": "date",
229
+ },
230
+ {
231
+ "inputType": "file",
232
+ "jsonType": "string",
233
+ "label": "A string with format:data-url -> file",
234
+ "name": "a_string_file",
235
+ "type": "file",
236
+ },
237
+ {
238
+ "inputType": "number",
239
+ "jsonType": "number",
240
+ "label": "A number -> number",
241
+ "name": "a_number",
242
+ "type": "number",
243
+ },
244
+ {
245
+ "inputType": "number",
246
+ "jsonType": "integer",
247
+ "label": "A integer -> number",
248
+ "name": "a_integer",
249
+ "type": "number",
250
+ },
251
+ {
252
+ "inputType": "checkbox",
253
+ "jsonType": "boolean",
254
+ "label": "A boolean -> checkbox",
255
+ "name": "a_boolean",
256
+ "type": "checkbox",
257
+ },
258
+ {
259
+ "fields": [
260
+ {
261
+ "inputType": "text",
262
+ "jsonType": "string",
263
+ "name": "foo",
264
+ "type": "text",
265
+ },
266
+ {
267
+ "inputType": "text",
268
+ "jsonType": "string",
269
+ "name": "bar",
270
+ "type": "text",
271
+ },
272
+ ],
273
+ "inputType": "fieldset",
274
+ "jsonType": "object",
275
+ "label": "An object -> fieldset",
276
+ "name": "a_object",
277
+ "type": "fieldset",
278
+ },
279
+ {
280
+ "inputType": "select",
281
+ "jsonType": "array",
282
+ "label": "An array items.anyOf -> select",
283
+ "name": "a_array_items",
284
+ "options": [
285
+ {
286
+ "label": "Chrome",
287
+ "value": "chr",
288
+ },
289
+ {
290
+ "label": "Firefox",
291
+ "value": "ff",
292
+ },
293
+ {
294
+ "label": "Internet Explorer",
295
+ "value": "ie",
296
+ },
297
+ ],
298
+ "type": "select",
299
+ },
300
+ {
301
+ "fields": [
302
+ {
303
+ "inputType": "text",
304
+ "jsonType": "string",
305
+ "label": "Role",
306
+ "name": "role",
307
+ "type": "text",
308
+ },
309
+ {
310
+ "inputType": "number",
311
+ "jsonType": "number",
312
+ "label": "Years",
313
+ "name": "years",
314
+ "type": "number",
315
+ },
316
+ ],
317
+ "inputType": "group-array",
318
+ "jsonType": "array",
319
+ "label": "An array items.properties -> group-array",
320
+ "name": "a_array_properties",
321
+ "type": "group-array",
322
+ },
323
+ {
324
+ "inputType": "text",
325
+ "label": "A void -> text",
326
+ "name": "a_void",
327
+ "type": "text",
328
+ },
329
+ ]
330
+ `);
331
+ });
332
+
333
+ it('given a json schema without json type, sets type based on structure (when strictInputType:false)', () => {
334
+ const { fields } = createHeadlessForm(schemaWithoutTypes, {
335
+ strictInputType: false,
336
+ });
337
+
338
+ const fieldsByNameAndType = extractTypeOnly(fields);
339
+ expect(fieldsByNameAndType).toMatchInlineSnapshot(`
340
+ [
341
+ {
342
+ "inputType": "text",
343
+ "label": "Default -> text",
344
+ "name": "default",
345
+ "type": "text",
346
+ },
347
+ {
348
+ "inputType": "radio",
349
+ "label": "With oneOf -> radio",
350
+ "name": "with_oneOf",
351
+ "options": [
352
+ {
353
+ "label": "Yes",
354
+ "value": "yes",
355
+ },
356
+ {
357
+ "label": "No",
358
+ "value": "no",
359
+ },
360
+ ],
361
+ "type": "radio",
362
+ },
363
+ {
364
+ "inputType": "email",
365
+ "label": "With format:email -> email",
366
+ "name": "with_email",
367
+ "type": "email",
368
+ },
369
+ {
370
+ "inputType": "select",
371
+ "label": "With properties -> fieldset",
372
+ "name": "with_object",
373
+ "type": "select",
374
+ },
375
+ {
376
+ "inputType": "text",
377
+ "label": "With items.anyOf -> select",
378
+ "name": "with_items_anyOf",
379
+ "options": [
380
+ {
381
+ "label": "Chrome",
382
+ "value": "chr",
383
+ },
384
+ {
385
+ "label": "Firefox",
386
+ "value": "ff",
387
+ },
388
+ {
389
+ "label": "Internet Explorer",
390
+ "value": "ie",
391
+ },
392
+ ],
393
+ "type": "text",
394
+ },
395
+ {
396
+ "fields": [
397
+ {
398
+ "inputType": "text",
399
+ "label": "Role",
400
+ "name": "role",
401
+ "type": "text",
402
+ },
403
+ {
404
+ "inputType": "text",
405
+ "label": "Years",
406
+ "name": "years",
407
+ "type": "text",
408
+ },
409
+ ],
410
+ "inputType": "group-array",
411
+ "label": "With items.properties -> group-array",
412
+ "name": "with_items_properties",
413
+ "type": "group-array",
414
+ },
415
+ ]
416
+ `);
417
+ });
418
+ });
419
+
420
+ describe('field support', () => {
421
+ it('support "text" field type', () => {
422
+ const { fields } = createHeadlessForm(schemaInputTypeText);
423
+
424
+ expect(fields[0]).toMatchObject({
425
+ description: 'The number of your national identification (max 10 digits)',
426
+ label: 'ID number',
427
+ name: 'id_number',
428
+ required: true,
429
+ schema: expect.any(Object),
430
+ inputType: 'text',
431
+ jsonType: 'string',
432
+ maskSecret: 2,
433
+ maxLength: 10,
434
+ isVisible: true,
435
+ });
436
+
437
+ const fieldValidator = fields[0].schema;
438
+ expect(fieldValidator.isValidSync('CI007')).toBe(true);
439
+ expect(fieldValidator.isValidSync(true)).toBe(true); // @BUG RMT-446 - cannot be a bool
440
+ expect(fieldValidator.isValidSync(1)).toBe(true); // @BUG RMT-446 - cannot be a number
441
+ expect(fieldValidator.isValidSync(0)).toBe(true); // @BUG RMT-446 - cannot be a number
442
+
443
+ expect(() => fieldValidator.validateSync('')).toThrowError('Required field');
444
+ });
445
+
446
+ it('supports both root level "description" and "x-jsf-presentation.description"', () => {
447
+ const resultsWithRootDescription = createHeadlessForm(
448
+ JSONSchemaBuilder()
449
+ .addInput({
450
+ id_number: mockTextInput,
451
+ })
452
+ .setRequiredFields(['id_number'])
453
+ .build()
454
+ );
455
+
456
+ expect(resultsWithRootDescription.fields[0].description).toMatch(
457
+ /the number of your national/i
458
+ );
459
+
460
+ const resultsWithPresentationDescription = createHeadlessForm(
461
+ JSONSchemaBuilder()
462
+ .addInput({
463
+ id_number: {
464
+ ...mockTextInput,
465
+ 'x-jsf-presentation': {
466
+ inputType: 'text',
467
+ maskSecret: 2,
468
+ // should override the root level description
469
+ description: 'a different description with <span>markup</span>',
470
+ },
471
+ },
472
+ })
473
+ .setRequiredFields(['id_number'])
474
+ .build()
475
+ );
476
+
477
+ expect(resultsWithPresentationDescription.fields[0].description).toMatch(
478
+ /a different description /i
479
+ );
480
+ });
481
+
482
+ it('supports both root level "description" and "presentation.description" (deprecated)', () => {
483
+ const resultsWithRootDescription = createHeadlessForm(
484
+ JSONSchemaBuilder()
485
+ .addInput({
486
+ id_number: mockTextInputDeprecated,
487
+ })
488
+ .setRequiredFields(['id_number'])
489
+ .build()
490
+ );
491
+
492
+ expect(resultsWithRootDescription.fields[0].description).toMatch(
493
+ /the number of your national/i
494
+ );
495
+
496
+ const resultsWithPresentationDescription = createHeadlessForm(
497
+ JSONSchemaBuilder()
498
+ .addInput({
499
+ id_number: {
500
+ ...mockTextInputDeprecated,
501
+ presentation: {
502
+ inputType: 'text',
503
+ maskSecret: 2,
504
+ // should override the root level description
505
+ description: 'a different description with <span>markup</span>',
506
+ },
507
+ },
508
+ })
509
+ .setRequiredFields(['id_number'])
510
+ .build()
511
+ );
512
+
513
+ expect(resultsWithPresentationDescription.fields[0].description).toMatch(
514
+ /a different description /i
515
+ );
516
+ });
517
+
518
+ it('support "select" field type @deprecated', () => {
519
+ const result = createHeadlessForm(schemaInputTypeSelectSoloDeprecated);
520
+
521
+ expect(result).toMatchObject({
522
+ fields: [
523
+ {
524
+ description: 'Life Insurance',
525
+ label: 'Benefits (solo)',
526
+ name: 'benefits',
527
+ placeholder: 'Select...',
528
+ type: 'select',
529
+ options: [
530
+ {
531
+ label: 'Medical Insurance',
532
+ value: 'Medical Insurance',
533
+ },
534
+ {
535
+ label: 'Health Insurance',
536
+ value: 'Health Insurance',
537
+ },
538
+ {
539
+ label: 'Travel Bonus',
540
+ value: 'Travel Bonus',
541
+ },
542
+ ],
543
+ },
544
+ ],
545
+ });
546
+ });
547
+ it('support "select" field type', () => {
548
+ const result = createHeadlessForm(schemaInputTypeSelectSolo);
549
+
550
+ const fieldSelect = result.fields[0];
551
+ expect(fieldSelect).toMatchObject({
552
+ name: 'browsers',
553
+ label: 'Browsers (solo)',
554
+ description: 'This solo select also includes a disabled option.',
555
+ options: [
556
+ {
557
+ value: 'chr',
558
+ label: 'Chrome',
559
+ },
560
+ {
561
+ value: 'ff',
562
+ label: 'Firefox',
563
+ },
564
+ {
565
+ value: 'ie',
566
+ label: 'Internet Explorer',
567
+ disabled: true,
568
+ },
569
+ ],
570
+ });
571
+
572
+ expect(fieldSelect).not.toHaveProperty('multiple');
573
+ });
574
+
575
+ it('supports "select" field type with multiple options @deprecated', () => {
576
+ const result = createHeadlessForm(schemaInputTypeSelectMultipleDeprecated);
577
+ expect(result).toMatchObject({
578
+ fields: [
579
+ {
580
+ description: 'Life Insurance',
581
+ label: 'Benefits (multiple)',
582
+ name: 'benefits_multi',
583
+ placeholder: 'Select...',
584
+ type: 'select',
585
+ options: [
586
+ {
587
+ label: 'Medical Insurance',
588
+ value: 'Medical Insurance',
589
+ },
590
+ {
591
+ label: 'Health Insurance',
592
+ value: 'Health Insurance',
593
+ },
594
+ {
595
+ label: 'Travel Bonus',
596
+ value: 'Travel Bonus',
597
+ },
598
+ ],
599
+ multiple: true,
600
+ },
601
+ ],
602
+ });
603
+ });
604
+ it('supports "select" field type with multiple options', () => {
605
+ const result = createHeadlessForm(schemaInputTypeSelectMultiple);
606
+ expect(result).toMatchObject({
607
+ fields: [
608
+ {
609
+ name: 'browsers_multi',
610
+ label: 'Browsers (multiple)',
611
+ description: 'This multi-select also includes a disabled option.',
612
+ options: [
613
+ {
614
+ value: 'chr',
615
+ label: 'Chrome',
616
+ },
617
+ {
618
+ value: 'ff',
619
+ label: 'Firefox',
620
+ },
621
+ {
622
+ value: 'ie',
623
+ label: 'Internet Explorer',
624
+ disabled: true,
625
+ },
626
+ ],
627
+ multiple: true,
628
+ },
629
+ ],
630
+ });
631
+ });
632
+
633
+ it('supports "select" field type with multiple options and optional', () => {
634
+ const result = createHeadlessForm(schemaInputTypeSelectMultipleOptional);
635
+ expect(result).toMatchObject({
636
+ fields: [
637
+ {
638
+ name: 'browsers_multi_optional',
639
+ label: 'Browsers (multiple) (optional)',
640
+ description: 'This optional multi-select also includes a disabled option.',
641
+ options: [
642
+ {
643
+ value: 'chr',
644
+ label: 'Chrome',
645
+ },
646
+ {
647
+ value: 'ff',
648
+ label: 'Firefox',
649
+ },
650
+ {
651
+ value: 'ie',
652
+ label: 'Internet Explorer',
653
+ disabled: true,
654
+ },
655
+ ],
656
+ multiple: true,
657
+ },
658
+ ],
659
+ });
660
+ });
661
+
662
+ it('support "radio" field type @deprecated', () => {
663
+ const result = createHeadlessForm(schemaInputTypeRadioDeprecated);
664
+
665
+ expect(result).toMatchObject({
666
+ fields: [
667
+ {
668
+ description: 'Do you have any siblings?',
669
+ label: 'Has siblings',
670
+ name: 'has_siblings',
671
+ options: [
672
+ {
673
+ label: 'Yes',
674
+ value: 'yes',
675
+ },
676
+ {
677
+ label: 'No',
678
+ value: 'no',
679
+ },
680
+ ],
681
+ required: true,
682
+ schema: expect.any(Object),
683
+ type: 'radio',
684
+ },
685
+ ],
686
+ });
687
+
688
+ const fieldValidator = result.fields[0].schema;
689
+ expect(fieldValidator.isValidSync('yes')).toBe(true);
690
+ expect(() => fieldValidator.validateSync('')).toThrowError('Required field');
691
+ });
692
+ it('support "radio" field type', () => {
693
+ const result = createHeadlessForm(schemaInputTypeRadio);
694
+
695
+ expect(result).toMatchObject({
696
+ fields: [
697
+ {
698
+ description: 'Do you have any siblings?',
699
+ label: 'Has siblings',
700
+ name: 'has_siblings',
701
+ options: [
702
+ {
703
+ label: 'Yes',
704
+ value: 'yes',
705
+ },
706
+ {
707
+ label: 'No',
708
+ value: 'no',
709
+ },
710
+ ],
711
+ required: true,
712
+ schema: expect.any(Object),
713
+ type: 'radio',
714
+ },
715
+ ],
716
+ });
717
+
718
+ const fieldValidator = result.fields[0].schema;
719
+ expect(fieldValidator.isValidSync('yes')).toBe(true);
720
+ expect(() => fieldValidator.validateSync('')).toThrowError('Required field');
721
+ });
722
+
723
+ it('support "radio" optional field', () => {
724
+ const result = createHeadlessForm(schemaInputTypeRadioRequiredAndOptional);
725
+
726
+ expect(result).toMatchObject({
727
+ fields: [
728
+ {},
729
+ {
730
+ name: 'has_car',
731
+ label: 'Has car',
732
+ description: 'Do you have a car? (optional field, check oneOf)',
733
+ options: [
734
+ {
735
+ label: 'Yes',
736
+ value: 'yes',
737
+ },
738
+ {
739
+ label: 'No',
740
+ value: 'no',
741
+ },
742
+ ],
743
+ required: false,
744
+ schema: expect.any(Object),
745
+ type: 'radio',
746
+ },
747
+ ],
748
+ });
749
+
750
+ const fieldValidator = result.fields[0].schema;
751
+ expect(fieldValidator.isValidSync('yes')).toBe(true);
752
+ expect(() => fieldValidator.validateSync('')).toThrowError('Required field');
753
+ });
754
+
755
+ it('support "number" field type', () => {
756
+ const result = createHeadlessForm(schemaInputTypeNumber);
757
+ expect(result).toMatchObject({
758
+ fields: [
759
+ {
760
+ description: 'How many open tabs do you have?',
761
+ label: 'Tabs',
762
+ name: 'tabs',
763
+ required: true,
764
+ schema: expect.any(Object),
765
+ type: 'number',
766
+ minimum: 1,
767
+ maximum: 10,
768
+ },
769
+ ],
770
+ });
771
+
772
+ const fieldValidator = result.fields[0].schema;
773
+ expect(fieldValidator.isValidSync('0')).toBe(false);
774
+ expect(fieldValidator.isValidSync('10')).toBe(true);
775
+ expect(fieldValidator.isValidSync('11')).toBe(false);
776
+ expect(fieldValidator.isValidSync('this is text with a number 1')).toBe(false);
777
+ expect(() => fieldValidator.validateSync('some text')).toThrowError(
778
+ 'The value must be a number'
779
+ );
780
+ expect(() => fieldValidator.validateSync('')).toThrowError('The value must be a number');
781
+ });
782
+
783
+ it('support "number" field type with the percentage attribute', () => {
784
+ const result = createHeadlessForm(schemaInputTypeNumberWithPercentage);
785
+ expect(result).toMatchObject({
786
+ fields: [
787
+ {
788
+ description: 'What % of shares do you own?',
789
+ label: 'Shares',
790
+ name: 'shares',
791
+ percentage: true,
792
+ required: true,
793
+ schema: expect.any(Object),
794
+ type: 'number',
795
+ minimum: 1,
796
+ maximum: 100,
797
+ },
798
+ ],
799
+ });
800
+
801
+ const fieldValidator = result.fields[0].schema;
802
+ const { percentage } = result.fields[0];
803
+ expect(fieldValidator.isValidSync('0')).toBe(false);
804
+ expect(fieldValidator.isValidSync('10')).toBe(true);
805
+ expect(fieldValidator.isValidSync('101')).toBe(false);
806
+ expect(fieldValidator.isValidSync('this is text with a number 1')).toBe(false);
807
+ expect(() => fieldValidator.validateSync('some text')).toThrowError(
808
+ 'The value must be a number'
809
+ );
810
+ expect(() => fieldValidator.validateSync('')).toThrowError('The value must be a number');
811
+ expect(percentage).toBe(true);
812
+ });
813
+
814
+ it('support "number" field type with the percentage attribute and custom range values', () => {
815
+ const result = createHeadlessForm(
816
+ JSONSchemaBuilder()
817
+ .addInput({
818
+ shares: {
819
+ ...mockNumberInputWithPercentageAndCustomRange,
820
+ },
821
+ })
822
+ .setRequiredFields(['shares'])
823
+ .build()
824
+ );
825
+
826
+ expect(result).toMatchObject({
827
+ fields: [
828
+ {
829
+ description: 'What % of shares do you own?',
830
+ label: 'Shares',
831
+ name: 'shares',
832
+ percentage: true,
833
+ required: true,
834
+ schema: expect.any(Object),
835
+ type: 'number',
836
+ minimum: 50,
837
+ maximum: 70,
838
+ },
839
+ ],
840
+ });
841
+
842
+ const fieldValidatorCustom = result.fields[0].schema;
843
+ const { percentage: percentageCustom } = result.fields[0];
844
+ expect(fieldValidatorCustom.isValidSync('0')).toBe(false);
845
+ expect(fieldValidatorCustom.isValidSync('49')).toBe(false);
846
+ expect(fieldValidatorCustom.isValidSync('55')).toBe(true);
847
+ expect(fieldValidatorCustom.isValidSync('70')).toBe(true);
848
+ expect(fieldValidatorCustom.isValidSync('101')).toBe(false);
849
+ expect(fieldValidatorCustom.isValidSync('this is text with a number 1')).toBe(false);
850
+ expect(() => fieldValidatorCustom.validateSync('some text')).toThrowError(
851
+ 'The value must be a number'
852
+ );
853
+ expect(() => fieldValidatorCustom.validateSync('')).toThrowError(
854
+ 'The value must be a number'
855
+ );
856
+ expect(percentageCustom).toBe(true);
857
+ });
858
+
859
+ it('support "date" field type', () => {
860
+ const result = createHeadlessForm(schemaInputTypeDate);
861
+
862
+ expect(result).toMatchObject({
863
+ fields: [
864
+ {
865
+ label: 'Birthdate',
866
+ name: 'birthdate',
867
+ required: true,
868
+ schema: expect.any(Object),
869
+ type: 'date',
870
+ maxLength: 10,
871
+ minDate: '1922-03-01',
872
+ maxDate: '2022-03-01',
873
+ },
874
+ ],
875
+ });
876
+
877
+ const fieldValidator = result.fields[0].schema;
878
+ const todayDateHint = new Date().toISOString().substring(0, 10);
879
+ expect(fieldValidator.isValidSync('2020-10-10')).toBe(true);
880
+ expect(fieldValidator.isValidSync('2020-13-10')).toBe(false);
881
+ expect(() => fieldValidator.validateSync('')).toThrowError(
882
+ `Must be a valid date in yyyy-mm-dd format. e.g. ${todayDateHint}`
883
+ );
884
+ });
885
+
886
+ it('supports "file" field type', () => {
887
+ const result = createHeadlessForm(
888
+ JSONSchemaBuilder()
889
+ .addInput({
890
+ fileInput: mockFileInput,
891
+ })
892
+ .build()
893
+ );
894
+
895
+ expect(result).toMatchObject({
896
+ fields: [
897
+ {
898
+ type: 'file',
899
+ fileDownload: 'http://some.domain.com/file-name.pdf',
900
+ description: 'File Input Description',
901
+ fileName: 'My File',
902
+ label: 'File Input',
903
+ name: 'fileInput',
904
+ required: false,
905
+ accept: '.png,.jpg,.jpeg,.pdf',
906
+ },
907
+ ],
908
+ });
909
+ });
910
+
911
+ describe('supports "group-array" field type', () => {
912
+ it('basic test', () => {
913
+ const result = createHeadlessForm(
914
+ JSONSchemaBuilder()
915
+ .addInput({
916
+ dependent_details: mockGroupArrayInput,
917
+ })
918
+ .build()
919
+ );
920
+
921
+ expect(result).toMatchObject({
922
+ fields: [
923
+ {
924
+ type: 'group-array',
925
+ description: 'Add the dependents you claim below',
926
+ label: 'Child details',
927
+ name: 'dependent_details',
928
+ required: false,
929
+ fields: expect.any(Function),
930
+ addFieldText: 'Add new field',
931
+ },
932
+ ],
933
+ });
934
+
935
+ // Validations
936
+ const fieldValidator = result.fields[0].schema;
937
+ // nthfields are required
938
+ expect(
939
+ fieldValidator.isValidSync([
940
+ {
941
+ birthdate: '',
942
+ full_name: '',
943
+ sex: '',
944
+ },
945
+ ])
946
+ ).toBe(false);
947
+ // date is invalid
948
+ expect(
949
+ fieldValidator.isValidSync([
950
+ {
951
+ birthdate: 'invalidate date',
952
+ full_name: 'John Doe',
953
+ sex: 'male',
954
+ },
955
+ ])
956
+ ).toBe(false);
957
+ // all good
958
+ expect(
959
+ fieldValidator.isValidSync([
960
+ {
961
+ birthdate: '2021-12-04',
962
+ full_name: 'John Doe',
963
+ sex: 'male',
964
+ },
965
+ ])
966
+ ).toBe(true);
967
+
968
+ const nestedFieldsFromResult = result.fields[0].fields();
969
+ expect(nestedFieldsFromResult).toMatchObject([
970
+ {
971
+ type: 'text',
972
+ description: 'Enter your child’s full name',
973
+ maxLength: 255,
974
+ nameKey: 'full_name',
975
+ label: 'Child Full Name',
976
+ name: 'full_name',
977
+ required: true,
978
+ },
979
+ {
980
+ type: 'date',
981
+ name: 'birthdate',
982
+ label: 'Child Birthdate',
983
+ required: true,
984
+ description: 'Enter your child’s date of birth',
985
+ maxLength: 255,
986
+ nameKey: 'birthdate',
987
+ },
988
+ {
989
+ type: 'radio',
990
+ name: 'sex',
991
+ label: 'Child Sex',
992
+ options: [
993
+ {
994
+ label: 'Male',
995
+ value: 'male',
996
+ },
997
+ {
998
+ label: 'Female',
999
+ value: 'female',
1000
+ },
1001
+ ],
1002
+ required: true,
1003
+ description:
1004
+ 'We know sex is non-binary but for insurance and payroll purposes, we need to collect this information.',
1005
+ nameKey: 'sex',
1006
+ },
1007
+ ]);
1008
+ });
1009
+
1010
+ it('nested fields (native, core and custom) has correct validations', () => {
1011
+ const { handleValidation } = createHeadlessForm({
1012
+ properties: {
1013
+ break_schedule: {
1014
+ title: 'Work schedule',
1015
+ type: 'array',
1016
+ presentation: {
1017
+ inputType: 'group-array',
1018
+ },
1019
+ items: {
1020
+ properties: {
1021
+ minutes_native: {
1022
+ title: 'Minutes of break (native)',
1023
+ type: 'integer',
1024
+ minimum: 60,
1025
+ // without presentation.inputType
1026
+ },
1027
+ minutes_core: {
1028
+ title: 'Minutes of break (core)',
1029
+ type: 'integer',
1030
+ minimum: 60,
1031
+ presentation: {
1032
+ inputType: 'number', // a core inputType
1033
+ },
1034
+ },
1035
+ minutes_custom: {
1036
+ title: 'Minutes of break (custom)',
1037
+ type: 'integer',
1038
+ minimum: 60,
1039
+ presentation: {
1040
+ inputType: 'hour', // a custom inputType
1041
+ },
1042
+ },
1043
+ },
1044
+ required: ['weekday', 'minutes_native', 'minutes_core', 'minutes_custom'],
1045
+ },
1046
+ },
1047
+ },
1048
+ required: ['break_schedule'],
1049
+ });
1050
+ const validateForm = (vals) => friendlyError(handleValidation(vals));
1051
+
1052
+ // Given empty, it says it's required
1053
+ expect(validateForm({})).toEqual({
1054
+ break_schedule: 'Required field',
1055
+ });
1056
+
1057
+ // Given empty fields, it mentions nested required fields
1058
+ expect(
1059
+ validateForm({
1060
+ break_schedule: [{}],
1061
+ })
1062
+ ).toEqual({
1063
+ break_schedule: [
1064
+ {
1065
+ minutes_native: 'Required field',
1066
+ minutes_core: 'Required field',
1067
+ minutes_custom: 'Required field',
1068
+ },
1069
+ ],
1070
+ });
1071
+
1072
+ // Given correct values, it's all valid.
1073
+ expect(
1074
+ validateForm({
1075
+ break_schedule: [
1076
+ {
1077
+ minutes_native: 60,
1078
+ minutes_core: 60,
1079
+ minutes_custom: 60,
1080
+ },
1081
+ ],
1082
+ })
1083
+ ).toBeUndefined();
1084
+
1085
+ // Given invalid values, the validation is triggered.
1086
+ expect(
1087
+ validateForm({
1088
+ break_schedule: [
1089
+ {
1090
+ minutes_native: 50,
1091
+ minutes_core: 50,
1092
+ minutes_custom: 50,
1093
+ },
1094
+ ],
1095
+ })
1096
+ ).toEqual({
1097
+ break_schedule: [
1098
+ {
1099
+ minutes_core: 'Must be greater or equal to 60',
1100
+ minutes_native: 'Must be greater or equal to 60',
1101
+ minutes_custom: 'Must be greater or equal to 60',
1102
+ },
1103
+ ],
1104
+ });
1105
+ });
1106
+
1107
+ it('can pass custom field attributes', () => {
1108
+ const result = createHeadlessForm(
1109
+ {
1110
+ properties: {
1111
+ children_basic: mockGroupArrayInput,
1112
+ children_custom: mockGroupArrayInput,
1113
+ },
1114
+ },
1115
+ {
1116
+ customProperties: {
1117
+ children_custom: {
1118
+ 'data-foo': 'baz',
1119
+ },
1120
+ },
1121
+ }
1122
+ );
1123
+
1124
+ expect(result).toMatchObject({
1125
+ fields: [
1126
+ {
1127
+ label: 'Child details',
1128
+ name: 'children_basic',
1129
+ required: false,
1130
+ type: 'group-array',
1131
+ inputType: 'group-array',
1132
+ jsonType: 'array',
1133
+ fields: expect.any(Function), // This is what makes the field work
1134
+ },
1135
+ {
1136
+ label: 'Child details',
1137
+ name: 'children_custom',
1138
+ type: 'group-array',
1139
+ inputType: 'group-array',
1140
+ jsonType: 'array',
1141
+ required: false,
1142
+ 'data-foo': 'baz', // check that custom property is properly propagated
1143
+ fields: expect.any(Function), // This is what makes the field work
1144
+ },
1145
+ ],
1146
+ });
1147
+ });
1148
+ });
1149
+
1150
+ it('supports "fieldset" field type', () => {
1151
+ const result = createHeadlessForm(
1152
+ JSONSchemaBuilder()
1153
+ .addInput({
1154
+ fieldset: mockFieldset,
1155
+ })
1156
+ .build()
1157
+ );
1158
+
1159
+ expect(result).toMatchObject({
1160
+ fields: [
1161
+ {
1162
+ description: 'Fieldset description',
1163
+ label: 'Fieldset title',
1164
+ name: 'fieldset',
1165
+ type: 'fieldset',
1166
+ required: false,
1167
+ fields: [
1168
+ {
1169
+ description: 'The number of your national identification (max 10 digits)',
1170
+ label: 'ID number',
1171
+ name: 'id_number',
1172
+ type: 'text',
1173
+ required: true,
1174
+ },
1175
+ {
1176
+ description: 'How many open tabs do you have?',
1177
+ label: 'Tabs',
1178
+ maximum: 10,
1179
+ minimum: 1,
1180
+ name: 'tabs',
1181
+ type: 'number',
1182
+ required: false,
1183
+ },
1184
+ ],
1185
+ },
1186
+ ],
1187
+ });
1188
+ });
1189
+
1190
+ it('supports "radio" field type with its "card" and "card-expandable" variants', () => {
1191
+ const result = createHeadlessForm(
1192
+ JSONSchemaBuilder()
1193
+ .addInput({
1194
+ experience_level: mockRadioCardExpandableInput,
1195
+ payment_method: mockRadioCardInput,
1196
+ })
1197
+ .build()
1198
+ );
1199
+
1200
+ expect(result).toMatchObject({
1201
+ fields: [
1202
+ {
1203
+ description:
1204
+ 'Please select the experience level that aligns with this role based on the job description (not the employees overall experience)',
1205
+ label: 'Experience level',
1206
+ name: 'experience_level',
1207
+ type: 'radio',
1208
+ required: false,
1209
+ variant: 'card-expandable',
1210
+ options: [
1211
+ {
1212
+ label: 'Junior level',
1213
+ value: 'junior',
1214
+ description:
1215
+ 'Entry level employees who perform tasks under the supervision of a more experienced employee.',
1216
+ },
1217
+ {
1218
+ label: 'Mid level',
1219
+ value: 'mid',
1220
+ description:
1221
+ 'Employees who perform tasks with a good degree of autonomy and/or with coordination and control functions.',
1222
+ },
1223
+ {
1224
+ label: 'Senior level',
1225
+ value: 'senior',
1226
+ description:
1227
+ 'Employees who perform tasks with a high degree of autonomy and/or with coordination and control functions.',
1228
+ },
1229
+ ],
1230
+ },
1231
+ {
1232
+ description: 'Chose how you want to be paid',
1233
+ label: 'Payment method',
1234
+ name: 'payment_method',
1235
+ type: 'radio',
1236
+ variant: 'card',
1237
+ required: false,
1238
+ options: [
1239
+ {
1240
+ label: 'Credit Card',
1241
+ value: 'cc',
1242
+ description: 'Plastic money, which is still money',
1243
+ },
1244
+ {
1245
+ label: 'Cash',
1246
+ value: 'cash',
1247
+ description: 'Rules Everything Around Me',
1248
+ },
1249
+ ],
1250
+ },
1251
+ ],
1252
+ });
1253
+ });
1254
+
1255
+ it('supports nested "fieldset" field type', () => {
1256
+ const result = createHeadlessForm(
1257
+ JSONSchemaBuilder()
1258
+ .addInput({
1259
+ nestedFieldset: mockNestedFieldset,
1260
+ })
1261
+ .build()
1262
+ );
1263
+
1264
+ expect(result).toMatchObject({
1265
+ fields: [
1266
+ {
1267
+ label: 'Nested fieldset title',
1268
+ description: 'Nested fieldset description',
1269
+ name: 'nestedFieldset',
1270
+ type: 'fieldset',
1271
+ required: false,
1272
+ fields: [
1273
+ {
1274
+ description: 'Fieldset description',
1275
+ label: 'Fieldset title',
1276
+ name: 'innerFieldset',
1277
+ type: 'fieldset',
1278
+ required: false,
1279
+ fields: [
1280
+ {
1281
+ description: 'The number of your national identification (max 10 digits)',
1282
+ label: 'ID number',
1283
+ name: 'id_number',
1284
+ type: 'text',
1285
+ required: true,
1286
+ },
1287
+ {
1288
+ description: 'How many open tabs do you have?',
1289
+ label: 'Tabs',
1290
+ maximum: 10,
1291
+ minimum: 1,
1292
+ name: 'tabs',
1293
+ type: 'number',
1294
+ required: false,
1295
+ },
1296
+ ],
1297
+ },
1298
+ ],
1299
+ },
1300
+ ],
1301
+ });
1302
+ });
1303
+
1304
+ it('supported "fieldset" with scoped conditionals', () => {
1305
+ const { handleValidation } = createHeadlessForm(schemaFieldsetScopedCondition, {});
1306
+ const validateForm = (vals) => friendlyError(handleValidation(vals));
1307
+
1308
+ // The "child.has_child" is required
1309
+ expect(validateForm({})).toEqual({
1310
+ child: {
1311
+ has_child: 'Required field',
1312
+ },
1313
+ });
1314
+
1315
+ // The "child.no" is valid
1316
+ expect(
1317
+ validateForm({
1318
+ child: {
1319
+ has_child: 'no',
1320
+ },
1321
+ })
1322
+ ).toBeUndefined();
1323
+
1324
+ // Invalid because it expect child.age too
1325
+ expect(
1326
+ validateForm({
1327
+ child: {
1328
+ has_child: 'yes',
1329
+ },
1330
+ })
1331
+ ).toEqual({
1332
+ child: {
1333
+ age: 'Required field',
1334
+ },
1335
+ });
1336
+
1337
+ // Valid without optional child.passport_id
1338
+ expect(
1339
+ validateForm({
1340
+ child: {
1341
+ has_child: 'yes',
1342
+ age: 15,
1343
+ },
1344
+ })
1345
+ ).toBeUndefined();
1346
+
1347
+ // Valid with optional child.passport_id
1348
+ expect(
1349
+ validateForm({
1350
+ child: {
1351
+ has_child: 'yes',
1352
+ age: 15,
1353
+ passport_id: 'asdf',
1354
+ },
1355
+ })
1356
+ ).toBeUndefined();
1357
+ });
1358
+
1359
+ it('should set any nested "fieldset" form values to null when they are invisible', async () => {
1360
+ const { handleValidation } = createHeadlessForm(schemaFieldsetScopedCondition, {});
1361
+ const validateForm = (vals) => friendlyError(handleValidation(vals));
1362
+
1363
+ const formValues = {
1364
+ child: {
1365
+ has_child: 'yes',
1366
+ age: 15,
1367
+ },
1368
+ };
1369
+
1370
+ await expect(validateForm(formValues)).toBeUndefined();
1371
+ expect(formValues.child.age).toBe(15);
1372
+
1373
+ formValues.child.has_child = 'no';
1374
+ // form value updates re-validate; see computeYupSchema()
1375
+ await expect(validateForm(formValues)).toBeUndefined();
1376
+
1377
+ // when child.has_child is 'no' child.age is invisible
1378
+ expect(formValues.child.age).toBe(null);
1379
+ });
1380
+
1381
+ it('support "email" field type', () => {
1382
+ const result = createHeadlessForm(schemaInputTypeEmail);
1383
+
1384
+ expect(result).toMatchObject({
1385
+ fields: [
1386
+ {
1387
+ description: 'Enter your email address',
1388
+ label: 'Email address',
1389
+ name: 'email_address',
1390
+ required: true,
1391
+ schema: expect.any(Object),
1392
+ type: 'email',
1393
+ maxLength: 255,
1394
+ },
1395
+ ],
1396
+ });
1397
+
1398
+ const fieldValidator = result.fields[0].schema;
1399
+ expect(fieldValidator.isValidSync('test@gmail.com')).toBe(true);
1400
+ expect(() => fieldValidator.validateSync('ffsdf')).toThrowError(
1401
+ 'Please enter a valid email address'
1402
+ );
1403
+ expect(() => fieldValidator.validateSync(undefined)).toThrowError('Required field');
1404
+ });
1405
+
1406
+ describe('supports "checkbox" field type', () => {
1407
+ describe('checkbox as string', () => {
1408
+ it('required: only accept the value in "checkboxValue"', () => {
1409
+ const result = createHeadlessForm(schemaInputTypeCheckbox);
1410
+ const checkboxField = result.fields.find((field) => field.name === 'contract_duration');
1411
+
1412
+ expect(checkboxField).toMatchObject({
1413
+ description:
1414
+ 'I acknowledge that all employees in France will be hired on indefinite contracts.',
1415
+ label: 'Contract duration',
1416
+ name: 'contract_duration',
1417
+ type: 'checkbox',
1418
+ checkboxValue: 'Permanent',
1419
+ });
1420
+ expect(checkboxField).not.toHaveProperty('default'); // ensure it's not checked by default.
1421
+
1422
+ const fieldValidator = checkboxField.schema;
1423
+ expect(fieldValidator.isValidSync('Permanent')).toBe(true);
1424
+ expect(() => fieldValidator.validateSync(undefined)).toThrowError(
1425
+ 'Please acknowledge this field'
1426
+ );
1427
+ });
1428
+
1429
+ it('required checked: returns a default value', () => {
1430
+ const result = createHeadlessForm(schemaInputTypeCheckbox);
1431
+ const checkboxField = result.fields.find(
1432
+ (field) => field.name === 'contract_duration_checked'
1433
+ );
1434
+
1435
+ expect(checkboxField).toMatchObject({
1436
+ default: 'Permanent',
1437
+ checkboxValue: 'Permanent',
1438
+ });
1439
+ });
1440
+ });
1441
+
1442
+ describe('checkbox as boolean', () => {
1443
+ it('optional: Accepts true or false', () => {
1444
+ const result = createHeadlessForm(schemaInputTypeCheckboxBooleans);
1445
+ const checkboxField = result.fields.find((field) => field.name === 'boolean_empty');
1446
+
1447
+ expect(checkboxField).toMatchObject({
1448
+ checkboxValue: true,
1449
+ });
1450
+ expect(checkboxField).not.toHaveProperty('default'); // ensure it's not checked by default.
1451
+
1452
+ const fieldValidator = checkboxField.schema;
1453
+ expect(fieldValidator.isValidSync(true)).toBe(true);
1454
+ expect(fieldValidator.isValidSync(false)).toBe(true);
1455
+ expect(fieldValidator.isValidSync(undefined)).toBe(true);
1456
+ expect(() => fieldValidator.validateSync('foo')).toThrowError(
1457
+ 'this must be a `boolean` type, but the final value was: `"foo"`.'
1458
+ );
1459
+ });
1460
+
1461
+ it('required: Only accepts true', () => {
1462
+ const result = createHeadlessForm(schemaInputTypeCheckboxBooleans);
1463
+ const checkboxField = result.fields.find((field) => field.name === 'boolean_required');
1464
+
1465
+ expect(checkboxField).toMatchObject({
1466
+ checkboxValue: true,
1467
+ });
1468
+
1469
+ const fieldValidator = checkboxField.schema;
1470
+ expect(fieldValidator.isValidSync(true)).toBe(true);
1471
+ expect(() => fieldValidator.validateSync(false)).toThrowError(
1472
+ 'Please acknowledge this field'
1473
+ );
1474
+ });
1475
+
1476
+ it('checked: returns default: true', () => {
1477
+ const result = createHeadlessForm(schemaInputTypeCheckboxBooleans);
1478
+ const checkboxField = result.fields.find((field) => field.name === 'boolean_checked');
1479
+
1480
+ expect(checkboxField).toMatchObject({
1481
+ checkboxValue: true,
1482
+ default: true,
1483
+ });
1484
+ });
1485
+ });
1486
+ });
1487
+
1488
+ describe('supports custom inputType (eg "hour")', () => {
1489
+ it('as required, optional, and mixed types', () => {
1490
+ const { fields, handleValidation } = createHeadlessForm(
1491
+ {
1492
+ properties: {
1493
+ start_time: {
1494
+ title: 'Starting time',
1495
+ type: 'string',
1496
+ presentation: {
1497
+ inputType: 'hour',
1498
+ },
1499
+ },
1500
+ pause: {
1501
+ title: 'Pause time (optional)',
1502
+ type: 'string',
1503
+ presentation: {
1504
+ inputType: 'hour',
1505
+ },
1506
+ },
1507
+ end_time: {
1508
+ title: 'Finishing time (optional)',
1509
+ type: ['null', 'string'], // ensure it supports mix types (array) (optional/null)
1510
+ presentation: {
1511
+ inputType: 'hour',
1512
+ },
1513
+ },
1514
+ },
1515
+ required: ['start_time'],
1516
+ },
1517
+ {
1518
+ strictInputType: false,
1519
+ }
1520
+ );
1521
+ const validateForm = (vals) => friendlyError(handleValidation(vals));
1522
+
1523
+ const commonAttrs = {
1524
+ type: 'hour',
1525
+ inputType: 'hour',
1526
+ jsonType: 'string',
1527
+ schema: expect.any(Object),
1528
+ };
1529
+ expect(fields).toMatchObject([
1530
+ {
1531
+ name: 'start_time',
1532
+ label: 'Starting time',
1533
+ ...commonAttrs,
1534
+ },
1535
+ {
1536
+ name: 'pause',
1537
+ label: 'Pause time (optional)',
1538
+ ...commonAttrs,
1539
+ },
1540
+ {
1541
+ name: 'end_time',
1542
+ label: 'Finishing time (optional)',
1543
+ ...commonAttrs,
1544
+ jsonType: ['null', 'string'],
1545
+ },
1546
+ ]);
1547
+
1548
+ expect(validateForm({})).toEqual({
1549
+ start_time: 'Required field',
1550
+ });
1551
+
1552
+ expect(validateForm({ start_time: '08:30' })).toBeUndefined();
1553
+ });
1554
+ });
1555
+ });
1556
+
1557
+ describe('validation options', () => {
1558
+ it('given invalid values it returns both yupError and formErrors', () => {
1559
+ const { handleValidation } = createHeadlessForm(schemaInputTypeText);
1560
+
1561
+ const { formErrors, yupError } = handleValidation({});
1562
+
1563
+ // Assert the yupError shape is really a YupError
1564
+ expect(yupError).toEqual(expect.any(Error));
1565
+ expect(yupError.inner[0].path).toBe('id_number');
1566
+ expect(yupError.inner[0].message).toBe('Required field');
1567
+
1568
+ // Assert the converted YupError to formErrors
1569
+ expect(formErrors).toEqual({
1570
+ id_number: 'Required field',
1571
+ });
1572
+ });
1573
+ });
1574
+
1575
+ describe('x-jsf-presentation attribute', () => {
1576
+ it('support field with "x-jsf-presentation.statement"', () => {
1577
+ const result = createHeadlessForm(schemaInputWithStatement);
1578
+
1579
+ expect(result).toMatchObject({
1580
+ fields: [
1581
+ {
1582
+ name: 'bonus',
1583
+ label: 'Bonus',
1584
+ type: 'text',
1585
+ statement: {
1586
+ description: 'This is a custom statement message.',
1587
+ inputType: 'statement',
1588
+ severity: 'info',
1589
+ },
1590
+ },
1591
+ {
1592
+ name: 'a_or_b',
1593
+ label: 'A dropdown',
1594
+ type: 'select',
1595
+ statement: {
1596
+ description: 'This is another statement message, but more severe.',
1597
+ inputType: 'statement',
1598
+ severity: 'warning',
1599
+ },
1600
+ },
1601
+ ],
1602
+ });
1603
+ });
1604
+ });
1605
+
1606
+ describe('property misc attributes', () => {
1607
+ it('pass readOnly to field', () => {
1608
+ const result = createHeadlessForm({
1609
+ properties: {
1610
+ secret: {
1611
+ title: 'Secret code',
1612
+ readOnly: true,
1613
+ type: 'string',
1614
+ presentation: {
1615
+ inputType: 'text',
1616
+ },
1617
+ },
1618
+ },
1619
+ });
1620
+
1621
+ expect(result).toMatchObject({
1622
+ fields: [
1623
+ {
1624
+ name: 'secret',
1625
+ label: 'Secret code',
1626
+ schema: expect.any(Object),
1627
+ readOnly: true,
1628
+ },
1629
+ ],
1630
+ });
1631
+ });
1632
+
1633
+ it('pass "deprecated" attributes to field', () => {
1634
+ const result = createHeadlessForm({
1635
+ properties: {
1636
+ secret: {
1637
+ title: 'Age',
1638
+ type: 'number',
1639
+ deprecated: true,
1640
+ presentation: {
1641
+ inputType: 'number',
1642
+ deprecated: {
1643
+ description: 'Deprecated in favor of "birthdate".',
1644
+ },
1645
+ },
1646
+ },
1647
+ },
1648
+ });
1649
+
1650
+ expect(result).toMatchObject({
1651
+ fields: [
1652
+ {
1653
+ type: 'number',
1654
+ name: 'secret',
1655
+ label: 'Age',
1656
+ schema: expect.any(Object),
1657
+ deprecated: {
1658
+ description: 'Deprecated in favor of "birthdate".',
1659
+ },
1660
+ },
1661
+ ],
1662
+ });
1663
+ });
1664
+
1665
+ it('pass "description" to field', () => {
1666
+ const result = createHeadlessForm({
1667
+ properties: {
1668
+ plain: {
1669
+ title: 'Regular',
1670
+ description: 'I am regular',
1671
+ presentation: { inputType: 'text' },
1672
+ },
1673
+ html: {
1674
+ title: 'Name',
1675
+ description: 'I am regular',
1676
+ presentation: {
1677
+ description: 'I am <b>bold</b>.',
1678
+ inputType: 'text',
1679
+ },
1680
+ },
1681
+ },
1682
+ });
1683
+
1684
+ expect(result).toMatchObject({
1685
+ fields: [
1686
+ { description: 'I am regular' },
1687
+ { description: '<span class="jsf-description">I am <b>bold</b>.</span>' },
1688
+ ],
1689
+ });
1690
+ });
1691
+
1692
+ it('passes scopedJsonSchema to each field', () => {
1693
+ const { fields } = createHeadlessForm(schemaWithoutInputTypes, {
1694
+ strictInputType: false,
1695
+ });
1696
+
1697
+ const getByName = (fieldList, name) => fieldList.find((f) => f.name === name);
1698
+
1699
+ const aFieldInRoot = getByName(fields, 'a_string');
1700
+ // It's the entire json schema
1701
+ expect(aFieldInRoot.scopedJsonSchema).toEqual(schemaWithoutInputTypes);
1702
+
1703
+ const aFieldset = getByName(fields, 'a_object');
1704
+ const aFieldInTheFieldset = getByName(aFieldset.fields, 'foo');
1705
+
1706
+ // It's only the json schema of that fieldset
1707
+ expect(aFieldInTheFieldset.scopedJsonSchema).toEqual(
1708
+ schemaWithoutInputTypes.properties.a_object
1709
+ );
1710
+ });
1711
+
1712
+ describe('Order of fields', () => {
1713
+ it('sorts fields based on presentation.position keyword (deprecated)', () => {
1714
+ const { fields } = createHeadlessForm(schemaWithPositionDeprecated);
1715
+
1716
+ // Assert the order from the original schema object
1717
+ expect(Object.keys(schemaWithPositionDeprecated.properties)).toEqual([
1718
+ 'age',
1719
+ 'street',
1720
+ 'username',
1721
+ ]);
1722
+ expect(Object.keys(schemaWithPositionDeprecated.properties.street.properties)).toEqual([
1723
+ 'line_one',
1724
+ 'postal_code',
1725
+ 'number',
1726
+ ]);
1727
+
1728
+ // Assert the Fields order
1729
+ const fieldsByName = fields.map((f) => f.name);
1730
+ expect(fieldsByName).toEqual(['username', 'age', 'street']);
1731
+
1732
+ const fieldsetByName = fields[2].fields.map((f) => f.name);
1733
+ expect(fieldsetByName).toEqual(['line_one', 'number', 'postal_code']);
1734
+ });
1735
+
1736
+ it('sorts fields based on x-jsf-order keyword', () => {
1737
+ const { fields } = createHeadlessForm(schemaWithOrderKeyword);
1738
+
1739
+ // Assert the order from the original schema object
1740
+ expect(Object.keys(schemaWithOrderKeyword.properties)).toEqual([
1741
+ 'age',
1742
+ 'street',
1743
+ 'username',
1744
+ ]);
1745
+ expect(Object.keys(schemaWithOrderKeyword.properties.street.properties)).toEqual([
1746
+ 'line_one',
1747
+ 'postal_code',
1748
+ 'number',
1749
+ ]);
1750
+
1751
+ // Assert the Fields order
1752
+ const fieldsByName = fields.map((f) => f.name);
1753
+ expect(fieldsByName).toEqual(['username', 'age', 'street']);
1754
+
1755
+ const fieldsetByName = fields[2].fields.map((f) => f.name);
1756
+ expect(fieldsetByName).toEqual(['line_one', 'number', 'postal_code']);
1757
+ });
1758
+
1759
+ it('sorts fields based on original properties (wihout x-jsf-order)', () => {
1760
+ // Assert the sample schema has x-jsf-order
1761
+ expect(schemaWithOrderKeyword['x-jsf-order']).toBeDefined();
1762
+
1763
+ const schemaWithoutOrder = {
1764
+ ...schemaWithOrderKeyword,
1765
+ 'x-jsf-order': undefined,
1766
+ };
1767
+ const { fields } = createHeadlessForm(schemaWithoutOrder);
1768
+
1769
+ const originalOrder = ['age', 'street', 'username'];
1770
+ // Assert the order from the original schema object
1771
+ expect(Object.keys(schemaWithoutOrder.properties)).toEqual(originalOrder);
1772
+
1773
+ // Assert the order of fields is the same as the original object
1774
+ const fieldsByName = fields.map((f) => f.name);
1775
+ expect(fieldsByName).toEqual(originalOrder);
1776
+ });
1777
+ });
1778
+ });
1779
+
1780
+ describe('when a field is required', () => {
1781
+ let fields;
1782
+ beforeEach(() => {
1783
+ const result = createHeadlessForm(
1784
+ buildJSONSchemaInput({ presentationFields: { inputType: 'text' }, required: true })
1785
+ );
1786
+ fields = result.fields;
1787
+ });
1788
+ describe('and value is empty', () => {
1789
+ it('should throw an error', async () =>
1790
+ expect(
1791
+ object()
1792
+ .shape({
1793
+ test: fields[0].schema,
1794
+ })
1795
+ .validate({ test: '' })
1796
+ ).rejects.toMatchObject({ errors: ['Required field'] }));
1797
+ });
1798
+ describe('and value is defined', () => {
1799
+ it('should validate field', async () => {
1800
+ const assertObj = { test: 'Hello' };
1801
+ return expect(
1802
+ object()
1803
+ .shape({
1804
+ test: fields[0].schema,
1805
+ })
1806
+ .validate(assertObj)
1807
+ ).resolves.toEqual(assertObj);
1808
+ });
1809
+ });
1810
+ });
1811
+
1812
+ describe('when a field is number', () => {
1813
+ let fields;
1814
+ beforeEach(() => {
1815
+ const result = createHeadlessForm(
1816
+ buildJSONSchemaInput({ presentationFields: { inputType: 'number' } })
1817
+ );
1818
+ fields = result.fields;
1819
+ });
1820
+ describe('and value is a string', () => {
1821
+ it('should throw an error', async () =>
1822
+ expect(
1823
+ object()
1824
+ .shape({
1825
+ test: fields[0].schema,
1826
+ })
1827
+ .validate({ test: 'Hello' })
1828
+ ).rejects.toThrow());
1829
+ });
1830
+ describe('and value is a number', () => {
1831
+ it('should validate field', async () => {
1832
+ const assertObj = { test: 3 };
1833
+ return expect(
1834
+ object()
1835
+ .shape({
1836
+ test: fields[0].schema,
1837
+ })
1838
+ .validate(assertObj)
1839
+ ).resolves.toEqual(assertObj);
1840
+ });
1841
+ });
1842
+ });
1843
+
1844
+ describe('when a field has a maxLength of 10', () => {
1845
+ let fields;
1846
+ beforeEach(() => {
1847
+ const result = createHeadlessForm(
1848
+ buildJSONSchemaInput({
1849
+ presentationFields: { inputType: 'text' },
1850
+ inputFields: { maxLength: 10 },
1851
+ })
1852
+ );
1853
+ fields = result.fields;
1854
+ });
1855
+ describe('and value is greater than that', () => {
1856
+ it('should throw an error', async () =>
1857
+ expect(
1858
+ object()
1859
+ .shape({
1860
+ test: fields[0].schema,
1861
+ })
1862
+ .validate({ test: 'Hello Mr John Doe' })
1863
+ ).rejects.toMatchObject({ errors: ['Please insert up to 10 characters'] }));
1864
+ });
1865
+ describe('and value is less than that', () => {
1866
+ it('should validate field', async () => {
1867
+ const assertObj = { test: 'Hello John' };
1868
+ return expect(
1869
+ object()
1870
+ .shape({
1871
+ test: fields[0].schema,
1872
+ })
1873
+ .validate(assertObj)
1874
+ ).resolves.toEqual(assertObj);
1875
+ });
1876
+ });
1877
+ });
1878
+
1879
+ describe('when a field has a minLength of 2', () => {
1880
+ let fields;
1881
+ beforeEach(() => {
1882
+ const result = createHeadlessForm(
1883
+ buildJSONSchemaInput({
1884
+ presentationFields: { inputType: 'text' },
1885
+ inputFields: { minLength: 2 },
1886
+ })
1887
+ );
1888
+ fields = result.fields;
1889
+ });
1890
+ describe('and value is smaller than that', () => {
1891
+ it('should throw an error', async () =>
1892
+ expect(
1893
+ object()
1894
+ .shape({
1895
+ test: fields[0].schema,
1896
+ })
1897
+ .validate({ test: 'H' })
1898
+ ).rejects.toMatchObject({ errors: ['Please insert at least 2 characters'] }));
1899
+ });
1900
+ describe('and value is greater than that', () => {
1901
+ it('should validate field', async () => {
1902
+ const assertObj = { test: 'Hello John' };
1903
+ return expect(
1904
+ object()
1905
+ .shape({
1906
+ test: fields[0].schema,
1907
+ })
1908
+ .validate(assertObj)
1909
+ ).resolves.toEqual(assertObj);
1910
+ });
1911
+ });
1912
+ });
1913
+
1914
+ describe('when a field has a minimum of 0', () => {
1915
+ let fields;
1916
+ beforeEach(() => {
1917
+ const result = createHeadlessForm(
1918
+ buildJSONSchemaInput({
1919
+ presentationFields: { inputType: 'number' },
1920
+ inputFields: { minimum: 0 },
1921
+ })
1922
+ );
1923
+ fields = result.fields;
1924
+ });
1925
+
1926
+ describe('and value is less than that', () => {
1927
+ it('should throw an error', async () =>
1928
+ expect(
1929
+ object()
1930
+ .shape({
1931
+ test: fields[0].schema,
1932
+ })
1933
+ .validate({ test: -1 })
1934
+ ).rejects.toMatchObject({ errors: ['Must be greater or equal to 0'] }));
1935
+ });
1936
+
1937
+ describe('and value is greater than that', () => {
1938
+ it('should validate field', async () => {
1939
+ const assertObj = { test: 4 };
1940
+ return expect(
1941
+ object()
1942
+ .shape({
1943
+ test: fields[0].schema,
1944
+ })
1945
+ .validate(assertObj)
1946
+ ).resolves.toEqual(assertObj);
1947
+ });
1948
+ });
1949
+ });
1950
+
1951
+ describe('when a field has a maximum of 10', () => {
1952
+ let fields;
1953
+ beforeEach(() => {
1954
+ const result = createHeadlessForm(
1955
+ buildJSONSchemaInput({
1956
+ presentationFields: { inputType: 'number' },
1957
+ inputFields: { maximum: 10 },
1958
+ })
1959
+ );
1960
+ fields = result.fields;
1961
+ });
1962
+
1963
+ describe('and value is greater than that', () => {
1964
+ it('should throw an error', async () =>
1965
+ expect(
1966
+ object()
1967
+ .shape({
1968
+ test: fields[0].schema,
1969
+ })
1970
+ .validate({ test: 11 })
1971
+ ).rejects.toMatchObject({ errors: ['Must be smaller or equal to 10'] }));
1972
+ });
1973
+
1974
+ describe('and value is greater than that', () => {
1975
+ it('should validate field', async () => {
1976
+ const assertObj = { test: 4 };
1977
+ return expect(
1978
+ object()
1979
+ .shape({
1980
+ test: fields[0].schema,
1981
+ })
1982
+ .validate(assertObj)
1983
+ ).resolves.toEqual(assertObj);
1984
+ });
1985
+ });
1986
+ });
1987
+
1988
+ describe('when a field has a pattern', () => {
1989
+ let fields;
1990
+ beforeEach(() => {
1991
+ const result = createHeadlessForm(
1992
+ buildJSONSchemaInput({
1993
+ presentationFields: { inputType: 'text' },
1994
+ inputFields: { pattern: '^[0-9]{3}-[0-9]{2}-(?!0{4})[0-9]{4}$' },
1995
+ })
1996
+ );
1997
+ fields = result.fields;
1998
+ });
1999
+ describe('and value does not match the pattern', () => {
2000
+ it('should throw an error', async () =>
2001
+ expect(
2002
+ object()
2003
+ .shape({
2004
+ test: fields[0].schema,
2005
+ })
2006
+ .validate({ test: 'Hello' })
2007
+ ).rejects.toMatchObject({ errors: [expect.any(String)] }));
2008
+ });
2009
+ describe('and value matches the pattern', () => {
2010
+ it('should validate field', async () => {
2011
+ const assertObj = { test: '401-85-1950' };
2012
+ return expect(
2013
+ object()
2014
+ .shape({
2015
+ test: fields[0].schema,
2016
+ })
2017
+ .validate(assertObj)
2018
+ ).resolves.toEqual(assertObj);
2019
+ });
2020
+ });
2021
+ });
2022
+
2023
+ describe('when a field has max file size', () => {
2024
+ let fields;
2025
+ beforeEach(() => {
2026
+ const result = createHeadlessForm(
2027
+ JSONSchemaBuilder().addInput({ fileInput: mockFileInput }).build()
2028
+ );
2029
+ fields = result.fields;
2030
+ });
2031
+ describe('and file is greater than that', () => {
2032
+ const file = new File([''], 'file.png');
2033
+ Object.defineProperty(file, 'size', { value: 1024 * 1024 * 1024 });
2034
+
2035
+ it('should throw an error', async () =>
2036
+ expect(
2037
+ object()
2038
+ .shape({
2039
+ fileInput: fields[0].schema,
2040
+ })
2041
+ .validate({ fileInput: [file] })
2042
+ ).rejects.toMatchObject({ errors: ['File size too large. The limit is 20 MB.'] }));
2043
+ });
2044
+ describe('and file is smaller than that', () => {
2045
+ const file = new File([''], 'file.png');
2046
+ Object.defineProperty(file, 'size', { value: 1024 * 1024 });
2047
+
2048
+ const assertObj = { fileInput: [file] };
2049
+ it('should validate field', async () =>
2050
+ expect(
2051
+ object()
2052
+ .shape({
2053
+ fileInput: fields[0].schema,
2054
+ })
2055
+ .validate({ fileInput: [file] })
2056
+ ).resolves.toEqual(assertObj));
2057
+ });
2058
+ });
2059
+
2060
+ describe('when a field file is optional', () => {
2061
+ it('it accepts an empty array', () => {
2062
+ const result = createHeadlessForm(
2063
+ JSONSchemaBuilder().addInput({ fileInput: mockFileInput }).build()
2064
+ );
2065
+ const emptyFile = { fileInput: [] };
2066
+ expect(
2067
+ object()
2068
+ .shape({
2069
+ fileInput: result.fields[0].schema,
2070
+ })
2071
+ .validate(emptyFile)
2072
+ ).resolves.toEqual(emptyFile);
2073
+ });
2074
+ });
2075
+
2076
+ describe('when a field has accepted extensions', () => {
2077
+ let fields;
2078
+ beforeEach(() => {
2079
+ const result = createHeadlessForm(
2080
+ JSONSchemaBuilder().addInput({ fileInput: mockFileInput }).build()
2081
+ );
2082
+ fields = result.fields;
2083
+ });
2084
+ describe('and file is of inccorrect format', () => {
2085
+ const file = new File(['foo'], 'file.txt', {
2086
+ type: 'text/plain',
2087
+ });
2088
+
2089
+ it('should throw an error', async () =>
2090
+ expect(
2091
+ object()
2092
+ .shape({
2093
+ fileInput: fields[0].schema,
2094
+ })
2095
+ .validate({ fileInput: [file] })
2096
+ ).rejects.toMatchObject({
2097
+ errors: ['Unsupported file format. The acceptable formats are .png,.jpg,.jpeg,.pdf.'],
2098
+ }));
2099
+ });
2100
+ describe('and file is of correct format', () => {
2101
+ const file = new File(['foo'], 'file.png', {
2102
+ type: 'image/png',
2103
+ });
2104
+ Object.defineProperty(file, 'size', { value: 1024 * 1024 });
2105
+
2106
+ const assertObj = { fileInput: [file] };
2107
+ it('should validate field', async () =>
2108
+ expect(
2109
+ object()
2110
+ .shape({
2111
+ fileInput: fields[0].schema,
2112
+ })
2113
+ .validate({ fileInput: [file] })
2114
+ ).resolves.toEqual(assertObj));
2115
+ });
2116
+
2117
+ describe('and file is of correct but uppercase format ', () => {
2118
+ const file = new File(['foo'], 'file.PNG', {
2119
+ type: 'image/png',
2120
+ });
2121
+ Object.defineProperty(file, 'size', { value: 1024 * 1024 });
2122
+
2123
+ const assertObj = { fileInput: [file] };
2124
+ it('should validate field', async () =>
2125
+ expect(
2126
+ object()
2127
+ .shape({
2128
+ fileInput: fields[0].schema,
2129
+ })
2130
+ .validate({ fileInput: [file] })
2131
+ ).resolves.toEqual(assertObj));
2132
+ });
2133
+ });
2134
+
2135
+ describe('when a field has conditional presentation properties', () => {
2136
+ it('adds .jsf-statement to nested statement markup when visible', () => {
2137
+ const { fields } = createHeadlessForm(schemaWithConditionalPresentationProperties, {
2138
+ initialValues: {
2139
+ // show the hidden statement
2140
+ mock_radio: 'no',
2141
+ },
2142
+ });
2143
+
2144
+ expect(fields[0].statement.description).toBe(
2145
+ `<span class="jsf-statement"><a href="">conditional statement markup</a></span>`
2146
+ );
2147
+ });
2148
+ });
2149
+
2150
+ describe('when a JSON Schema is provided', () => {
2151
+ const getByName = (fields, name) => fields.find((f) => f.name === name);
2152
+
2153
+ describe('and all fields are optional', () => {
2154
+ let handleValidation;
2155
+ const validateForm = (vals) => friendlyError(handleValidation(vals));
2156
+
2157
+ beforeEach(() => {
2158
+ const result = JSONSchemaBuilder()
2159
+ .addInput({ textInput: mockTextInput })
2160
+ .addInput({ numberInput: mockNumberInput })
2161
+ .build();
2162
+ const { handleValidation: handleValidationEach } = createHeadlessForm(result);
2163
+ handleValidation = handleValidationEach;
2164
+ });
2165
+
2166
+ it.each([
2167
+ [
2168
+ 'validation should return true when the object has empty values',
2169
+ { textInput: '' },
2170
+ undefined,
2171
+ ],
2172
+ [
2173
+ 'validation should return true when object is valid',
2174
+ { textInput: 'abcde', numberInput: 9 },
2175
+ undefined,
2176
+ ],
2177
+ ])('%s', (_, value, errors) => {
2178
+ const testValue = validateForm(value);
2179
+ if (errors) {
2180
+ expect(testValue).toEqual(errors);
2181
+ } else {
2182
+ expect(testValue).toBeUndefined();
2183
+ }
2184
+ });
2185
+ });
2186
+
2187
+ describe('and all fields are mandatory', () => {
2188
+ let handleValidation;
2189
+ const validateForm = (vals) => friendlyError(handleValidation(vals));
2190
+
2191
+ beforeEach(() => {
2192
+ const result = JSONSchemaBuilder()
2193
+ .addInput({ textInput: mockTextInput })
2194
+ .addInput({ numberInput: mockNumberInput })
2195
+ .setRequiredFields(['numberInput', 'textInput'])
2196
+ .build();
2197
+ const { handleValidation: handleValidationEach } = createHeadlessForm(result);
2198
+ handleValidation = handleValidationEach;
2199
+ });
2200
+
2201
+ it.each([
2202
+ [
2203
+ 'validation should return false when value is an empty object',
2204
+ {},
2205
+ {
2206
+ numberInput: 'Required field',
2207
+ textInput: 'Required field',
2208
+ },
2209
+ ],
2210
+ [
2211
+ 'validation should return false when value is an object with null values',
2212
+ { textInput: null, numberInput: null },
2213
+ { numberInput: 'Required field', textInput: 'Required field' },
2214
+ ],
2215
+ [
2216
+ 'validation should return false when value is an object with empty values',
2217
+ { textInput: '', numberInput: '' },
2218
+ { numberInput: 'The value must be a number', textInput: 'Required field' },
2219
+ ],
2220
+ [
2221
+ 'validation should return false when one value is empty',
2222
+ { textInput: '986-39-076', numberInput: '' },
2223
+ { numberInput: 'The value must be a number' },
2224
+ ],
2225
+ [
2226
+ 'validation should return false a numeric field is not a number',
2227
+ { textInput: '986-39-076', numberInput: 'not a number' },
2228
+ { numberInput: 'The value must be a number' },
2229
+ ],
2230
+ [
2231
+ 'validation should return true when object is valid',
2232
+ { textInput: 'abc-xy-asd', numberInput: 9 },
2233
+ undefined,
2234
+ ],
2235
+ ])('%s', (_, values, errors) => {
2236
+ const testValue = validateForm(values);
2237
+ if (errors) {
2238
+ expect(testValue).toEqual(errors);
2239
+ } else {
2240
+ expect(testValue).toBeUndefined();
2241
+ }
2242
+ });
2243
+
2244
+ describe('and one field has pattern validation', () => {
2245
+ beforeEach(() => {
2246
+ const result = JSONSchemaBuilder()
2247
+ .addInput({ patternTextInput: mockTextPatternInput })
2248
+ .setRequiredFields(['patternTextInput'])
2249
+ .build();
2250
+ const { handleValidation: handleValidationEach } = createHeadlessForm(result);
2251
+ handleValidation = handleValidationEach;
2252
+ });
2253
+
2254
+ it.each([
2255
+ [
2256
+ 'validation should return false when a value does not match a pattern',
2257
+ { patternTextInput: 'abc-xy-asd' },
2258
+ { patternTextInput: expect.stringMatching(/Must have a valid format. E.g./i) },
2259
+ ],
2260
+ [
2261
+ 'validation should return true when value matches the pattern',
2262
+ { patternTextInput: '986-39-0716' },
2263
+ undefined,
2264
+ ],
2265
+ ])('%s', (_, values, errors) => {
2266
+ const testValue = validateForm(values);
2267
+ if (errors) {
2268
+ expect(testValue).toEqual(errors);
2269
+ } else {
2270
+ expect(testValue).toBeUndefined();
2271
+ }
2272
+ });
2273
+ });
2274
+
2275
+ describe('and one field has max length validation', () => {
2276
+ beforeEach(() => {
2277
+ const result = JSONSchemaBuilder()
2278
+ .addInput({ maxLengthTextInput: mockTextMaxLengthInput })
2279
+ .setRequiredFields(['maxLengthTextInput'])
2280
+ .build();
2281
+ const { handleValidation: handleValidationEach } = createHeadlessForm(result);
2282
+ handleValidation = handleValidationEach;
2283
+ });
2284
+
2285
+ it.each([
2286
+ [
2287
+ 'validation should return false when a value is greater than the limit',
2288
+ { maxLengthTextInput: 'Hello John Dow' },
2289
+ { maxLengthTextInput: 'Please insert up to 10 characters' },
2290
+ ],
2291
+ [
2292
+ 'validation should return true when value is within the limit',
2293
+ { maxLengthTextInput: 'Hello John' },
2294
+ undefined,
2295
+ ],
2296
+ ])('%s', (_, values, errors) => {
2297
+ const testValue = validateForm(values);
2298
+ if (errors) {
2299
+ expect(testValue).toEqual(errors);
2300
+ } else {
2301
+ expect(testValue).toBeUndefined();
2302
+ }
2303
+ });
2304
+ });
2305
+ });
2306
+
2307
+ describe('and fields are dynamically required/optional', () => {
2308
+ it('applies correct validation for single-value based conditionals', async () => {
2309
+ const { fields, handleValidation } = createHeadlessForm(schemaDynamicValidationConst);
2310
+ const validateForm = (vals) => friendlyError(handleValidation(vals));
2311
+
2312
+ expect(
2313
+ validateForm({
2314
+ validate_tabs: 'no',
2315
+ a_fieldset: {
2316
+ id_number: 123,
2317
+ },
2318
+ mandatory_group_array: 'no',
2319
+ })
2320
+ ).toBeUndefined();
2321
+
2322
+ const getTabsField = () =>
2323
+ fields.find((f) => f.name === 'a_fieldset').fields.find((f) => f.name === 'tabs');
2324
+
2325
+ expect(getTabsField().required).toBeFalsy();
2326
+
2327
+ expect(
2328
+ validateForm({
2329
+ validate_tabs: 'yes',
2330
+ a_fieldset: {
2331
+ id_number: 123,
2332
+ },
2333
+ mandatory_group_array: 'no',
2334
+ })
2335
+ ).toEqual({
2336
+ a_fieldset: {
2337
+ tabs: 'Required field',
2338
+ },
2339
+ });
2340
+
2341
+ expect(getTabsField().required).toBeTruthy();
2342
+
2343
+ expect(
2344
+ validateForm({
2345
+ validate_tabs: 'yes',
2346
+ a_fieldset: {
2347
+ id_number: 123,
2348
+ },
2349
+ mandatory_group_array: 'yes',
2350
+ a_group_array: [{ full_name: 'adfs' }],
2351
+ })
2352
+ ).toEqual({ a_fieldset: { tabs: 'Required field' } });
2353
+
2354
+ expect(
2355
+ validateForm({
2356
+ validate_tabs: 'yes',
2357
+ a_fieldset: {
2358
+ id_number: 123,
2359
+ tabs: 2,
2360
+ },
2361
+ mandatory_group_array: 'no',
2362
+ })
2363
+ ).toBeUndefined();
2364
+ });
2365
+
2366
+ it('applies correct validation for minimum/maximum conditionals', async () => {
2367
+ const { handleValidation } = createHeadlessForm(schemaDynamicValidationMinimumMaximum);
2368
+ const validateForm = (vals) => friendlyError(handleValidation(vals));
2369
+
2370
+ // Check for minimum condition
2371
+ expect(
2372
+ validateForm({
2373
+ a_number: 0,
2374
+ })
2375
+ ).toEqual({
2376
+ a_conditional_text: 'Required field',
2377
+ a_number: 'Must be greater or equal to 1',
2378
+ });
2379
+
2380
+ // Check for maximum condition
2381
+ expect(
2382
+ validateForm({
2383
+ a_number: 11,
2384
+ })
2385
+ ).toEqual({
2386
+ a_conditional_text: 'Required field',
2387
+ a_number: 'Must be smaller or equal to 10',
2388
+ });
2389
+
2390
+ // Check for absence of a_number
2391
+ expect(validateForm({})).toEqual({
2392
+ a_conditional_text: 'Required field',
2393
+ });
2394
+
2395
+ // Check for number within range
2396
+ expect(
2397
+ validateForm({
2398
+ a_number: 5,
2399
+ })
2400
+ ).toBeUndefined();
2401
+ });
2402
+
2403
+ it('applies correct validation for minLength/maxLength conditionals', async () => {
2404
+ const { handleValidation } = createHeadlessForm(schemaDynamicValidationMinLengthMaxLength);
2405
+ const validateForm = (vals) => friendlyError(handleValidation(vals));
2406
+ const formError = {
2407
+ a_conditional_text: 'Required field',
2408
+ };
2409
+ // By default a_conditional_text is required.
2410
+ expect(validateForm({})).toEqual(formError);
2411
+
2412
+ // Check for minimum length condition - a_text >= 3 chars
2413
+ expect(
2414
+ validateForm({
2415
+ a_text: 'Foo',
2416
+ })
2417
+ ).toBeUndefined();
2418
+
2419
+ // Check for maximum length condition - a_text <= 5 chars
2420
+ expect(
2421
+ validateForm({
2422
+ a_text: 'Fooba',
2423
+ })
2424
+ ).toBeUndefined();
2425
+
2426
+ // Check for text out of length range (7 chars)
2427
+ expect(
2428
+ validateForm({
2429
+ a_text: 'Foobaaz',
2430
+ })
2431
+ ).toEqual(formError);
2432
+
2433
+ // Check for text out of length range (2 chars)
2434
+ expect(
2435
+ validateForm({
2436
+ a_text: 'Fe',
2437
+ })
2438
+ ).toEqual(formError);
2439
+ });
2440
+
2441
+ it('applies correct validation for array-contain based conditionals', async () => {
2442
+ const { handleValidation } = createHeadlessForm(schemaDynamicValidationContains);
2443
+ const validateForm = (vals) => friendlyError(handleValidation(vals));
2444
+
2445
+ expect(
2446
+ validateForm({
2447
+ validate_fieldset: ['id_number'],
2448
+ a_fieldset: {
2449
+ id_number: 123,
2450
+ },
2451
+ })
2452
+ ).toBeUndefined();
2453
+
2454
+ expect(
2455
+ validateForm({
2456
+ validate_fieldset: ['id_number', 'all'],
2457
+ a_fieldset: {
2458
+ id_number: 123,
2459
+ },
2460
+ })
2461
+ ).toEqual({
2462
+ a_fieldset: {
2463
+ tabs: 'Required field',
2464
+ },
2465
+ });
2466
+
2467
+ expect(
2468
+ validateForm({
2469
+ validate_fieldset: ['id_number', 'all'],
2470
+ a_fieldset: {
2471
+ id_number: 123,
2472
+ tabs: 2,
2473
+ },
2474
+ })
2475
+ ).toBeUndefined();
2476
+ });
2477
+
2478
+ it('applies correct validation for fieldset fields', async () => {
2479
+ const { handleValidation } = createHeadlessForm(schemaDynamicValidationContains);
2480
+ const validateForm = (vals) => friendlyError(handleValidation(vals));
2481
+
2482
+ expect(
2483
+ validateForm({
2484
+ validate_fieldset: ['id_number'],
2485
+ a_fieldset: {
2486
+ id_number: 123,
2487
+ },
2488
+ })
2489
+ ).toBeUndefined();
2490
+
2491
+ expect(
2492
+ validateForm({
2493
+ validate_fieldset: ['id_number', 'all'],
2494
+ a_fieldset: {
2495
+ id_number: 123,
2496
+ },
2497
+ })
2498
+ ).toEqual({
2499
+ a_fieldset: {
2500
+ tabs: 'Required field',
2501
+ },
2502
+ });
2503
+
2504
+ expect(
2505
+ validateForm({
2506
+ validate_fieldset: ['id_number', 'all'],
2507
+ a_fieldset: {
2508
+ id_number: 123,
2509
+ tabs: 2,
2510
+ },
2511
+ })
2512
+ ).toBeUndefined();
2513
+ });
2514
+
2515
+ it('applies any of the validation alternatives in a anyOf branch', async () => {
2516
+ const { handleValidation } = createHeadlessForm(schemaAnyOfValidation);
2517
+ const validateForm = (vals) => friendlyError(handleValidation(vals));
2518
+
2519
+ expect(
2520
+ validateForm({
2521
+ field_a: '123',
2522
+ })
2523
+ ).toBeUndefined();
2524
+
2525
+ expect(
2526
+ validateForm({
2527
+ field_b: '456',
2528
+ })
2529
+ ).toEqual({ field_c: 'Required field' });
2530
+
2531
+ expect(
2532
+ validateForm({
2533
+ field_b: '456',
2534
+ field_c: '789',
2535
+ })
2536
+ ).toBeUndefined();
2537
+
2538
+ expect(
2539
+ validateForm({
2540
+ field_a: '123',
2541
+ field_c: '789',
2542
+ })
2543
+ ).toBeUndefined();
2544
+
2545
+ expect(
2546
+ validateForm({
2547
+ field_a: '123',
2548
+ field_b: '456',
2549
+ field_c: '789',
2550
+ })
2551
+ ).toBeUndefined();
2552
+ });
2553
+
2554
+ describe('nested conditionals', () => {
2555
+ it('given empty values, runs "else" (gets hidden)', () => {
2556
+ const { fields } = createHeadlessForm(schemaWithConditionalReadOnlyProperty, {
2557
+ field_a: null,
2558
+ });
2559
+ expect(getByName(fields, 'field_b').isVisible).toBe(false);
2560
+ });
2561
+
2562
+ it('given a match, runs "then" (turns visible and editable)', () => {
2563
+ const { fields } = createHeadlessForm(schemaWithConditionalReadOnlyProperty, {
2564
+ initialValues: { field_a: 'yes' },
2565
+ });
2566
+ expect(getByName(fields, 'field_b').isVisible).toBe(true);
2567
+ expect(getByName(fields, 'field_b').readOnly).toBe(false);
2568
+ });
2569
+
2570
+ it('given a nested match, runs "else-then" (turns visible but readOnly)', () => {
2571
+ const { fields } = createHeadlessForm(schemaWithConditionalReadOnlyProperty, {
2572
+ initialValues: { field_a: 'no' },
2573
+ });
2574
+ expect(getByName(fields, 'field_b').isVisible).toBe(true);
2575
+ expect(getByName(fields, 'field_b').readOnly).toBe(true);
2576
+ });
2577
+ });
2578
+
2579
+ describe('conditional fields (incorrectly done)', () => {
2580
+ // this catches the typical scenario where developers forget to set the if.required[]
2581
+
2582
+ it('given empty values, the incorrect conditional runs "then" instead of "else"', () => {
2583
+ const { fields: fieldsEmpty } = createHeadlessForm(schemaWithWrongConditional, {
2584
+ initialValues: { field_a: null, field_a_wrong: null },
2585
+ });
2586
+ // The dependent correct field gets hidden, but...
2587
+ expect(getByName(fieldsEmpty, 'field_b').isVisible).toBe(false);
2588
+ // ...the dependent wrong field stays visible because the
2589
+ // conditional is wrong (it's missing the if.required[])
2590
+ expect(getByName(fieldsEmpty, 'field_b_wrong').isVisible).toBe(true);
2591
+ });
2592
+
2593
+ it('given a match ("yes"), both runs "then" (turn visible)', () => {
2594
+ const { fields: fieldsVisible } = createHeadlessForm(schemaWithWrongConditional, {
2595
+ initialValues: { field_a: 'yes', field_a_wrong: 'yes' },
2596
+ });
2597
+ expect(getByName(fieldsVisible, 'field_b').isVisible).toBe(true);
2598
+ expect(getByName(fieldsVisible, 'field_b_wrong').isVisible).toBe(true);
2599
+ });
2600
+
2601
+ it('not given a match ("no"), both run else (stay hidden)', () => {
2602
+ const { fields: fieldsHidden } = createHeadlessForm(schemaWithWrongConditional, {
2603
+ initialValues: { field_a: 'no', field_a_wrong: 'no' },
2604
+ });
2605
+ expect(getByName(fieldsHidden, 'field_b').isVisible).toBe(false);
2606
+ expect(getByName(fieldsHidden, 'field_b_wrong').isVisible).toBe(false);
2607
+ });
2608
+ });
2609
+
2610
+ it('checkbox should have no initial value when its dynamically shown and invisible', () => {
2611
+ const { fields } = createHeadlessForm(schemaWithConditionalAcknowledgementProperty, {
2612
+ initialValues: {
2613
+ field_a: 'no',
2614
+ },
2615
+ });
2616
+ const dependentField = getByName(fields, 'field_b');
2617
+ expect(dependentField.isVisible).toBe(false);
2618
+ expect(dependentField.value).toBe(undefined);
2619
+ });
2620
+
2621
+ it('checkbox should have no initial value when its dynamically shown and visible', () => {
2622
+ const { fields } = createHeadlessForm(schemaWithConditionalAcknowledgementProperty, {
2623
+ initialValues: {
2624
+ field_a: 'yes',
2625
+ },
2626
+ });
2627
+ const dependentField = getByName(fields, 'field_b');
2628
+ expect(dependentField.isVisible).toBe(true);
2629
+ expect(dependentField.value).toBe(undefined);
2630
+ });
2631
+ });
2632
+ });
2633
+
2634
+ // TODO: delete after migration to x-jsf-errorMessage is completed
2635
+ describe('Throwing custom error messages using errorMessage (deprecated)', () => {
2636
+ it.each([
2637
+ [
2638
+ 'type',
2639
+ JSONSchemaBuilder()
2640
+ .addInput({
2641
+ numberInput: {
2642
+ ...mockNumberInput,
2643
+ errorMessage: { type: 'It has to be a number.' },
2644
+ },
2645
+ })
2646
+ .build(),
2647
+ { numberInput: 'Two' },
2648
+ {
2649
+ numberInput: 'It has to be a number.',
2650
+ },
2651
+ false,
2652
+ ],
2653
+ [
2654
+ 'minimum',
2655
+ JSONSchemaBuilder()
2656
+ .addInput({
2657
+ numberInput: {
2658
+ ...mockNumberInput,
2659
+ errorMessage: { minimum: 'I am a custom error message' },
2660
+ },
2661
+ })
2662
+ .build(),
2663
+ { numberInput: -1 },
2664
+ {
2665
+ numberInput: 'I am a custom error message',
2666
+ },
2667
+ false,
2668
+ ],
2669
+ [
2670
+ 'required',
2671
+ JSONSchemaBuilder()
2672
+ .addInput({
2673
+ numberInput: {
2674
+ ...mockNumberInput,
2675
+ errorMessage: { required: 'I am a custom error message' },
2676
+ },
2677
+ })
2678
+ .setRequiredFields(['numberInput'])
2679
+ .build(),
2680
+ {},
2681
+ {
2682
+ numberInput: 'I am a custom error message',
2683
+ },
2684
+ ],
2685
+ [
2686
+ 'required (ignored because it is optional)',
2687
+ JSONSchemaBuilder()
2688
+ .addInput({
2689
+ numberInput: {
2690
+ ...mockNumberInput,
2691
+ errorMessage: { required: 'I am a custom error message' },
2692
+ },
2693
+ })
2694
+ .build(),
2695
+ {},
2696
+ undefined,
2697
+ ],
2698
+ [
2699
+ 'maximum',
2700
+ JSONSchemaBuilder()
2701
+ .addInput({
2702
+ numberInput: {
2703
+ ...mockNumberInput,
2704
+ errorMessage: { maximum: 'I am a custom error message' },
2705
+ },
2706
+ })
2707
+ .build(),
2708
+ { numberInput: 11 },
2709
+ {
2710
+ numberInput: 'I am a custom error message',
2711
+ },
2712
+ ],
2713
+ [
2714
+ 'minLength',
2715
+ JSONSchemaBuilder()
2716
+ .addInput({
2717
+ stringInput: {
2718
+ ...mockTextInput,
2719
+ minLength: 3,
2720
+ errorMessage: { minLength: 'I am a custom error message' },
2721
+ },
2722
+ })
2723
+ .build(),
2724
+ { stringInput: 'aa' },
2725
+ {
2726
+ stringInput: 'I am a custom error message',
2727
+ },
2728
+ ],
2729
+ [
2730
+ 'maxLength',
2731
+ JSONSchemaBuilder()
2732
+ .addInput({
2733
+ stringInput: {
2734
+ ...mockTextInput,
2735
+ maxLength: 3,
2736
+ errorMessage: { maxLength: 'I am a custom error message' },
2737
+ },
2738
+ })
2739
+ .build(),
2740
+ { stringInput: 'aaaa' },
2741
+ {
2742
+ stringInput: 'I am a custom error message',
2743
+ },
2744
+ ],
2745
+ [
2746
+ 'pattern',
2747
+ JSONSchemaBuilder()
2748
+ .addInput({
2749
+ stringInput: {
2750
+ ...mockTextInput,
2751
+ pattern: '^(\\+|00)\\d*$',
2752
+ errorMessage: { pattern: 'I am a custom error message' },
2753
+ },
2754
+ })
2755
+ .build(),
2756
+ { stringInput: 'aaaa' },
2757
+ {
2758
+ stringInput: 'I am a custom error message',
2759
+ },
2760
+ ],
2761
+ [
2762
+ 'maxFileSize',
2763
+ JSONSchemaBuilder()
2764
+ .addInput({
2765
+ fileInput: {
2766
+ ...mockFileInput,
2767
+ 'x-jsf-presentation': {
2768
+ ...mockFileInput['x-jsf-presentation'],
2769
+ maxFileSize: 1000,
2770
+ },
2771
+ errorMessage: { maxFileSize: 'I am a custom error message' },
2772
+ },
2773
+ })
2774
+ .build(),
2775
+ {
2776
+ fileInput: [
2777
+ (() => {
2778
+ const file = new File([''], 'file.png');
2779
+ Object.defineProperty(file, 'size', { value: 1024 * 1024 * 1024 });
2780
+ return file;
2781
+ })(),
2782
+ ],
2783
+ },
2784
+ {
2785
+ fileInput: 'I am a custom error message',
2786
+ },
2787
+ ],
2788
+ [
2789
+ 'accept',
2790
+ JSONSchemaBuilder()
2791
+ .addInput({
2792
+ fileInput: {
2793
+ ...mockFileInput,
2794
+ accept: '.pdf',
2795
+ errorMessage: { accept: 'I am a custom error message' },
2796
+ },
2797
+ })
2798
+ .build(),
2799
+ {
2800
+ fileInput: [new File([''], 'file.docx')],
2801
+ },
2802
+ {
2803
+ fileInput: 'I am a custom error message',
2804
+ },
2805
+ ],
2806
+ ])('error message for property "%s"', (_, schema, input, errors) => {
2807
+ const { handleValidation } = createHeadlessForm(schema);
2808
+ const validateForm = (vals) => friendlyError(handleValidation(vals));
2809
+
2810
+ if (errors) {
2811
+ expect(validateForm(input)).toEqual(errors);
2812
+ } else {
2813
+ expect(validateForm(input)).toBeUndefined();
2814
+ }
2815
+ });
2816
+ });
2817
+
2818
+ describe('Custom error messages', () => {
2819
+ it.each([
2820
+ [
2821
+ 'type',
2822
+ JSONSchemaBuilder()
2823
+ .addInput({
2824
+ numberInput: {
2825
+ ...mockNumberInput,
2826
+ 'x-jsf-errorMessage': { type: 'It has to be a number.' },
2827
+ },
2828
+ })
2829
+ .build(),
2830
+ { numberInput: 'Two' },
2831
+ {
2832
+ numberInput: 'It has to be a number.',
2833
+ },
2834
+ false,
2835
+ ],
2836
+ [
2837
+ 'minimum',
2838
+ JSONSchemaBuilder()
2839
+ .addInput({
2840
+ numberInput: {
2841
+ ...mockNumberInput,
2842
+ 'x-jsf-errorMessage': { minimum: 'I am a custom error message' },
2843
+ },
2844
+ })
2845
+ .build(),
2846
+ { numberInput: -1 },
2847
+ {
2848
+ numberInput: 'I am a custom error message',
2849
+ },
2850
+ false,
2851
+ ],
2852
+ [
2853
+ 'required',
2854
+ JSONSchemaBuilder()
2855
+ .addInput({
2856
+ numberInput: {
2857
+ ...mockNumberInput,
2858
+ 'x-jsf-errorMessage': { required: 'I am a custom error message' },
2859
+ },
2860
+ })
2861
+ .setRequiredFields(['numberInput'])
2862
+ .build(),
2863
+ {},
2864
+ {
2865
+ numberInput: 'I am a custom error message',
2866
+ },
2867
+ ],
2868
+ [
2869
+ 'required (ignored because it is optional)',
2870
+ JSONSchemaBuilder()
2871
+ .addInput({
2872
+ numberInput: {
2873
+ ...mockNumberInput,
2874
+ 'x-jsf-errorMessage': { required: 'I am a custom error message' },
2875
+ },
2876
+ })
2877
+ .build(),
2878
+ {},
2879
+ undefined,
2880
+ ],
2881
+ [
2882
+ 'maximum',
2883
+ JSONSchemaBuilder()
2884
+ .addInput({
2885
+ numberInput: {
2886
+ ...mockNumberInput,
2887
+ 'x-jsf-errorMessage': { maximum: 'I am a custom error message' },
2888
+ },
2889
+ })
2890
+ .build(),
2891
+ { numberInput: 11 },
2892
+ {
2893
+ numberInput: 'I am a custom error message',
2894
+ },
2895
+ ],
2896
+ [
2897
+ 'minLength',
2898
+ JSONSchemaBuilder()
2899
+ .addInput({
2900
+ stringInput: {
2901
+ ...mockTextInput,
2902
+ minLength: 3,
2903
+ 'x-jsf-errorMessage': { minLength: 'I am a custom error message' },
2904
+ },
2905
+ })
2906
+ .build(),
2907
+ { stringInput: 'aa' },
2908
+ {
2909
+ stringInput: 'I am a custom error message',
2910
+ },
2911
+ ],
2912
+ [
2913
+ 'maxLength',
2914
+ JSONSchemaBuilder()
2915
+ .addInput({
2916
+ stringInput: {
2917
+ ...mockTextInput,
2918
+ maxLength: 3,
2919
+ 'x-jsf-errorMessage': { maxLength: 'I am a custom error message' },
2920
+ },
2921
+ })
2922
+ .build(),
2923
+ { stringInput: 'aaaa' },
2924
+ {
2925
+ stringInput: 'I am a custom error message',
2926
+ },
2927
+ ],
2928
+ [
2929
+ 'pattern',
2930
+ JSONSchemaBuilder()
2931
+ .addInput({
2932
+ stringInput: {
2933
+ ...mockTextInput,
2934
+ pattern: '^(\\+|00)\\d*$',
2935
+ 'x-jsf-errorMessage': { pattern: 'I am a custom error message' },
2936
+ },
2937
+ })
2938
+ .build(),
2939
+ { stringInput: 'aaaa' },
2940
+ {
2941
+ stringInput: 'I am a custom error message',
2942
+ },
2943
+ ],
2944
+ [
2945
+ 'maxFileSize',
2946
+ JSONSchemaBuilder()
2947
+ .addInput({
2948
+ fileInput: {
2949
+ ...mockFileInput,
2950
+ 'x-jsf-presentation': {
2951
+ ...mockFileInput['x-jsf-presentation'],
2952
+ maxFileSize: 1000,
2953
+ },
2954
+ 'x-jsf-errorMessage': { maxFileSize: 'I am a custom error message' },
2955
+ },
2956
+ })
2957
+ .build(),
2958
+ {
2959
+ fileInput: [
2960
+ (() => {
2961
+ const file = new File([''], 'file.png');
2962
+ Object.defineProperty(file, 'size', { value: 1024 * 1024 * 1024 });
2963
+ return file;
2964
+ })(),
2965
+ ],
2966
+ },
2967
+ {
2968
+ fileInput: 'I am a custom error message',
2969
+ },
2970
+ ],
2971
+ [
2972
+ 'accept',
2973
+ JSONSchemaBuilder()
2974
+ .addInput({
2975
+ fileInput: {
2976
+ ...mockFileInput,
2977
+ accept: '.pdf',
2978
+ 'x-jsf-errorMessage': { accept: 'I am a custom error message' },
2979
+ },
2980
+ })
2981
+ .build(),
2982
+ {
2983
+ fileInput: [new File([''], 'file.docx')],
2984
+ },
2985
+ {
2986
+ fileInput: 'I am a custom error message',
2987
+ },
2988
+ ],
2989
+ ])('error message for property "%s"', (_, schema, input, errors) => {
2990
+ const { handleValidation } = createHeadlessForm(schema);
2991
+ const validateForm = (vals) => friendlyError(handleValidation(vals));
2992
+
2993
+ if (errors) {
2994
+ expect(validateForm(input)).toEqual(errors);
2995
+ } else {
2996
+ expect(validateForm(input)).toBeUndefined();
2997
+ }
2998
+ });
2999
+
3000
+ it('accepts with options.inputType[].errorMessage', () => {
3001
+ // Sanity-check the default error message
3002
+ const resultDefault = createHeadlessForm(schemaForErrorMessageSpecificity);
3003
+ expect(resultDefault.handleValidation({}).formErrors).toEqual({
3004
+ weekday: 'Required field',
3005
+ day: 'Required field',
3006
+ month: 'Required field',
3007
+ year: 'The year is mandatory.', // from x-jsf-errorMessage
3008
+ });
3009
+
3010
+ // Assert the custom error message
3011
+ const resultCustom = createHeadlessForm(schemaForErrorMessageSpecificity, {
3012
+ ...jsfConfigForErrorMessageSpecificity,
3013
+ });
3014
+ expect(resultCustom.handleValidation({}).formErrors).toEqual({
3015
+ weekday: 'Required field', // sanity-check that a different inputType keeps the default error msg.
3016
+ day: 'This cannot be empty.',
3017
+ month: 'This cannot be empty.',
3018
+ year: 'The year is mandatory.', // error specificity: schema's msg is higher than options' msg.
3019
+ });
3020
+ });
3021
+ });
3022
+
3023
+ describe('when default values are provided', () => {
3024
+ describe('and "fieldset" has scoped conditionals', () => {
3025
+ it('should show conditionals fields when values fullfil conditions', () => {
3026
+ const result = createHeadlessForm(schemaFieldsetScopedCondition, {
3027
+ initialValues: { child: { has_child: 'yes' } },
3028
+ });
3029
+
3030
+ const fieldset = result.fields[0];
3031
+
3032
+ expect(fieldset).toMatchObject({
3033
+ fields: [
3034
+ {
3035
+ name: 'has_child',
3036
+ required: true,
3037
+ },
3038
+ {
3039
+ name: 'age',
3040
+ required: true,
3041
+ isVisible: true,
3042
+ },
3043
+ {
3044
+ name: 'passport_id',
3045
+ required: false,
3046
+ isVisible: true,
3047
+ },
3048
+ ],
3049
+ });
3050
+ });
3051
+
3052
+ it('should hide conditionals fields when values do not fullfil conditions', () => {
3053
+ const result = createHeadlessForm(schemaFieldsetScopedCondition, {
3054
+ child: { has_child: 'no' },
3055
+ });
3056
+
3057
+ const fieldset = result.fields[0];
3058
+
3059
+ expect(fieldset).toMatchObject({
3060
+ fields: [
3061
+ {
3062
+ name: 'has_child',
3063
+ required: true,
3064
+ },
3065
+ {
3066
+ name: 'age',
3067
+ required: false,
3068
+ isVisible: false,
3069
+ },
3070
+ {
3071
+ name: 'passport_id',
3072
+ required: false,
3073
+ isVisible: false,
3074
+ },
3075
+ ],
3076
+ });
3077
+ });
3078
+ });
3079
+ });
3080
+
3081
+ describe('parser options', () => {
3082
+ it('should support any custom field attribute', () => {
3083
+ const customAttrs = {
3084
+ something: 'foo', // a misc attribute
3085
+ inputType: 'super', // overrides "textarea"
3086
+ falsy: false, // accepts falsy attributes
3087
+ };
3088
+ const result = createHeadlessForm(
3089
+ {
3090
+ properties: {
3091
+ feedback: {
3092
+ title: 'Your feedback',
3093
+ type: 'string',
3094
+ presentation: {
3095
+ inputType: 'textarea',
3096
+ },
3097
+ },
3098
+ },
3099
+ },
3100
+ {
3101
+ customProperties: {
3102
+ feedback: {
3103
+ ...customAttrs,
3104
+ },
3105
+ },
3106
+ }
3107
+ );
3108
+
3109
+ expect(result).toMatchObject({
3110
+ fields: [
3111
+ {
3112
+ name: 'feedback',
3113
+ label: 'Your feedback',
3114
+ jsonType: 'string',
3115
+ ...customAttrs,
3116
+ },
3117
+ ],
3118
+ });
3119
+ });
3120
+
3121
+ it('should support custom description (checkbox)', () => {
3122
+ const result = createHeadlessForm(
3123
+ {
3124
+ properties: {
3125
+ terms: {
3126
+ const: 'Agreed',
3127
+ title: 'Terms',
3128
+ description: 'Accept terms.',
3129
+ type: 'string',
3130
+ presentation: { inputType: 'checkbox' },
3131
+ },
3132
+ },
3133
+ },
3134
+ {
3135
+ customProperties: {
3136
+ terms: {
3137
+ description: (text) => `Extra text before. ${text}`,
3138
+ },
3139
+ },
3140
+ }
3141
+ );
3142
+
3143
+ expect(result).toMatchObject({
3144
+ fields: [
3145
+ {
3146
+ label: 'Terms',
3147
+ description: 'Extra text before. Accept terms.', // ensure custom description works
3148
+ name: 'terms',
3149
+ required: false,
3150
+ inputType: 'checkbox',
3151
+ type: 'checkbox',
3152
+ jsonType: 'string',
3153
+ checkboxValue: 'Agreed', // ensure _composeFieldCheckbox(). transformations are passed.
3154
+ },
3155
+ ],
3156
+ });
3157
+
3158
+ // ensure _composeFieldCheckbox() "value" destructure happens.
3159
+ expect(result.fields[0]).not.toHaveProperty('value');
3160
+ });
3161
+
3162
+ it('should ignore fields that are not present in the schema', () => {
3163
+ const schemaBase = {
3164
+ properties: {
3165
+ feedback: {
3166
+ title: 'Your feedback',
3167
+ type: 'string',
3168
+ presentation: {
3169
+ inputType: 'textarea',
3170
+ },
3171
+ },
3172
+ },
3173
+ };
3174
+
3175
+ const resultWithoutCustomProperties = createHeadlessForm(schemaBase);
3176
+ const resultWithInvalidCustomProperty = createHeadlessForm(schemaBase, {
3177
+ customProperties: {
3178
+ unknown: {
3179
+ 'data-foo': 'baz',
3180
+ },
3181
+ },
3182
+ });
3183
+
3184
+ function assertResultHasNoCustomizations(result) {
3185
+ expect(result.fields).toHaveLength(1); // The "unknown" is not present
3186
+ expect(result.fields[0].name).toBe('feedback');
3187
+ expect(result.fields[0]).not.toHaveProperty('data-foo');
3188
+ }
3189
+
3190
+ assertResultHasNoCustomizations(resultWithoutCustomProperties);
3191
+ assertResultHasNoCustomizations(resultWithInvalidCustomProperty);
3192
+ });
3193
+
3194
+ it('should handle custom properties when inside fieldsets', () => {
3195
+ const result = createHeadlessForm(
3196
+ JSONSchemaBuilder()
3197
+ .addInput({
3198
+ id_number: mockNumberInput,
3199
+ })
3200
+ .addInput({
3201
+ fieldset: mockFieldset,
3202
+ })
3203
+ .addInput({ nestedFieldset: mockNestedFieldset })
3204
+ .build(),
3205
+ {
3206
+ customProperties: {
3207
+ id_number: { 'data-field': 'field' },
3208
+ fieldset: {
3209
+ id_number: { 'data-fieldset': 'fieldset' },
3210
+ },
3211
+ nestedFieldset: {
3212
+ innerFieldset: {
3213
+ id_number: { 'data-nested-fieldset': 'nested-fieldset' },
3214
+ },
3215
+ },
3216
+ },
3217
+ }
3218
+ );
3219
+
3220
+ expect(result).toMatchObject({
3221
+ fields: [
3222
+ {
3223
+ 'data-field': 'field',
3224
+ name: 'id_number',
3225
+ },
3226
+ {
3227
+ name: 'fieldset',
3228
+ fields: [
3229
+ {
3230
+ name: 'id_number',
3231
+ 'data-fieldset': 'fieldset',
3232
+ },
3233
+ {
3234
+ name: 'tabs',
3235
+ },
3236
+ ],
3237
+ },
3238
+ {
3239
+ name: 'nestedFieldset',
3240
+ fields: [
3241
+ {
3242
+ name: 'innerFieldset',
3243
+ fields: [
3244
+ {
3245
+ name: 'id_number',
3246
+ 'data-nested-fieldset': 'nested-fieldset',
3247
+ },
3248
+ {
3249
+ name: 'tabs',
3250
+ },
3251
+ ],
3252
+ },
3253
+ ],
3254
+ },
3255
+ ],
3256
+ });
3257
+
3258
+ const [fieldResult, fildsetResult, nestedFieldsetResult] = result.fields;
3259
+
3260
+ // Sanity check that custom attrs are not "leaked" into other fields
3261
+ // $.id_number
3262
+ expect(fieldResult).toHaveProperty('name', 'id_number');
3263
+ expect(fieldResult).toHaveProperty('data-field', 'field');
3264
+ expect(fieldResult).not.toHaveProperty('data-fieldset');
3265
+ expect(fieldResult).not.toHaveProperty('data-nested-fieldset');
3266
+
3267
+ // $.fieldset.id_number
3268
+ expect(fildsetResult.fields[0]).toHaveProperty('name', 'id_number');
3269
+ expect(fildsetResult.fields[0]).toHaveProperty('data-fieldset', 'fieldset');
3270
+ expect(fildsetResult.fields[0]).not.toHaveProperty('data-field');
3271
+ expect(fildsetResult.fields[0]).not.toHaveProperty('data-nested-fieldset');
3272
+ expect(fildsetResult.fields[1]).not.toHaveProperty('data-field');
3273
+ expect(fildsetResult.fields[1]).not.toHaveProperty('data-nested-fieldset');
3274
+
3275
+ // $.nestedFieldset.innerFieldset.id_number
3276
+ expect(nestedFieldsetResult.fields[0].fields[0]).toHaveProperty('name', 'id_number');
3277
+ expect(nestedFieldsetResult.fields[0].fields[0]).toHaveProperty(
3278
+ 'data-nested-fieldset',
3279
+ 'nested-fieldset'
3280
+ );
3281
+ expect(nestedFieldsetResult.fields[0].fields[0]).not.toHaveProperty('data-field');
3282
+ expect(nestedFieldsetResult.fields[0].fields[0]).not.toHaveProperty('data-fieldset');
3283
+ expect(nestedFieldsetResult.fields[0].fields[1]).not.toHaveProperty('data-field');
3284
+ expect(nestedFieldsetResult.fields[0].fields[1]).not.toHaveProperty('data-fieldset');
3285
+ });
3286
+ });
3287
+
3288
+ describe('presentation (deprecated in favor of x-jsf-presentation)', () => {
3289
+ it('works well with position, description, inputType, and any other arbitrary attribute', () => {
3290
+ const { fields } = createHeadlessForm({
3291
+ properties: {
3292
+ day: {
3293
+ title: 'Date',
3294
+ presentation: {
3295
+ inputType: 'date',
3296
+ position: 1,
3297
+ foo: 'bar',
3298
+ statement: {
3299
+ description: 'ss',
3300
+ },
3301
+ },
3302
+ },
3303
+ time: {
3304
+ title: 'Time',
3305
+ presentation: {
3306
+ inputType: 'clock',
3307
+ description: 'Write in <b>hh:ss</b> format',
3308
+ position: 0,
3309
+ deprecated: {
3310
+ description: 'In favor of X',
3311
+ },
3312
+ },
3313
+ },
3314
+ },
3315
+ });
3316
+
3317
+ // Assert order from presentation.position
3318
+ expect(fields[0].name).toBe('time');
3319
+ expect(fields[1].name).toBe('day');
3320
+
3321
+ // Assert spreaded attributes
3322
+ expect(fields).toMatchObject([
3323
+ {
3324
+ name: 'time',
3325
+ description: '<span class="jsf-description">Write in <b>hh:ss</b> format</span>', // from presentation
3326
+ inputType: 'clock', // arbitrary type from presentation
3327
+ deprecated: {
3328
+ description: 'In favor of X', // from presentation
3329
+ },
3330
+ },
3331
+ {
3332
+ name: 'day',
3333
+ inputType: 'date', // arbitrary type from presentation
3334
+ foo: 'bar', // spread from presentation
3335
+ statement: {
3336
+ // from presentation
3337
+ description: 'ss',
3338
+ },
3339
+ },
3340
+ ]);
3341
+ });
3342
+ });
3343
+ });