@planning-inspectorate/dynamic-forms 3.20.5 → 3.22.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 (69) hide show
  1. package/README.md +100 -0
  2. package/docs/Dynamic Sections.md +47 -0
  3. package/docs/Getting Started.md +8 -2
  4. package/package.json +1 -1
  5. package/src/components/boolean/question.d.ts +1 -1
  6. package/src/components/boolean/question.js +1 -1
  7. package/src/components/date/question.d.ts +1 -1
  8. package/src/components/date/question.js +1 -1
  9. package/src/components/date-period/question.d.ts +1 -1
  10. package/src/components/date-period/question.js +1 -1
  11. package/src/components/date-time/question.d.ts +1 -1
  12. package/src/components/date-time/question.js +1 -1
  13. package/src/components/manage-list/question.d.ts.map +1 -1
  14. package/src/components/manage-list/question.js +5 -0
  15. package/src/components/manage-list/question.test.js +3 -1
  16. package/src/components/multi-field-input/question.d.ts.map +1 -1
  17. package/src/components/multi-field-input/question.js +8 -6
  18. package/src/components/multi-field-input/question.test.js +126 -213
  19. package/src/components/single-line-input/index.njk +4 -0
  20. package/src/components/single-line-input/question.d.ts +2 -0
  21. package/src/components/single-line-input/question.d.ts.map +1 -1
  22. package/src/components/single-line-input/question.js +4 -0
  23. package/src/components/single-line-input/question.test.js +6 -0
  24. package/src/components/text-entry-redact/question.d.ts.map +1 -1
  25. package/src/components/text-entry-redact/question.js +3 -1
  26. package/src/components/text-entry-redact/question.test.js +9 -0
  27. package/src/components/unit-option-entry/question.d.ts +1 -1
  28. package/src/components/unit-option-entry/question.d.ts.map +1 -1
  29. package/src/components/unit-option-entry/question.js +4 -2
  30. package/src/components/unit-option-entry/question.test.js +9 -0
  31. package/src/controller.d.ts +15 -3
  32. package/src/controller.d.ts.map +1 -1
  33. package/src/controller.js +19 -11
  34. package/src/controller.test.js +182 -153
  35. package/src/dynamic-section.d.ts +40 -0
  36. package/src/dynamic-section.d.ts.map +1 -0
  37. package/src/dynamic-section.js +51 -0
  38. package/src/dynamic-section.test.js +113 -0
  39. package/src/index.d.ts +2 -1
  40. package/src/index.js +2 -1
  41. package/src/index.test.js +3 -0
  42. package/src/journey/journey.d.ts +10 -0
  43. package/src/journey/journey.d.ts.map +1 -1
  44. package/src/journey/journey.js +15 -0
  45. package/src/lib/answer-utils.d.ts +22 -0
  46. package/src/lib/answer-utils.d.ts.map +1 -0
  47. package/src/lib/answer-utils.js +34 -0
  48. package/src/{components/manage-list/utils.test.js → lib/answer-utils.test.js} +9 -11
  49. package/src/lib/session-answer-store.d.ts.map +1 -1
  50. package/src/lib/session-answer-store.js +14 -6
  51. package/src/lib/session-answer-store.test.js +43 -0
  52. package/src/middleware/redirect-to-unanswered-question.d.ts.map +1 -1
  53. package/src/middleware/redirect-to-unanswered-question.js +4 -3
  54. package/src/middleware/redirect-to-unanswered-question.test.js +32 -0
  55. package/src/questions/options-question.js +1 -1
  56. package/src/questions/question.d.ts +8 -3
  57. package/src/questions/question.d.ts.map +1 -1
  58. package/src/questions/question.js +16 -6
  59. package/src/questions/question.test.js +19 -0
  60. package/src/section.d.ts +19 -0
  61. package/src/section.d.ts.map +1 -1
  62. package/src/section.js +50 -9
  63. package/src/section.test.js +9 -0
  64. package/test/dynamic-journey.test.js +372 -0
  65. package/typedefs/question-props.d.ts +3 -1
  66. package/typedefs/question-types.d.ts +3 -0
  67. package/src/components/manage-list/utils.d.ts +0 -18
  68. package/src/components/manage-list/utils.d.ts.map +0 -1
  69. package/src/components/manage-list/utils.js +0 -35
@@ -1,7 +1,7 @@
1
1
  import escape from 'escape-html';
2
2
  import { capitalize, nl2br, trimTrailingSlash } from '../lib/utils.js';
3
3
  import MultiFieldInputValidator from '../validator/multi-field-input-validator.js';
4
- import { answerObjectForManageList } from '#src/components/manage-list/utils.js';
4
+ import { answerObjectForListItem } from '#src/lib/answer-utils.js';
5
5
 
6
6
  /**
7
7
  * A specific question within a journey which is made up of one (usually) or many (sometimes) components and their required content.
@@ -202,7 +202,8 @@ export class Question {
202
202
  toViewModel({ params, manageListQuestion, section, journey, customViewData, payload }) {
203
203
  const viewModel = this.prepQuestionForRendering(section, journey, customViewData, payload, {
204
204
  params,
205
- manageListQuestion
205
+ manageListQuestion,
206
+ dynamicSection: section.isDynamicSection ? section : undefined
206
207
  });
207
208
  viewModel.backLink = journey.getBackLink({ params, manageListQuestion });
208
209
  return viewModel;
@@ -215,7 +216,7 @@ export class Question {
215
216
  * @param {import('../journey/journey.js').Journey} journey - the journey we are in
216
217
  * @param {Record<string, unknown>} [customViewData] additional data to send to view
217
218
  * @param {unknown} [payload]
218
- * @param {import('#typedefs/question-types.d.ts').PrepQuestionForRenderingOptions} [options] - required to support manage list question
219
+ * @param {import('#typedefs/question-types.d.ts').PrepQuestionForRenderingOptions} [options] - required to support manage list question and dynamic sections
219
220
  * @returns {import('#typedefs/question-types.d.ts').QuestionViewModel}
220
221
  */
221
222
  prepQuestionForRendering(section, journey, customViewData, payload, options) {
@@ -280,13 +281,13 @@ export class Question {
280
281
  addCustomDataToViewModel(viewModel) {}
281
282
 
282
283
  /**
283
- * Get the answers object from the journey response, which may be nested in an array for manage list questions
284
+ * Get the answers object from the journey response, which may be nested in an array for manage list questions or dynamic sections
284
285
  *
285
286
  * @param {import('../journey/journey-response.js').JourneyResponse} response
286
287
  * @param {import('#typedefs/question-types.d.ts').PrepQuestionForRenderingOptions} [options]
287
288
  * @returns {Record<string, any>}
288
289
  */
289
- answerObjectFromJourneyResponse(response, { params, manageListQuestion } = {}) {
290
+ answerObjectFromJourneyResponse(response, { params, manageListQuestion, dynamicSection } = {}) {
290
291
  if (this.isInManageListSection) {
291
292
  if (!params?.manageListItemId) {
292
293
  throw new Error('no list item id for manage list question');
@@ -295,7 +296,11 @@ export class Question {
295
296
  throw new Error('no manageListQuestion for manage list question');
296
297
  }
297
298
  // if this is a manage list question, the response is within the 'parent' manage list answers array
298
- return answerObjectForManageList(response, manageListQuestion, params.manageListItemId);
299
+ return answerObjectForListItem(response, manageListQuestion, params.manageListItemId);
300
+ }
301
+ if (dynamicSection) {
302
+ // if this is a question in a dynamic section, the response is within an answers array, key by section
303
+ return answerObjectForListItem(response, dynamicSection, params.section);
299
304
  }
300
305
  return response.answers;
301
306
  }
@@ -393,6 +398,11 @@ export class Question {
393
398
 
394
399
  /**
395
400
  * returns the formatted answers values to be used to build task list elements
401
+ *
402
+ * If overriding this method and access answers on the journey, use journey.responseForSection
403
+ * to get the answers, instead of `journey.response` directly. This is to support DynamicSections
404
+ * where answers are in an array. See `MultiFieldInputQuestion` for an example.
405
+ *
396
406
  * @param {string} sectionSegment
397
407
  * @param {import('../journey/journey.js').Journey} journey
398
408
  * @param {unknown} answer
@@ -285,6 +285,25 @@ describe('./src/dynamic-forms/question.js', () => {
285
285
  /no manageListQuestion for manage list question/
286
286
  );
287
287
  });
288
+
289
+ it('should return dynamic section object', () => {
290
+ const question = getTestQuestion();
291
+ const answers = {
292
+ id: 'section-1',
293
+ myField: 'my-value'
294
+ };
295
+ const journeyResponse = {
296
+ answers: {
297
+ myList: [answers]
298
+ }
299
+ };
300
+ const params = { section: 'section-1' };
301
+ const got = question.answerObjectFromJourneyResponse(journeyResponse, {
302
+ params,
303
+ dynamicSection: { fieldName: 'myList' }
304
+ });
305
+ assert.strictEqual(got, answers);
306
+ });
288
307
  });
289
308
 
290
309
  describe('renderAction', () => {
package/src/section.d.ts CHANGED
@@ -37,6 +37,15 @@ export class Section {
37
37
  * @type {Array<import('./questions/question.js').Question>} - questions within the section
38
38
  */
39
39
  questions: Array<import("./questions/question.js").Question>;
40
+ /**
41
+ * Is this section dynamic?
42
+ *
43
+ * Implemented as a getter so dynamic section can override it,
44
+ * but it cannot be changed at runtime.
45
+ *
46
+ * @returns {boolean}
47
+ */
48
+ get isDynamicSection(): boolean;
40
49
  /**
41
50
  * Add a condition to all questions in this section
42
51
  *
@@ -83,6 +92,16 @@ export class Section {
83
92
  * @returns {SectionStatus}
84
93
  */
85
94
  getStatus(journeyResponse: import("#journey-response").JourneyResponse): SectionStatus;
95
+ /**
96
+ * Get the journey response (answers) for this section
97
+ * That is most often the root answers object
98
+ * For dynamic sections, it is an object within the configured array
99
+ *
100
+ * @param {import('#journey-response').JourneyResponse} journeyResponse
101
+ */
102
+ getResponse(journeyResponse: import("#journey-response").JourneyResponse): import("#journey-response").JourneyResponse<import("./index.js").JourneyAnswers> | {
103
+ answers: Record<string, unknown>;
104
+ };
86
105
  /**
87
106
  * checks answers on response and return true if the status of the section is complete
88
107
  * @param {import('#journey-response').JourneyResponse} journeyResponse
@@ -1 +1 @@
1
- {"version":3,"file":"section.d.ts","sourceRoot":"","sources":["section.js"],"names":[],"mappings":"AAGA;;GAEG;AAEH;;;;GAIG;AACH,6BAFU,MAAM,CAEuC;AAEvD;;;GAGG;AACH;IAgNC;;;;;OAKG;IACH,8FAHW,OAAO,8BAA8B,EAAE,2BAA2B,GAChE,OAAO,yBAAyB,EAAE,QAAQ,GAAC,MAAM,GAAC,IAAI,CAoBlE;IAvMD;;;;OAIG;IACH,kBAHW,MAAM,WACN,MAAM,EAKhB;IAxCD;;OAEG;IACH,MAFU,MAAM,CAEX;IAEL;;OAEG;IACH,SAFU,MAAM,CAER;IAER;;OAEG;IACH,WAFU,KAAK,CAAC,OAAO,yBAAyB,EAAE,QAAQ,CAAC,CAE5C;IA6Bf;;;;;OAKG;IACH,2CAHW,iBAAiB,GACf,IAAI,CAchB;IAED;;;;;OAKG;IACH,sBAJW,OAAO,yBAAyB,EAAE,QAAQ,sBAC1C,OAAO,iDAAiD,EAAE,iBAAiB,GACzE,IAAI,CAgBhB;IA8BD;;;;OAIG;IACH,qCAHW,iBAAiB,GACf,IAAI,CAahB;IAED,kFAoBC;IAED;;;;;OAKG;IACH,2CAJW,MAAM,yBACN,iBAAiB,GACf,IAAI,CAQhB;IAED;;;;OAIG;IACH,yCAHW,MAAM,GACJ,IAAI,CAQhB;IAED;;;;OAIG;IACH,wBAHW,OAAO,8BAA8B,EAAE,qBAAqB,GAC1D,OAAO,yBAAyB,EAAE,QAAQ,GAAC,MAAM,GAAC,IAAI,CAuBlE;IA4BD;;;;OAIG;IACH,2BAHW,OAAO,mBAAmB,EAAE,eAAe,GACzC,aAAa,CAoDzB;IAED;;;;OAIG;IACH,4BAHW,OAAO,mBAAmB,EAAE,eAAe,GACzC,OAAO,CAInB;;CAKD;6BAOS,aAAa;AALvB;;GAEG;AAEH;;GAEG;AACH;;;;GAIG;gCA1UU,CAAC,CAAC,QAAQ,EAAE,OAAO,mBAAmB,EAAE,eAAe,KAAK,OAAO,CAAC;4BAgUpE,MAAM"}
1
+ {"version":3,"file":"section.d.ts","sourceRoot":"","sources":["section.js"],"names":[],"mappings":"AAGA;;GAEG;AAEH;;;;GAIG;AACH,6BAFU,MAAM,CAEuC;AAEvD;;;GAGG;AACH;IAkOC;;;;;OAKG;IACH,8FAHW,OAAO,8BAA8B,EAAE,2BAA2B,GAChE,OAAO,yBAAyB,EAAE,QAAQ,GAAC,MAAM,GAAC,IAAI,CAoBlE;IAzND;;;;OAIG;IACH,kBAHW,MAAM,WACN,MAAM,EAKhB;IAxCD;;OAEG;IACH,MAFU,MAAM,CAEX;IAEL;;OAEG;IACH,SAFU,MAAM,CAER;IAER;;OAEG;IACH,WAFU,KAAK,CAAC,OAAO,yBAAyB,EAAE,QAAQ,CAAC,CAE5C;IA6Bf;;;;;;;OAOG;IACH,wBAFa,OAAO,CAInB;IAED;;;;;OAKG;IACH,2CAHW,iBAAiB,GACf,IAAI,CAchB;IAED;;;;;OAKG;IACH,sBAJW,OAAO,yBAAyB,EAAE,QAAQ,sBAC1C,OAAO,iDAAiD,EAAE,iBAAiB,GACzE,IAAI,CAgBhB;IA8BD;;;;OAIG;IACH,qCAHW,iBAAiB,GACf,IAAI,CAahB;IAED,kFAoBC;IAED;;;;;OAKG;IACH,2CAJW,MAAM,yBACN,iBAAiB,GACf,IAAI,CAQhB;IAED;;;;OAIG;IACH,yCAHW,MAAM,GACJ,IAAI,CAQhB;IAED;;;;OAIG;IACH,wBAHW,OAAO,8BAA8B,EAAE,qBAAqB,GAC1D,OAAO,yBAAyB,EAAE,QAAQ,GAAC,MAAM,GAAC,IAAI,CA6BlE;IA4BD;;;;OAIG;IACH,2BAHW,OAAO,mBAAmB,EAAE,eAAe,GACzC,aAAa,CAuDzB;IAED;;;;;;OAMG;IACH,6BAFW,OAAO,mBAAmB,EAAE,eAAe;;MAarD;IAED;;;;OAIG;IACH,4BAHW,OAAO,mBAAmB,EAAE,eAAe,GACzC,OAAO,CAInB;;CAKD;6BAOS,aAAa;AALvB;;GAEG;AAEH;;GAEG;AACH;;;;GAIG;gCAnXU,CAAC,CAAC,QAAQ,EAAE,OAAO,mBAAmB,EAAE,eAAe,KAAK,OAAO,CAAC;4BAyWpE,MAAM"}
package/src/section.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import RequiredValidator from './validator/required-validator.js';
2
- import { answerObjectForManageList } from '#src/components/manage-list/utils.js';
2
+ import { answerObjectForListItem } from '#src/lib/answer-utils.js';
3
3
 
4
4
  /**
5
5
  * @typedef {((response: import('#journey-response').JourneyResponse) => boolean)} QuestionCondition
@@ -59,6 +59,18 @@ export class Section {
59
59
  this.segment = segment;
60
60
  }
61
61
 
62
+ /**
63
+ * Is this section dynamic?
64
+ *
65
+ * Implemented as a getter so dynamic section can override it,
66
+ * but it cannot be changed at runtime.
67
+ *
68
+ * @returns {boolean}
69
+ */
70
+ get isDynamicSection() {
71
+ return false;
72
+ }
73
+
62
74
  /**
63
75
  * Add a condition to all questions in this section
64
76
  *
@@ -206,7 +218,7 @@ export class Section {
206
218
  if (manageListQuestion) {
207
219
  // first check if the next question is within the manage list section
208
220
  // here we get the answers for the manage list item for the question.shouldDisplay logic
209
- const answers = answerObjectForManageList(response, manageListQuestion, routeParams.manageListItemId);
221
+ const answers = answerObjectForListItem(response, manageListQuestion, routeParams.manageListItemId);
210
222
  const next = Section.getNextQuestion({
211
223
  ...params,
212
224
  response: { answers },
@@ -218,10 +230,16 @@ export class Section {
218
230
  }
219
231
  return next;
220
232
  }
221
- return Section.getNextQuestion({
233
+ const nextQuestionParams = {
222
234
  ...params,
223
235
  questions: this.questions
224
- });
236
+ };
237
+ if (this.isDynamicSection) {
238
+ // for dynamic sections, the answers are within an array
239
+ const answers = answerObjectForListItem(response, this, this.segment);
240
+ nextQuestionParams.response = { answers };
241
+ }
242
+ return Section.getNextQuestion(nextQuestionParams);
225
243
  }
226
244
 
227
245
  /**
@@ -261,8 +279,11 @@ export class Section {
261
279
  let requiredAnswerCount = 0;
262
280
  let answerCount = 0;
263
281
 
282
+ // answers for this section may be within an array, for example dynamic sections
283
+ const response = this.getResponse(journeyResponse);
284
+
264
285
  for (let question of this.questions) {
265
- if (!question.shouldDisplay(journeyResponse)) {
286
+ if (!question.shouldDisplay(response)) {
266
287
  continue;
267
288
  }
268
289
  // if question is a multi field input question, check all fields
@@ -271,10 +292,10 @@ export class Section {
271
292
  if (question.fieldIsRequired(field.fieldName)) {
272
293
  requiredQuestionCount++;
273
294
  }
274
- if (question.isAnswered(journeyResponse, field.fieldName)) {
295
+ if (question.isAnswered(response, field.fieldName)) {
275
296
  answerCount++;
276
297
  }
277
- if (question.isAnswered(journeyResponse, field.fieldName) && question.fieldIsRequired(field.fieldName)) {
298
+ if (question.isAnswered(response, field.fieldName) && question.fieldIsRequired(field.fieldName)) {
278
299
  requiredAnswerCount++;
279
300
  }
280
301
  }
@@ -283,11 +304,11 @@ export class Section {
283
304
  requiredQuestionCount++;
284
305
  }
285
306
 
286
- if (question.isAnswered(journeyResponse)) {
307
+ if (question.isAnswered(response)) {
287
308
  answerCount++;
288
309
  }
289
310
 
290
- if (question.isAnswered(journeyResponse) && question.isRequired()) {
311
+ if (question.isAnswered(response) && question.isRequired()) {
291
312
  requiredAnswerCount++;
292
313
  }
293
314
  }
@@ -307,6 +328,26 @@ export class Section {
307
328
  return result;
308
329
  }
309
330
 
331
+ /**
332
+ * Get the journey response (answers) for this section
333
+ * That is most often the root answers object
334
+ * For dynamic sections, it is an object within the configured array
335
+ *
336
+ * @param {import('#journey-response').JourneyResponse} journeyResponse
337
+ */
338
+ getResponse(journeyResponse) {
339
+ // if this is a dynamic section, answers are within an array
340
+ // only answers within the dynamic section can be used for display logic
341
+ if (this.isDynamicSection) {
342
+ // this is not a JourneyResponse class, but is like it
343
+ // DF-46 will likely replace usage of JourneyResponse with an interface
344
+ return {
345
+ answers: answerObjectForListItem(journeyResponse, this, this.segment)
346
+ };
347
+ }
348
+ return journeyResponse;
349
+ }
350
+
310
351
  /**
311
352
  * checks answers on response and return true if the status of the section is complete
312
353
  * @param {import('#journey-response').JourneyResponse} journeyResponse
@@ -754,4 +754,13 @@ describe('./src/dynamic-forms/section.js', () => {
754
754
  });
755
755
  });
756
756
  });
757
+
758
+ describe('getResponse', () => {
759
+ it('should return response for normal section', () => {
760
+ const section = new Section('s1', 'S');
761
+ const res = { answers: {} };
762
+ const got = section.getResponse(res);
763
+ assert.strictEqual(got, res);
764
+ });
765
+ });
757
766
  });
@@ -0,0 +1,372 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'assert';
3
+ import { Section } from '#src/section.js';
4
+ import { DynamicSection } from '#src/dynamic-section.js';
5
+ import { Journey } from '#src/journey/journey.js';
6
+ import { COMPONENT_TYPES } from '#src/index.js';
7
+ import { createQuestions } from '#src/questions/create-questions.js';
8
+ import { questionClasses } from '#src/questions/questions.js';
9
+ import { whenQuestionHasAnswer } from '#src/components/utils/question-has-answer.js';
10
+ import { BOOLEAN_OPTIONS } from '#src/components/boolean/question.js';
11
+ import { createAppWithQuestions, renderQuestionCheck, postAnswer } from '#test/utils/question-test-utils.js';
12
+
13
+ const JOURNEY_ID = 'dynamic-journey';
14
+
15
+ /**
16
+ * Question props for conditional journey tests
17
+ * @type {Record<string, import('#src/questions/question-props.js').QuestionProps>}
18
+ */
19
+ const questionProps = {
20
+ // Section 1: Insurance (simple + chained conditions)
21
+ wantsInsurance: {
22
+ type: COMPONENT_TYPES.BOOLEAN,
23
+ title: 'Want Insurance',
24
+ question: 'Do you want to add travel insurance?',
25
+ fieldName: 'wantsInsurance',
26
+ url: 'wants-insurance',
27
+ label: 'Add insurance?'
28
+ },
29
+ insuranceLevel: {
30
+ type: COMPONENT_TYPES.RADIO,
31
+ title: 'Insurance Level',
32
+ question: 'What level of insurance do you want?',
33
+ fieldName: 'insuranceLevel',
34
+ url: 'insurance-level',
35
+ label: 'Select an insurance level',
36
+ options: [
37
+ { value: 'basic', text: 'Basic' },
38
+ { value: 'premium', text: 'Premium' }
39
+ ]
40
+ },
41
+ premiumBenefits: {
42
+ type: COMPONENT_TYPES.CHECKBOX,
43
+ title: 'Premium Benefits',
44
+ question: 'Which premium benefits do you want?',
45
+ fieldName: 'premiumBenefits',
46
+ url: 'premium-benefits',
47
+ label: 'Select all that apply',
48
+ options: [
49
+ { value: 'cancellation', text: 'Cancellation cover' },
50
+ { value: 'medical', text: 'Medical cover' },
51
+ { value: 'luggage', text: 'Luggage cover' }
52
+ ]
53
+ },
54
+ // Section 2: Contact (always shown)
55
+ contactEmail: {
56
+ type: COMPONENT_TYPES.EMAIL,
57
+ title: 'Contact Email',
58
+ question: 'What is your email address?',
59
+ fieldName: 'contactEmail',
60
+ url: 'contact-email',
61
+ label: 'Email address'
62
+ },
63
+ // Section after the dynamic sections (regular section)
64
+ paymentMethod: {
65
+ type: COMPONENT_TYPES.RADIO,
66
+ title: 'Payment Method',
67
+ question: 'How would you like to pay?',
68
+ fieldName: 'paymentMethod',
69
+ url: 'payment-method',
70
+ label: 'Select a payment method',
71
+ options: [
72
+ { value: 'card', text: 'Card' },
73
+ { value: 'bank-transfer', text: 'Bank transfer' }
74
+ ]
75
+ }
76
+ };
77
+
78
+ // Dynamic sections: questions asked once per traveller (holiday-goer)
79
+ const travellerQuestionProps = {
80
+ passportStatus: {
81
+ type: COMPONENT_TYPES.RADIO,
82
+ title: 'Passport Status',
83
+ question: 'Does this traveller have a valid passport?',
84
+ fieldName: 'passportStatus',
85
+ url: 'passport-status',
86
+ label: 'Select passport status',
87
+ options: [
88
+ { value: 'has-passport', text: 'Has a valid passport' },
89
+ { value: 'needs-passport', text: 'Needs to apply for a passport' }
90
+ ]
91
+ },
92
+ passportNumber: {
93
+ type: COMPONENT_TYPES.SINGLE_LINE_INPUT,
94
+ title: 'Passport Number',
95
+ question: 'What is the passport number?',
96
+ fieldName: 'passportNumber',
97
+ url: 'passport-number',
98
+ label: 'Passport number'
99
+ },
100
+ dietaryRequirements: {
101
+ type: COMPONENT_TYPES.RADIO,
102
+ title: 'Dietary Requirements',
103
+ question: 'Does this traveller have any dietary requirements?',
104
+ fieldName: 'dietaryRequirements',
105
+ url: 'dietary-requirements',
106
+ label: 'Select dietary requirements',
107
+ options: [
108
+ { value: 'none', text: 'No requirements' },
109
+ { value: 'vegetarian', text: 'Vegetarian' },
110
+ { value: 'other', text: 'Other' }
111
+ ]
112
+ },
113
+ dietaryDetails: {
114
+ type: COMPONENT_TYPES.SINGLE_LINE_INPUT,
115
+ title: 'Dietary Details',
116
+ question: 'Please describe the dietary requirements',
117
+ fieldName: 'dietaryDetails',
118
+ url: 'dietary-details',
119
+ label: 'Dietary details'
120
+ }
121
+ };
122
+
123
+ /**
124
+ * The travellers that each get their own DynamicSection.
125
+ * In a real journey this list would typically come from a ManageList question
126
+ * or a database; here it is fixed to keep the test deterministic.
127
+ *
128
+ * @type {Array<{ id: string, name: string }>}
129
+ */
130
+ const travellers = [
131
+ { id: 'traveller-1', name: 'Din Djarin' },
132
+ { id: 'traveller-2', name: 'Grogu' }
133
+ ];
134
+
135
+ /**
136
+ * Create a single journey with various conditional logic scenarios:
137
+ *
138
+ * Section 1 - Insurance (simple + chained conditions):
139
+ * - wantsInsurance: always shown
140
+ * - insuranceLevel: shown if wantsInsurance = 'yes' (simple condition)
141
+ * - premiumBenefits: shown if insuranceLevel = 'premium' (chained condition)
142
+ *
143
+ * Section 2 - Trip:
144
+ * - tripType: always shown
145
+ *
146
+ * Section 3 - Visa (section-wide condition):
147
+ * - Entire section shown only if tripType = 'international'
148
+ * - visaRequired: shown if section condition met
149
+ * - visaType: shown if section condition met AND visaRequired = 'yes'
150
+ *
151
+ * Section 4 - Accommodation (multi-question condition):
152
+ * - accommodation: always shown
153
+ * - hotelStars + hotelBreakfast: shown only if accommodation = 'hotel' (multi-question group)
154
+ *
155
+ * Section 5 - Contact:
156
+ * - contactEmail: always shown
157
+ *
158
+ * Dynamic Sections - one per traveller (holiday-goer):
159
+ * - Each traveller gets a DynamicSection with its own answers stored in the
160
+ * `travellers` array (matched by the section segment / traveller id)
161
+ * - passportStatus: always shown
162
+ * - passportNumber: shown if passportStatus = 'has-passport' (question condition)
163
+ * - dietaryRequirements: always shown
164
+ * - dietaryDetails: shown if dietaryRequirements = 'other' (question condition)
165
+ * - conditions are evaluated per traveller, independently of other travellers
166
+ *
167
+ * Section 6 - Payment (regular section following the dynamic sections):
168
+ * - paymentMethod: always shown
169
+ */
170
+ function createConditionalJourney(questions, response) {
171
+ return new Journey({
172
+ journeyId: JOURNEY_ID,
173
+ sections: [
174
+ // Section 1: Insurance with simple and chained conditions
175
+ new Section('Insurance', 'insurance')
176
+ .addQuestion(questions.wantsInsurance)
177
+ .addQuestion(questions.insuranceLevel)
178
+ .withCondition(whenQuestionHasAnswer(questions.wantsInsurance, BOOLEAN_OPTIONS.YES))
179
+ .addQuestion(questions.premiumBenefits)
180
+ .withCondition(whenQuestionHasAnswer(questions.insuranceLevel, 'premium')),
181
+
182
+ // Section 2: Contact (always shown)
183
+ new Section('Contact', 'contact').addQuestion(questions.contactEmail),
184
+
185
+ // Dynamic sections: one per traveller, each with their own question conditions.
186
+ // The DynamicSection segment is the traveller id, and 'travellers' is the
187
+ // answers array key. Question conditions can only reference answers within
188
+ // the same (per-traveller) section.
189
+ ...travellers.map((traveller) => {
190
+ // create a fresh set of questions for each section
191
+ const qs = createQuestions(travellerQuestionProps, questionClasses, {});
192
+
193
+ return new DynamicSection(traveller.name, traveller.id, 'travellers')
194
+ .addQuestion(qs.passportStatus)
195
+ .addQuestion(qs.passportNumber)
196
+ .withCondition(whenQuestionHasAnswer(qs.passportStatus, 'has-passport'))
197
+ .addQuestion(qs.dietaryRequirements)
198
+ .addQuestion(qs.dietaryDetails)
199
+ .withCondition(whenQuestionHasAnswer(qs.dietaryRequirements, 'other'));
200
+ }),
201
+
202
+ // Section 5: Payment (regular section after the dynamic sections)
203
+ new Section('Payment', 'payment').addQuestion(questions.paymentMethod)
204
+ ],
205
+ taskListUrl: 'check-your-answers',
206
+ journeyTemplate: 'views/layout-journey.njk',
207
+ taskListTemplate: 'views/layout-check-your-answers.njk',
208
+ journeyTitle: 'Conditional Journey',
209
+ returnToListing: false,
210
+ makeBaseUrl: () => '/',
211
+ initialBackLink: '/start',
212
+ response
213
+ });
214
+ }
215
+
216
+ /**
217
+ * Helper to create an app with the conditional journey
218
+ * @param {import('node:test').TestContext} ctx
219
+ * @returns {Promise<import('#test/utils/test-server.js').TestServer>}
220
+ */
221
+ function createAppWithJourney(ctx) {
222
+ return createAppWithQuestions(ctx, {
223
+ journeyId: JOURNEY_ID,
224
+ questions: createQuestions(questionProps, questionClasses, {}),
225
+ createJourneyFn: createConditionalJourney
226
+ });
227
+ }
228
+
229
+ describe('dynamic journey tests', () => {
230
+ // test largely copied from conditional-journey test to ensure that logic isn't broken by DynamicSections in a Journey
231
+ describe('simple condition (whenQuestionHasAnswer on single question)', () => {
232
+ const q = questionProps;
233
+ // noinspection DuplicatedCode
234
+ it('should show conditional question when condition is met', async (ctx) => {
235
+ const testServer = await createAppWithJourney(ctx);
236
+
237
+ // First, render the initial question
238
+ await renderQuestionCheck(ctx, testServer, '/insurance/wants-insurance', q.wantsInsurance.question);
239
+
240
+ // Answer 'yes' to wantsInsurance
241
+ const location = await postAnswer(testServer, '/insurance/wants-insurance', {
242
+ [q.wantsInsurance.fieldName]: BOOLEAN_OPTIONS.YES
243
+ });
244
+
245
+ // Should redirect to insuranceLevel (conditional question)
246
+ assert.strictEqual(location, 'insurance/insurance-level');
247
+
248
+ // Render the conditional question
249
+ await renderQuestionCheck(ctx, testServer, '/insurance/insurance-level', q.insuranceLevel.question);
250
+ });
251
+
252
+ it('should skip conditional question when condition is not met', async (ctx) => {
253
+ const testServer = await createAppWithJourney(ctx);
254
+
255
+ // Answer 'no' to wantsInsurance
256
+ const location = await postAnswer(testServer, '/insurance/wants-insurance', {
257
+ [q.wantsInsurance.fieldName]: BOOLEAN_OPTIONS.NO
258
+ });
259
+
260
+ // Should skip insuranceLevel and premiumBenefits, go to next section (contact)
261
+ assert.strictEqual(location, 'contact/contact-email');
262
+ });
263
+ });
264
+
265
+ describe('dynamic sections (DynamicSection per traveller)', () => {
266
+ const q = travellerQuestionProps;
267
+ it('should navigate from a regular section into the first dynamic traveller section', async (ctx) => {
268
+ const testServer = await createAppWithJourney(ctx);
269
+
270
+ // Answer the last regular question before the dynamic sections
271
+ const location = await postAnswer(testServer, '/contact/contact-email', {
272
+ [questionProps.contactEmail.fieldName]: 'traveller@example.com'
273
+ });
274
+
275
+ // Should move into the first traveller's dynamic section
276
+ assert.strictEqual(location, 'traveller-1/passport-status');
277
+
278
+ // Render the first question of the dynamic section
279
+ await renderQuestionCheck(ctx, testServer, '/traveller-1/passport-status', q.passportStatus.question);
280
+ });
281
+
282
+ it('should show a conditional question within a traveller section when its condition is met', async (ctx) => {
283
+ const testServer = await createAppWithJourney(ctx);
284
+
285
+ // Traveller has a passport -> should ask for the passport number
286
+ const location = await postAnswer(testServer, '/traveller-1/passport-status', {
287
+ [q.passportStatus.fieldName]: 'has-passport'
288
+ });
289
+
290
+ assert.strictEqual(location, 'traveller-1/passport-number');
291
+
292
+ // Render the conditional question within the dynamic section
293
+ await renderQuestionCheck(ctx, testServer, '/traveller-1/passport-number', q.passportNumber.question);
294
+ });
295
+
296
+ it('should skip a conditional question within a traveller section when its condition is not met', async (ctx) => {
297
+ const testServer = await createAppWithJourney(ctx);
298
+
299
+ // Traveller needs to apply for a passport -> skip passport-number
300
+ const location = await postAnswer(testServer, '/traveller-1/passport-status', {
301
+ [q.passportStatus.fieldName]: 'needs-passport'
302
+ });
303
+
304
+ // Should skip passport-number and go to the next question in the section
305
+ assert.strictEqual(location, 'traveller-1/dietary-requirements');
306
+ });
307
+
308
+ it('should support a second independent condition within the same traveller section', async (ctx) => {
309
+ const testServer = await createAppWithJourney(ctx);
310
+
311
+ // Skip the passport branch
312
+ await postAnswer(testServer, '/traveller-1/passport-status', {
313
+ [q.passportStatus.fieldName]: 'needs-passport'
314
+ });
315
+
316
+ // Choose 'other' dietary requirements -> should ask for details
317
+ const location = await postAnswer(testServer, '/traveller-1/dietary-requirements', {
318
+ [q.dietaryRequirements.fieldName]: 'other'
319
+ });
320
+
321
+ assert.strictEqual(location, 'traveller-1/dietary-details');
322
+
323
+ await renderQuestionCheck(ctx, testServer, '/traveller-1/dietary-details', q.dietaryDetails.question);
324
+ });
325
+
326
+ it('should keep answers and conditions isolated per traveller', async (ctx) => {
327
+ const testServer = await createAppWithJourney(ctx);
328
+
329
+ // Traveller 1 has a passport -> conditional question is shown
330
+ const traveller1Location = await postAnswer(testServer, '/traveller-1/passport-status', {
331
+ [q.passportStatus.fieldName]: 'has-passport'
332
+ });
333
+ assert.strictEqual(traveller1Location, 'traveller-1/passport-number');
334
+
335
+ // Traveller 2 needs a passport -> same conditional question is skipped
336
+ const traveller2Location = await postAnswer(testServer, '/traveller-2/passport-status', {
337
+ [q.passportStatus.fieldName]: 'needs-passport'
338
+ });
339
+ assert.strictEqual(traveller2Location, 'traveller-2/dietary-requirements');
340
+ });
341
+
342
+ it('should navigate from one traveller section to the next', async (ctx) => {
343
+ const testServer = await createAppWithJourney(ctx);
344
+
345
+ // Complete traveller 1's section
346
+ await postAnswer(testServer, '/traveller-1/passport-status', {
347
+ [q.passportStatus.fieldName]: 'needs-passport'
348
+ });
349
+ const location = await postAnswer(testServer, '/traveller-1/dietary-requirements', {
350
+ [q.dietaryRequirements.fieldName]: 'none'
351
+ });
352
+
353
+ // Should move into the second traveller's dynamic section
354
+ assert.strictEqual(location, 'traveller-2/passport-status');
355
+ });
356
+
357
+ it('should navigate from the last traveller section into the following regular section', async (ctx) => {
358
+ const testServer = await createAppWithJourney(ctx);
359
+
360
+ // Complete the last traveller's section
361
+ await postAnswer(testServer, '/traveller-2/passport-status', {
362
+ [q.passportStatus.fieldName]: 'needs-passport'
363
+ });
364
+ const location = await postAnswer(testServer, '/traveller-2/dietary-requirements', {
365
+ [q.dietaryRequirements.fieldName]: 'none'
366
+ });
367
+
368
+ // Should move into the regular Payment section after the dynamic sections
369
+ assert.strictEqual(location, 'payment/payment-method');
370
+ });
371
+ });
372
+ });
@@ -68,7 +68,7 @@ export type SelectableOption = {
68
68
  attributes?: Record<string, string>;
69
69
  behaviour?: 'exclusive';
70
70
  conditional?: {
71
- question: string;
71
+ question?: string;
72
72
  type: string;
73
73
  fieldName: string;
74
74
  inputClasses?: string;
@@ -261,6 +261,8 @@ export type SingleLineInputQuestionParams = CommonQuestionParams & {
261
261
  inputAttributes?: Record<string, string>; // HTML attributes to add to the input
262
262
  label?: string; // if defined this will show as a label for the input and the question will just be a standard h1
263
263
  classes?: string; // HTML classes to add to the input
264
+ prefix?: Affix;
265
+ suffix?: Affix;
264
266
  };
265
267
 
266
268
  type SingleLineInputQuestionProps = SingleLineInputQuestionParams & {
@@ -92,6 +92,9 @@ export interface QuestionViewModel<TQuestionViewData extends BaseQuestionViewDat
92
92
  export interface PrepQuestionForRenderingOptions {
93
93
  params: RouteParams;
94
94
  manageListQuestion?: ManageListQuestion;
95
+ dynamicSection?: {
96
+ fieldName: string;
97
+ };
95
98
  }
96
99
 
97
100
  /**