@planning-inspectorate/dynamic-forms 3.14.0 → 3.15.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.
Files changed (49) hide show
  1. package/package.json +1 -1
  2. package/src/components/address/question.js +14 -0
  3. package/src/components/address/question.test.js +13 -0
  4. package/src/components/date/question.js +8 -0
  5. package/src/components/date/question.test.js +17 -0
  6. package/src/components/date-period/question.js +15 -0
  7. package/src/components/date-period/question.test.js +20 -0
  8. package/src/components/date-time/question.js +15 -0
  9. package/src/components/date-time/question.test.js +11 -0
  10. package/src/components/multi-field-input/question.js +8 -0
  11. package/src/components/multi-field-input/question.test.js +7 -0
  12. package/src/questions/question.js +11 -0
  13. package/src/questions/question.test.js +7 -0
  14. package/src/validator/address-validator.js +1 -1
  15. package/src/validator/base-validator.js +1 -1
  16. package/src/validator/conditional-required-validator.js +1 -1
  17. package/src/validator/cross-question-validator.README.md +131 -0
  18. package/src/validator/cross-question-validator.js +48 -5
  19. package/src/validator/cross-question-validator.test.js +448 -11
  20. package/src/validator/date-period-validator.js +1 -5
  21. package/src/validator/date-time-validator.js +1 -5
  22. package/src/validator/date-validator.js +1 -5
  23. package/src/validator/required-validator.js +1 -5
  24. package/src/validator/unit-option-entry-validator.js +1 -5
  25. package/types/src/components/address/question.d.ts.map +1 -1
  26. package/types/src/components/date/question.d.ts.map +1 -1
  27. package/types/src/components/date-period/question.d.ts.map +1 -1
  28. package/types/src/components/date-time/question.d.ts.map +1 -1
  29. package/types/src/components/multi-field-input/question.d.ts.map +1 -1
  30. package/types/src/questions/question.d.ts +8 -0
  31. package/types/src/questions/question.d.ts.map +1 -1
  32. package/types/src/validator/address-validator.d.ts +2 -2
  33. package/types/src/validator/address-validator.d.ts.map +1 -1
  34. package/types/src/validator/base-validator.d.ts +4 -2
  35. package/types/src/validator/base-validator.d.ts.map +1 -1
  36. package/types/src/validator/conditional-required-validator.d.ts +2 -2
  37. package/types/src/validator/conditional-required-validator.d.ts.map +1 -1
  38. package/types/src/validator/cross-question-validator.d.ts +11 -4
  39. package/types/src/validator/cross-question-validator.d.ts.map +1 -1
  40. package/types/src/validator/date-period-validator.d.ts +2 -6
  41. package/types/src/validator/date-period-validator.d.ts.map +1 -1
  42. package/types/src/validator/date-time-validator.d.ts +2 -6
  43. package/types/src/validator/date-time-validator.d.ts.map +1 -1
  44. package/types/src/validator/date-validator.d.ts +2 -6
  45. package/types/src/validator/date-validator.d.ts.map +1 -1
  46. package/types/src/validator/required-validator.d.ts +2 -6
  47. package/types/src/validator/required-validator.d.ts.map +1 -1
  48. package/types/src/validator/unit-option-entry-validator.d.ts +2 -6
  49. package/types/src/validator/unit-option-entry-validator.d.ts.map +1 -1
@@ -14,6 +14,89 @@ function createMockRequest(answers) {
14
14
  };
15
15
  }
16
16
 
17
+ /**
18
+ * Creates a mock question object with a simple getDataToSave that returns the body field value.
19
+ * @param {string} fieldName
20
+ * @returns {{fieldName: string, bodyFieldNames: string[], getDataToSave: Function}}
21
+ */
22
+ function createMockQuestion(fieldName) {
23
+ return {
24
+ fieldName,
25
+ bodyFieldNames: [fieldName],
26
+ getDataToSave: async (req) => ({
27
+ answers: {
28
+ [fieldName]: req.body[fieldName]
29
+ }
30
+ })
31
+ };
32
+ }
33
+
34
+ /**
35
+ * Creates a mock date question that extracts date from day/month/year fields.
36
+ * @param {string} fieldName
37
+ * @returns {{fieldName: string, bodyFieldNames: string[], getDataToSave: Function}}
38
+ */
39
+ function createMockDateQuestion(fieldName) {
40
+ return {
41
+ fieldName,
42
+ bodyFieldNames: [`${fieldName}_day`, `${fieldName}_month`, `${fieldName}_year`],
43
+ getDataToSave: async (req) => {
44
+ const day = req.body[`${fieldName}_day`];
45
+ const month = req.body[`${fieldName}_month`];
46
+ const year = req.body[`${fieldName}_year`];
47
+ if (!day || !month || !year) {
48
+ throw new Error(`Incomplete date fields for ${fieldName}`);
49
+ }
50
+ return {
51
+ answers: {
52
+ [fieldName]: new Date(year, month - 1, day)
53
+ }
54
+ };
55
+ }
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Creates a mock date-period question that extracts start/end dates.
61
+ * @param {string} fieldName
62
+ * @returns {{fieldName: string, bodyFieldNames: string[], getDataToSave: Function}}
63
+ */
64
+ function createMockDatePeriodQuestion(fieldName) {
65
+ return {
66
+ fieldName,
67
+ bodyFieldNames: [
68
+ `${fieldName}_start_day`,
69
+ `${fieldName}_start_month`,
70
+ `${fieldName}_start_year`,
71
+ `${fieldName}_end_day`,
72
+ `${fieldName}_end_month`,
73
+ `${fieldName}_end_year`
74
+ ],
75
+ getDataToSave: async (req) => {
76
+ const startDay = req.body[`${fieldName}_start_day`];
77
+ const startMonth = req.body[`${fieldName}_start_month`];
78
+ const startYear = req.body[`${fieldName}_start_year`];
79
+ const endDay = req.body[`${fieldName}_end_day`];
80
+ const endMonth = req.body[`${fieldName}_end_month`];
81
+ const endYear = req.body[`${fieldName}_end_year`];
82
+ if (!startDay || !startMonth || !startYear) {
83
+ throw new Error(`Incomplete start date fields for ${fieldName}`);
84
+ }
85
+ if (!endDay || !endMonth || !endYear) {
86
+ throw new Error(`Incomplete end date fields for ${fieldName}`);
87
+ }
88
+ return {
89
+ answers: {
90
+ [fieldName]: {
91
+ start: new Date(startYear, startMonth - 1, startDay),
92
+ end: new Date(endYear, endMonth - 1, endDay)
93
+ }
94
+ }
95
+ };
96
+ }
97
+ };
98
+ }
99
+
17
100
  describe('CrossQuestionValidator', () => {
18
101
  test('should throw an error if dependencyFieldName is missing', () => {
19
102
  assert.throws(() => {
@@ -39,7 +122,7 @@ describe('CrossQuestionValidator', () => {
39
122
  dependencyFieldName: 'other',
40
123
  validationFunction: validationFn
41
124
  });
42
- const questionObj = { fieldName: 'field' };
125
+ const questionObj = createMockQuestion('field');
43
126
  const req = createMockRequest({ field: 'foo', other: 'bar' });
44
127
  const chain = validator.validate(questionObj, req.res.locals.journeyResponse);
45
128
  await chain[0].run(req);
@@ -51,7 +134,7 @@ describe('CrossQuestionValidator', () => {
51
134
  dependencyFieldName: 'other',
52
135
  validationFunction: () => false
53
136
  });
54
- const questionObj = { fieldName: 'field' };
137
+ const questionObj = createMockQuestion('field');
55
138
  const chain = validator.validate(questionObj);
56
139
  const req = createMockRequest({ field: 'foo', other: 'bar' });
57
140
  req.body = { field: 'foo' };
@@ -66,25 +149,28 @@ describe('CrossQuestionValidator', () => {
66
149
  dependencyFieldName: 'other',
67
150
  validationFunction: validationFn
68
151
  });
69
- const questionObj = { fieldName: 'field' };
152
+ const questionObj = createMockQuestion('field');
70
153
  const chain = validator.validate(questionObj);
71
154
  const req = { res: { locals: {} } };
72
155
  await chain[0].run(req);
73
156
  assert.deepEqual(validationFn.mock.calls[0].arguments, [undefined, undefined]);
74
157
  });
75
158
 
76
- it('should use session answers instead of request body values when useBodyValues is false', async () => {
159
+ it('should use JourneyResponse answers instead of request body values when useBodyValues is false', async () => {
77
160
  const validationFn = mock.fn(() => true);
78
161
  const validator = new CrossQuestionValidator({
79
162
  dependencyFieldName: 'other',
80
163
  validationFunction: validationFn
81
164
  });
82
- const questionObj = { fieldName: 'field' };
83
- const req = createMockRequest({ field: 'from-session', other: 'from-session-dependency' });
165
+ const questionObj = createMockQuestion('field');
166
+ const req = createMockRequest({ field: 'from-journey-response', other: 'from-journey-response-dependency' });
84
167
  req.body = { field: 'from-body', other: 'from-body-dependency' };
85
168
  const chain = validator.validate(questionObj, req.res.locals.journeyResponse);
86
169
  await chain[0].run(req);
87
- assert.deepEqual(validationFn.mock.calls[0].arguments, ['from-session', 'from-session-dependency']);
170
+ assert.deepEqual(validationFn.mock.calls[0].arguments, [
171
+ 'from-journey-response',
172
+ 'from-journey-response-dependency'
173
+ ]);
88
174
  });
89
175
 
90
176
  it('should use request body values when useBodyValues is true', async () => {
@@ -94,9 +180,9 @@ describe('CrossQuestionValidator', () => {
94
180
  useBodyValues: true,
95
181
  validationFunction: validationFn
96
182
  });
97
- const questionObj = { fieldName: 'field' };
183
+ const questionObj = createMockQuestion('field');
98
184
  const chain = validator.validate(questionObj);
99
- const req = createMockRequest({ field: 'from-session', other: 'from-session-dependency' });
185
+ const req = createMockRequest({ field: 'from-journey-response', other: 'from-journey-response-dependency' });
100
186
  req.body = { field: 'from-body', other: 'from-body-dependency' };
101
187
  await chain[0].run(req);
102
188
  assert.deepEqual(validationFn.mock.calls[0].arguments, ['from-body', 'from-body-dependency']);
@@ -109,7 +195,7 @@ describe('CrossQuestionValidator', () => {
109
195
  throw new Error('boom');
110
196
  }
111
197
  });
112
- const questionObj = { fieldName: 'field' };
198
+ const questionObj = createMockQuestion('field');
113
199
  const chain = validator.validate(questionObj);
114
200
  const req = createMockRequest({ field: 'foo', other: 'bar' });
115
201
  const result = await chain[0].run(req);
@@ -123,10 +209,361 @@ describe('CrossQuestionValidator', () => {
123
209
  dependencyFieldName: 'other',
124
210
  validationFunction: validationFn
125
211
  });
126
- const questionObj = { fieldName: 'field' };
212
+ const questionObj = createMockQuestion('field');
127
213
  const chain = validator.validate(questionObj);
128
214
  const req = { res: { locals: { journeyResponse: {} } } };
129
215
  await chain[0].run(req);
130
216
  assert.deepEqual(validationFn.mock.calls[0].arguments, [undefined, undefined]);
131
217
  });
218
+
219
+ describe('date question integration', () => {
220
+ it('should extract date using getDataToSave when useBodyValues is true', async () => {
221
+ const validationFn = mock.fn(() => true);
222
+ const validator = new CrossQuestionValidator({
223
+ dependencyFieldName: 'other',
224
+ validationFunction: validationFn,
225
+ useBodyValues: true
226
+ });
227
+ const questionObj = createMockDateQuestion('dateField');
228
+ const req = createMockRequest({});
229
+ req.body = {
230
+ dateField_day: '15',
231
+ dateField_month: '6',
232
+ dateField_year: '2026',
233
+ other: 'dependency-value'
234
+ };
235
+ const chain = validator.validate(questionObj);
236
+ await chain[0].run(req);
237
+
238
+ const [currentAnswer, dependencyAnswer] = validationFn.mock.calls[0].arguments;
239
+ assert.ok(currentAnswer instanceof Date, 'currentAnswer should be a Date');
240
+ assert.strictEqual(currentAnswer.getFullYear(), 2026);
241
+ assert.strictEqual(currentAnswer.getMonth() + 1, 6);
242
+ assert.strictEqual(currentAnswer.getDate(), 15);
243
+ assert.strictEqual(dependencyAnswer, 'dependency-value');
244
+ });
245
+
246
+ it('should propagate error when getDataToSave throws (e.g. incomplete date fields)', async () => {
247
+ const validator = new CrossQuestionValidator({
248
+ dependencyFieldName: 'other',
249
+ validationFunction: () => true,
250
+ useBodyValues: true
251
+ });
252
+ const questionObj = createMockDateQuestion('dateField');
253
+ const req = createMockRequest({});
254
+ req.body = {
255
+ dateField_day: '15',
256
+ dateField_month: '6',
257
+ // missing year
258
+ other: 'dependency-value'
259
+ };
260
+ const chain = validator.validate(questionObj);
261
+ const result = await chain[0].run(req);
262
+ assert.strictEqual(result.isEmpty(), false);
263
+ assert.match(result.array()[0].msg, /Incomplete date fields/);
264
+ });
265
+
266
+ it('should use JourneyResponse answer directly when useBodyValues is false', async () => {
267
+ const storedDate = new Date('2026-06-15T00:00:00.000Z');
268
+ const validationFn = mock.fn(() => true);
269
+ const validator = new CrossQuestionValidator({
270
+ dependencyFieldName: 'other',
271
+ validationFunction: validationFn
272
+ });
273
+ const questionObj = createMockDateQuestion('dateField');
274
+ const req = createMockRequest({ dateField: storedDate, other: 'dependency-value' });
275
+ req.body = { dateField_day: '15' };
276
+ const chain = validator.validate(questionObj, req.res.locals.journeyResponse);
277
+ await chain[0].run(req);
278
+
279
+ const [currentAnswer, dependencyAnswer] = validationFn.mock.calls[0].arguments;
280
+ assert.strictEqual(currentAnswer, storedDate);
281
+ assert.strictEqual(dependencyAnswer, 'dependency-value');
282
+ });
283
+ });
284
+
285
+ describe('date-period question integration', () => {
286
+ it('should extract date period using getDataToSave when useBodyValues is true', async () => {
287
+ const validationFn = mock.fn(() => true);
288
+ const validator = new CrossQuestionValidator({
289
+ dependencyFieldName: 'other',
290
+ validationFunction: validationFn,
291
+ useBodyValues: true
292
+ });
293
+ const questionObj = createMockDatePeriodQuestion('periodField');
294
+ const req = createMockRequest({});
295
+ req.body = {
296
+ periodField_start_day: '1',
297
+ periodField_start_month: '6',
298
+ periodField_start_year: '2026',
299
+ periodField_end_day: '30',
300
+ periodField_end_month: '6',
301
+ periodField_end_year: '2026',
302
+ other: 'dependency-value'
303
+ };
304
+ const chain = validator.validate(questionObj);
305
+ await chain[0].run(req);
306
+
307
+ const [currentAnswer, dependencyAnswer] = validationFn.mock.calls[0].arguments;
308
+ assert.ok(currentAnswer.start instanceof Date, 'start should be a Date');
309
+ assert.ok(currentAnswer.end instanceof Date, 'end should be a Date');
310
+ assert.strictEqual(currentAnswer.start.getDate(), 1);
311
+ assert.strictEqual(currentAnswer.end.getDate(), 30);
312
+ assert.strictEqual(dependencyAnswer, 'dependency-value');
313
+ });
314
+
315
+ it('should propagate error when getDataToSave throws for incomplete start date', async () => {
316
+ const validator = new CrossQuestionValidator({
317
+ dependencyFieldName: 'other',
318
+ validationFunction: () => true,
319
+ useBodyValues: true
320
+ });
321
+ const questionObj = createMockDatePeriodQuestion('periodField');
322
+ const req = createMockRequest({});
323
+ req.body = {
324
+ periodField_start_day: '1',
325
+ // missing start month and year
326
+ periodField_end_day: '30',
327
+ periodField_end_month: '6',
328
+ periodField_end_year: '2026',
329
+ other: 'dependency-value'
330
+ };
331
+ const chain = validator.validate(questionObj);
332
+ const result = await chain[0].run(req);
333
+ assert.strictEqual(result.isEmpty(), false);
334
+ assert.match(result.array()[0].msg, /Incomplete start date fields/);
335
+ });
336
+
337
+ it('should propagate error when getDataToSave throws for incomplete end date', async () => {
338
+ const validator = new CrossQuestionValidator({
339
+ dependencyFieldName: 'other',
340
+ validationFunction: () => true,
341
+ useBodyValues: true
342
+ });
343
+ const questionObj = createMockDatePeriodQuestion('periodField');
344
+ const req = createMockRequest({});
345
+ req.body = {
346
+ periodField_start_day: '1',
347
+ periodField_start_month: '6',
348
+ periodField_start_year: '2026',
349
+ periodField_end_day: '30',
350
+ // missing end month and year
351
+ other: 'dependency-value'
352
+ };
353
+ const chain = validator.validate(questionObj);
354
+ const result = await chain[0].run(req);
355
+ assert.strictEqual(result.isEmpty(), false);
356
+ assert.match(result.array()[0].msg, /Incomplete end date fields/);
357
+ });
358
+
359
+ it('should use JourneyResponse answer directly when useBodyValues is false', async () => {
360
+ const storedPeriod = {
361
+ start: new Date('2026-06-01T00:00:00.000Z'),
362
+ end: new Date('2026-06-30T00:00:00.000Z')
363
+ };
364
+ const validationFn = mock.fn(() => true);
365
+ const validator = new CrossQuestionValidator({
366
+ dependencyFieldName: 'other',
367
+ validationFunction: validationFn
368
+ });
369
+ const questionObj = createMockDatePeriodQuestion('periodField');
370
+ const req = createMockRequest({ periodField: storedPeriod, other: 'dependency-value' });
371
+ req.body = { periodField_start_day: '1' };
372
+ const chain = validator.validate(questionObj, req.res.locals.journeyResponse);
373
+ await chain[0].run(req);
374
+
375
+ const [currentAnswer, dependencyAnswer] = validationFn.mock.calls[0].arguments;
376
+ assert.strictEqual(currentAnswer, storedPeriod);
377
+ assert.strictEqual(dependencyAnswer, 'dependency-value');
378
+ });
379
+ });
380
+
381
+ describe('useBodyValuesForCurrent', () => {
382
+ it('should set useBodyValuesForCurrent property correctly', () => {
383
+ const validator1 = new CrossQuestionValidator({
384
+ dependencyFieldName: 'other',
385
+ validationFunction: () => true,
386
+ useBodyValuesForCurrent: true
387
+ });
388
+ assert.strictEqual(validator1.useBodyValuesForCurrent, true);
389
+
390
+ const validator2 = new CrossQuestionValidator({
391
+ dependencyFieldName: 'other',
392
+ validationFunction: () => true,
393
+ useBodyValues: true
394
+ });
395
+ assert.strictEqual(validator2.useBodyValuesForCurrent, true);
396
+
397
+ const validator3 = new CrossQuestionValidator({
398
+ dependencyFieldName: 'other',
399
+ validationFunction: () => true
400
+ });
401
+ assert.strictEqual(validator3.useBodyValuesForCurrent, false);
402
+ });
403
+
404
+ it('should use body value for current and JourneyResponse value for dependency when useBodyValuesForCurrent is true', async () => {
405
+ const validationFn = mock.fn(() => true);
406
+ const validator = new CrossQuestionValidator({
407
+ dependencyFieldName: 'other',
408
+ validationFunction: validationFn,
409
+ useBodyValuesForCurrent: true
410
+ });
411
+ const questionObj = createMockQuestion('field');
412
+ const req = createMockRequest({ field: 'from-journey-response', other: 'from-journey-response-dependency' });
413
+ req.body = { field: 'from-body', other: 'from-body-dependency' };
414
+ const chain = validator.validate(questionObj, req.res.locals.journeyResponse);
415
+ await chain[0].run(req);
416
+
417
+ const [currentAnswer, dependencyAnswer] = validationFn.mock.calls[0].arguments;
418
+ assert.strictEqual(currentAnswer, 'from-body');
419
+ assert.strictEqual(dependencyAnswer, 'from-journey-response-dependency');
420
+ });
421
+
422
+ it('should use body values for both current and dependency when useBodyValues is true', async () => {
423
+ const validationFn = mock.fn(() => true);
424
+ const validator = new CrossQuestionValidator({
425
+ dependencyFieldName: 'other',
426
+ validationFunction: validationFn,
427
+ useBodyValues: true
428
+ });
429
+ const questionObj = createMockQuestion('field');
430
+ const req = createMockRequest({ field: 'from-journey-response', other: 'from-journey-response-dependency' });
431
+ req.body = { field: 'from-body', other: 'from-body-dependency' };
432
+ const chain = validator.validate(questionObj, req.res.locals.journeyResponse);
433
+ await chain[0].run(req);
434
+
435
+ const [currentAnswer, dependencyAnswer] = validationFn.mock.calls[0].arguments;
436
+ assert.strictEqual(currentAnswer, 'from-body');
437
+ assert.strictEqual(dependencyAnswer, 'from-body-dependency');
438
+ });
439
+
440
+ it('should use JourneyResponse value for current when useBodyValuesForCurrent is false', async () => {
441
+ const validationFn = mock.fn(() => true);
442
+ const validator = new CrossQuestionValidator({
443
+ dependencyFieldName: 'other',
444
+ validationFunction: validationFn,
445
+ useBodyValuesForCurrent: false
446
+ });
447
+ const questionObj = createMockQuestion('field');
448
+ const req = createMockRequest({ field: 'from-journey-response', other: 'from-journey-response-dependency' });
449
+ req.body = { field: 'from-body', other: 'from-body-dependency' };
450
+ const chain = validator.validate(questionObj, req.res.locals.journeyResponse);
451
+ await chain[0].run(req);
452
+
453
+ const [currentAnswer, dependencyAnswer] = validationFn.mock.calls[0].arguments;
454
+ assert.strictEqual(currentAnswer, 'from-journey-response');
455
+ assert.strictEqual(dependencyAnswer, 'from-journey-response-dependency');
456
+ });
457
+
458
+ it('should fallback to req.body[fieldName] when getDataToSave is not available', async () => {
459
+ const validationFn = mock.fn(() => true);
460
+ const validator = new CrossQuestionValidator({
461
+ dependencyFieldName: 'other',
462
+ validationFunction: validationFn,
463
+ useBodyValuesForCurrent: true
464
+ });
465
+ // Question without getDataToSave method
466
+ const questionObj = {
467
+ fieldName: 'field'
468
+ };
469
+ const req = createMockRequest({ field: 'from-journey-response', other: 'from-journey-response-dependency' });
470
+ req.body = { field: 'from-body', other: 'from-body-dependency' };
471
+ const chain = validator.validate(questionObj, req.res.locals.journeyResponse);
472
+ await chain[0].run(req);
473
+
474
+ const [currentAnswer, dependencyAnswer] = validationFn.mock.calls[0].arguments;
475
+ assert.strictEqual(currentAnswer, 'from-body');
476
+ assert.strictEqual(dependencyAnswer, 'from-journey-response-dependency');
477
+ });
478
+
479
+ it('should return all formatted answers when fieldName is not in formattedAnswers', async () => {
480
+ const validationFn = mock.fn(() => true);
481
+ const validator = new CrossQuestionValidator({
482
+ dependencyFieldName: 'otherField',
483
+ validationFunction: validationFn,
484
+ useBodyValues: true
485
+ });
486
+ // Mock a behavior where getDataToSave returns answers keyed by input
487
+ // field names, not the question's fieldName
488
+ const questionObj = {
489
+ fieldName: 'contactDetails', // This won't be in formattedAnswers
490
+ bodyFieldNames: ['email', 'phone'],
491
+ getDataToSave: async (req) => ({
492
+ answers: {
493
+ // Returns individual input fields, not 'contactDetails'
494
+ email: req.body.email,
495
+ phone: req.body.phone
496
+ }
497
+ })
498
+ };
499
+ const req = createMockRequest({});
500
+ req.body = {
501
+ contactDetails: 'ignored',
502
+ email: 'test@example.com',
503
+ phone: '123456789',
504
+ otherField: 'dependency-value'
505
+ };
506
+ const chain = validator.validate(questionObj);
507
+ await chain[0].run(req);
508
+
509
+ const [currentAnswer, dependencyAnswer] = validationFn.mock.calls[0].arguments;
510
+ // Should receive all formatted answers since 'contactDetails' is not in formattedAnswers
511
+ assert.deepStrictEqual(currentAnswer, {
512
+ email: 'test@example.com',
513
+ phone: '123456789'
514
+ });
515
+ assert.strictEqual(dependencyAnswer, 'dependency-value');
516
+ });
517
+ });
518
+
519
+ describe('bodyFieldNames getter support', () => {
520
+ it('should use bodyFieldNames[0] when question provides it', async () => {
521
+ const validationFn = mock.fn(() => true);
522
+ const validator = new CrossQuestionValidator({
523
+ dependencyFieldName: 'other',
524
+ validationFunction: validationFn
525
+ });
526
+ // Mock question with custom bodyFieldNames getter
527
+ const questionObj = {
528
+ fieldName: 'customField',
529
+ bodyFieldNames: ['customField_first', 'customField_second'],
530
+ getDataToSave: async (req) => ({
531
+ answers: {
532
+ customField: req.body.customField_first + '-' + req.body.customField_second
533
+ }
534
+ })
535
+ };
536
+ const req = createMockRequest({ customField: 'stored-value', other: 'dep-value' });
537
+ req.body = {
538
+ customField_first: 'a',
539
+ customField_second: 'b',
540
+ other: 'body-dep'
541
+ };
542
+ const chain = validator.validate(questionObj, req.res.locals.journeyResponse);
543
+ await chain[0].run(req);
544
+
545
+ // Should have called validation function with stored answer since useBodyValuesForCurrent is false
546
+ assert.deepEqual(validationFn.mock.calls[0].arguments, ['stored-value', 'dep-value']);
547
+ });
548
+
549
+ it('should fallback to fieldName when bodyFieldNames is not available', async () => {
550
+ const validationFn = mock.fn(() => true);
551
+ const validator = new CrossQuestionValidator({
552
+ dependencyFieldName: 'other',
553
+ validationFunction: validationFn
554
+ });
555
+ // Question without bodyFieldNames getter
556
+ const questionObj = {
557
+ fieldName: 'simpleField'
558
+ };
559
+ const req = createMockRequest({ simpleField: 'stored-value', other: 'dep-value' });
560
+ req.body = {
561
+ simpleField: 'body-value'
562
+ };
563
+ const chain = validator.validate(questionObj, req.res.locals.journeyResponse);
564
+ await chain[0].run(req);
565
+
566
+ assert.deepEqual(validationFn.mock.calls[0].arguments, ['stored-value', 'dep-value']);
567
+ });
568
+ });
132
569
  });
@@ -6,10 +6,6 @@ import { enGB } from 'date-fns/locale';
6
6
  import BaseValidator from './base-validator.js';
7
7
  import { parseDateInput, startOfDay } from '../lib/date-utils.js';
8
8
 
9
- /**
10
- * @typedef {import('../components/date/question.js')} DateQuestion
11
- */
12
-
13
9
  /**
14
10
  * @typedef {Object} DateValidationSettings
15
11
  * @property {Boolean} ensureFuture
@@ -64,7 +60,7 @@ export class DatePeriodValidator extends BaseValidator {
64
60
 
65
61
  /**
66
62
  * validates the response body, checking the values sent for the date are valid
67
- * @param {DateQuestion} questionObj
63
+ * @param {import('../components/date/question.js').DateQuestion} questionObj
68
64
  */
69
65
  validate(questionObj) {
70
66
  const fieldName = questionObj.fieldName;
@@ -1,10 +1,6 @@
1
1
  import { body } from 'express-validator';
2
2
  import DateValidator from './date-validator.js';
3
3
 
4
- /**
5
- * @typedef {import('./question.js')} DateTimeQuestion
6
- */
7
-
8
4
  /**
9
5
  * enforces a user has entered a valid date
10
6
  * @class
@@ -32,7 +28,7 @@ export class DateTimeValidator extends DateValidator {
32
28
 
33
29
  /**
34
30
  * validates the response body, checking the values sent for the date are valid
35
- * @param {DateTimeQuestion} questionObj
31
+ * @param {import('../components/date-time/question.js').DateTimeQuestion} questionObj
36
32
  */
37
33
  validate(questionObj) {
38
34
  const fieldName = questionObj.fieldName;
@@ -5,10 +5,6 @@ import { enGB } from 'date-fns/locale';
5
5
  import BaseValidator from './base-validator.js';
6
6
  import { endOfDay, parseDateInput, startOfDay } from '../lib/date-utils.js';
7
7
 
8
- /**
9
- * @typedef {import('../components/date/question.js')} DateQuestion
10
- */
11
-
12
8
  /**
13
9
  * @typedef {Object} DateValidationSettings
14
10
  * @property {Boolean} [ensureFuture]
@@ -69,7 +65,7 @@ export class DateValidator extends BaseValidator {
69
65
 
70
66
  /**
71
67
  * validates the response body, checking the values sent for the date are valid
72
- * @param {DateQuestion} questionObj
68
+ * @param {import('../components/date/question.js').DateQuestion} questionObj
73
69
  */
74
70
  validate(questionObj) {
75
71
  const fieldName = questionObj.fieldName;
@@ -2,10 +2,6 @@ import { body } from 'express-validator';
2
2
 
3
3
  import BaseValidator from './base-validator.js';
4
4
 
5
- /**
6
- * @typedef {import('../questions/question.js')} Question
7
- */
8
-
9
5
  /**
10
6
  * enforces a field is not empty
11
7
  * @class
@@ -30,7 +26,7 @@ export class RequiredValidator extends BaseValidator {
30
26
 
31
27
  /**
32
28
  * validates the response body, checking the questionObj's fieldname
33
- * @param {Question} questionObj
29
+ * @param {import('../questions/question.js').Question} questionObj
34
30
  */
35
31
  validate(questionObj) {
36
32
  return body(questionObj.fieldName).notEmpty().withMessage(this.errorMessage);
@@ -3,10 +3,6 @@ import { body } from 'express-validator';
3
3
  import BaseValidator from './base-validator.js';
4
4
  import { toArray } from '#src/lib/utils.js';
5
5
 
6
- /**
7
- * @typedef {import('../questions/question.js')} Question
8
- */
9
-
10
6
  /**
11
7
  * enforces a field is not empty when condition is satisfied
12
8
  * @class
@@ -34,7 +30,7 @@ export class UnitOptionEntryValidator extends BaseValidator {
34
30
 
35
31
  /**
36
32
  * validates the response body, checking the questionObj's fieldname
37
- * @param {Question} questionObj
33
+ * @param {import('../components/unit-option-entry/question.js').UnitOptionEntryQuestion} questionObj
38
34
  */
39
35
  validate(questionObj) {
40
36
  return questionObj.options.reduce((schema, option) => {
@@ -1 +1 @@
1
- {"version":3,"file":"question.d.ts","sourceRoot":"","sources":["../../../../src/components/address/question.js"],"names":[],"mappings":"AAOA;IACC;;OAEG;IACH,oBAFW,OAAO,iBAAiB,EAAE,kBAAkB,EAqBtD;IAXE;;;;;;kBAA8C;IAIhD;;;;;;MAMC;IAGF;;;OAGG;IACH,4BAHW,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GACjB,GAAC,GAAC,MAAM,CAqBpB;IAqCD;;;OAGG;IACH;;eAUC;IAuBD,kEAMC;CACD;;yBAxIwB,WAAW"}
1
+ {"version":3,"file":"question.d.ts","sourceRoot":"","sources":["../../../../src/components/address/question.js"],"names":[],"mappings":"AAOA;IACC;;OAEG;IACH,oBAFW,OAAO,iBAAiB,EAAE,kBAAkB,EAqBtD;IAXE;;;;;;kBAA8C;IAIhD;;;;;;MAMC;IAiBF;;;OAGG;IACH,4BAHW,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GACjB,GAAC,GAAC,MAAM,CAqBpB;IAqCD;;;OAGG;IACH;;eAUC;IAuBD,kEAMC;CACD;;yBAtJwB,WAAW"}
@@ -1 +1 @@
1
- {"version":3,"file":"question.d.ts","sourceRoot":"","sources":["../../../../src/components/date/question.js"],"names":[],"mappings":"AAKA;;GAEG;AACH;IACC;;;OAGG;IACH,uCAHW,OAAO,iBAAiB,EAAE,kBAAkB,EAStD;IADA,gBAA4B;IAG7B;;;;;OAKG,CACH,mBAJW,OAAO,SAAS,EAAE,OAAO,mBACzB,OAAO,mBAAmB,EAAE,eAAe,GACzC,OAAO,CAAE,MAAM,CAAC,CAY5B;IAED;;MAwBC;CAoBD;;yBAlFwB,6BAA6B"}
1
+ {"version":3,"file":"question.d.ts","sourceRoot":"","sources":["../../../../src/components/date/question.js"],"names":[],"mappings":"AAKA;;GAEG;AACH;IACC;;;OAGG;IACH,uCAHW,OAAO,iBAAiB,EAAE,kBAAkB,EAStD;IADA,gBAA4B;IAW7B;;;;;OAKG,CACH,mBAJW,OAAO,SAAS,EAAE,OAAO,mBACzB,OAAO,mBAAmB,EAAE,eAAe,GACzC,OAAO,CAAE,MAAM,CAAC,CAY5B;IAED;;MAwBC;CAoBD;;yBA1FwB,6BAA6B"}
@@ -1 +1 @@
1
- {"version":3,"file":"question.d.ts","sourceRoot":"","sources":["../../../../src/components/date-period/question.js"],"names":[],"mappings":"AAOA;;;GAGG;AACH;IACC;;;;;;;;;OASG;IACH,6FATW,OAAO,iBAAiB,EAAE,kBAAkB,EAqBtD;IAPA,gBAA4B;IAC5B,YAAsD;IAEtD,eAA0B;IAC1B,aAAsB;IACtB,eAA+D;IAC/D,aAA2D;IAG5D;;;;;OAKG,CACH,mBAJW,OAAO,SAAS,EAAE,OAAO,mBACzB,OAAO,mBAAmB,EAAE,eAAe,GACzC,OAAO,CAAE,MAAM,CAAC,CAgC5B;IAED;;MAuCC;CAyCD;;yBAzJwB,WAAW"}
1
+ {"version":3,"file":"question.d.ts","sourceRoot":"","sources":["../../../../src/components/date-period/question.js"],"names":[],"mappings":"AAOA;;;GAGG;AACH;IACC;;;;;;;;;OASG;IACH,6FATW,OAAO,iBAAiB,EAAE,kBAAkB,EAqBtD;IAPA,gBAA4B;IAC5B,YAAsD;IAEtD,eAA0B;IAC1B,aAAsB;IACtB,eAA+D;IAC/D,aAA2D;IAkB5D;;;;;OAKG,CACH,mBAJW,OAAO,SAAS,EAAE,OAAO,mBACzB,OAAO,mBAAmB,EAAE,eAAe,GACzC,OAAO,CAAE,MAAM,CAAC,CAgC5B;IAED;;MAuCC;CAyCD;;yBAxKwB,WAAW"}
@@ -1 +1 @@
1
- {"version":3,"file":"question.d.ts","sourceRoot":"","sources":["../../../../src/components/date-time/question.js"],"names":[],"mappings":"AAMA;IACC,kBAAiB;IACjB,kBAAiB;IAEjB;;;;OAIG;IACH,mDAJW,OAAO,iBAAiB,EAAE,kBAAkB,EAWtD;IAFA,gBAA4B;IAC5B,gBAA4B;IAG7B;;;;;OAKG,CACH,mBAJW,OAAO,SAAS,EAAE,OAAO,mBACzB,eAAe,GACb,OAAO,CAAE,MAAM,CAAC,CAqB5B;IAED;;MAqCC;;CAoCD;;yBA5HwB,6BAA6B"}
1
+ {"version":3,"file":"question.d.ts","sourceRoot":"","sources":["../../../../src/components/date-time/question.js"],"names":[],"mappings":"AAMA;IACC,kBAAiB;IACjB,kBAAiB;IAEjB;;;;OAIG;IACH,mDAJW,OAAO,iBAAiB,EAAE,kBAAkB,EAWtD;IAFA,gBAA4B;IAC5B,gBAA4B;IAkB7B;;;;;OAKG,CACH,mBAJW,OAAO,SAAS,EAAE,OAAO,mBACzB,eAAe,GACb,OAAO,CAAE,MAAM,CAAC,CAqB5B;IAED;;MAqCC;;CAoCD;;yBA3IwB,6BAA6B"}
@@ -1 +1 @@
1
- {"version":3,"file":"question.d.ts","sourceRoot":"","sources":["../../../../src/components/multi-field-input/question.js"],"names":[],"mappings":"AAIA;;;;;;GAMG;AAEH;;GAEG;AACH;IACC;;OAEG;IACH,wCAFW,OAAO,oCAAoC,EAAE,4BAA4B,EAanF;IAJC,uEAA8B;IAMhC;;;;;;;;;;;QAOC;IAsBD;;;;;;;;;;;;;OAaG;IACH,wDAZW,OAAO,GAEL,KAAK,CAAC;QACd,GAAG,EAAE,MAAM,CAAC;QACZ,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;QACvB,MAAM,EAAE;YACN,IAAI,EAAE,MAAM,CAAC;YACb,IAAI,EAAE,MAAM,CAAC;YACb,kBAAkB,EAAE,MAAM,CAAC;SAC5B,CAAC;KACH,CAAC,CAkBJ;;CAyBD;;gCA/GY,OAAO,WAAW,EAAE,iBAAiB;sBACrC,OAAO,UAAU,EAAE,OAAO;8BAC1B,OAAO,mBAAmB,EAAE,eAAe;;;yBAP/B,WAAW"}
1
+ {"version":3,"file":"question.d.ts","sourceRoot":"","sources":["../../../../src/components/multi-field-input/question.js"],"names":[],"mappings":"AAIA;;;;;;GAMG;AAEH;;GAEG;AACH;IACC;;OAEG;IACH,wCAFW,OAAO,oCAAoC,EAAE,4BAA4B,EAanF;IAJC,uEAA8B;IAchC;;;;;;;;;;;QAOC;IAsBD;;;;;;;;;;;;;OAaG;IACH,wDAZW,OAAO,GAEL,KAAK,CAAC;QACd,GAAG,EAAE,MAAM,CAAC;QACZ,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;QACvB,MAAM,EAAE;YACN,IAAI,EAAE,MAAM,CAAC;YACb,IAAI,EAAE,MAAM,CAAC;YACb,kBAAkB,EAAE,MAAM,CAAC;SAC5B,CAAC;KACH,CAAC,CAkBJ;;CAyBD;;gCAvHY,OAAO,WAAW,EAAE,iBAAiB;sBACrC,OAAO,UAAU,EAAE,OAAO;8BAC1B,OAAO,mBAAmB,EAAE,eAAe;;;yBAP/B,WAAW"}
@@ -111,6 +111,14 @@ export class Question {
111
111
  * @returns {boolean}
112
112
  */
113
113
  get isInManageListSection(): boolean;
114
+ /**
115
+ * Gets the body field names used by this question in form submissions.
116
+ * Returns the field names that should be present in req.body when this question is submitted.
117
+ * Subclasses should override this for questions with multiple or differently-named body fields.
118
+ *
119
+ * @returns {string[]}
120
+ */
121
+ get bodyFieldNames(): string[];
114
122
  /**
115
123
  * gets the view model for this question
116
124
  *