@planning-inspectorate/dynamic-forms 3.21.0 → 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.
- package/README.md +52 -1
- package/docs/Dynamic Sections.md +47 -0
- package/package.json +1 -1
- package/src/components/manage-list/question.d.ts.map +1 -1
- package/src/components/manage-list/question.js +5 -0
- package/src/components/manage-list/question.test.js +3 -1
- package/src/components/multi-field-input/question.d.ts.map +1 -1
- package/src/components/multi-field-input/question.js +7 -5
- package/src/components/multi-field-input/question.test.js +126 -213
- package/src/components/text-entry-redact/question.d.ts.map +1 -1
- package/src/components/text-entry-redact/question.js +3 -1
- package/src/components/text-entry-redact/question.test.js +9 -0
- package/src/components/unit-option-entry/question.d.ts.map +1 -1
- package/src/components/unit-option-entry/question.js +3 -1
- package/src/components/unit-option-entry/question.test.js +9 -0
- package/src/controller.d.ts +15 -3
- package/src/controller.d.ts.map +1 -1
- package/src/controller.js +19 -11
- package/src/controller.test.js +182 -153
- package/src/dynamic-section.d.ts +40 -0
- package/src/dynamic-section.d.ts.map +1 -0
- package/src/dynamic-section.js +51 -0
- package/src/dynamic-section.test.js +113 -0
- package/src/index.d.ts +2 -1
- package/src/index.js +2 -1
- package/src/index.test.js +3 -0
- package/src/journey/journey.d.ts +10 -0
- package/src/journey/journey.d.ts.map +1 -1
- package/src/journey/journey.js +15 -0
- package/src/lib/answer-utils.d.ts +22 -0
- package/src/lib/answer-utils.d.ts.map +1 -0
- package/src/lib/answer-utils.js +34 -0
- package/src/{components/manage-list/utils.test.js → lib/answer-utils.test.js} +9 -11
- package/src/lib/session-answer-store.d.ts.map +1 -1
- package/src/lib/session-answer-store.js +14 -6
- package/src/lib/session-answer-store.test.js +43 -0
- package/src/middleware/redirect-to-unanswered-question.d.ts.map +1 -1
- package/src/middleware/redirect-to-unanswered-question.js +4 -3
- package/src/middleware/redirect-to-unanswered-question.test.js +32 -0
- package/src/questions/question.d.ts +8 -3
- package/src/questions/question.d.ts.map +1 -1
- package/src/questions/question.js +16 -6
- package/src/questions/question.test.js +19 -0
- package/src/section.d.ts +19 -0
- package/src/section.d.ts.map +1 -1
- package/src/section.js +50 -9
- package/src/section.test.js +9 -0
- package/test/dynamic-journey.test.js +372 -0
- package/typedefs/question-types.d.ts +3 -0
- package/src/components/manage-list/utils.d.ts +0 -18
- package/src/components/manage-list/utils.d.ts.map +0 -1
- package/src/components/manage-list/utils.js +0 -35
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
|
package/src/section.d.ts.map
CHANGED
|
@@ -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;
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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(
|
|
295
|
+
if (question.isAnswered(response, field.fieldName)) {
|
|
275
296
|
answerCount++;
|
|
276
297
|
}
|
|
277
|
-
if (question.isAnswered(
|
|
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(
|
|
307
|
+
if (question.isAnswered(response)) {
|
|
287
308
|
answerCount++;
|
|
288
309
|
}
|
|
289
310
|
|
|
290
|
-
if (question.isAnswered(
|
|
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
|
package/src/section.test.js
CHANGED
|
@@ -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
|
+
});
|
|
@@ -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
|
/**
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
3
|
-
* @param {import('../../journey/journey-response.js').JourneyResponse} response
|
|
4
|
-
* @param {import('./question.js')} manageListQuestion
|
|
5
|
-
* @param {string} manageListItemId
|
|
6
|
-
* @returns {Record<string, unknown>}
|
|
7
|
-
*/
|
|
8
|
-
export function answerObjectForManageList(response: import("../../journey/journey-response.js").JourneyResponse, manageListQuestion: typeof import("./question.js"), manageListItemId: string): Record<string, unknown>;
|
|
9
|
-
/**
|
|
10
|
-
* Similar to answerObjectForManageList but will edit response and add a new array entry if not found
|
|
11
|
-
*
|
|
12
|
-
* @param {import('../../journey/journey-response.js').JourneyResponse} response
|
|
13
|
-
* @param {import('./question.js')} manageListQuestion
|
|
14
|
-
* @param {import('#typedefs/journey-types.d.ts').RouteParams} params
|
|
15
|
-
* @returns {import('#typedefs/journey-types.d.ts').ManageListAnswers}
|
|
16
|
-
*/
|
|
17
|
-
export function answerObjectForManageListSaving(response: import("../../journey/journey-response.js").JourneyResponse, manageListQuestion: typeof import("./question.js"), params: import("#typedefs/journey-types.d.ts").RouteParams): import("#typedefs/journey-types.d.ts").ManageListAnswers;
|
|
18
|
-
//# sourceMappingURL=utils.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["utils.js"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,oDALW,OAAO,mCAAmC,EAAE,eAAe,wEAE3D,MAAM,GACJ,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAQnC;AAED;;;;;;;GAOG;AACH,0DALW,OAAO,mCAAmC,EAAE,eAAe,8DAE3D,OAAO,8BAA8B,EAAE,WAAW,GAChD,OAAO,8BAA8B,EAAE,iBAAiB,CAapE"}
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
3
|
-
* @param {import('../../journey/journey-response.js').JourneyResponse} response
|
|
4
|
-
* @param {import('./question.js')} manageListQuestion
|
|
5
|
-
* @param {string} manageListItemId
|
|
6
|
-
* @returns {Record<string, unknown>}
|
|
7
|
-
*/
|
|
8
|
-
export function answerObjectForManageList(response, manageListQuestion, manageListItemId) {
|
|
9
|
-
const answers = response.answers[manageListQuestion.fieldName];
|
|
10
|
-
if (!Array.isArray(answers)) {
|
|
11
|
-
return {};
|
|
12
|
-
}
|
|
13
|
-
return answers.find((a) => a.id === manageListItemId) || {};
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Similar to answerObjectForManageList but will edit response and add a new array entry if not found
|
|
18
|
-
*
|
|
19
|
-
* @param {import('../../journey/journey-response.js').JourneyResponse} response
|
|
20
|
-
* @param {import('./question.js')} manageListQuestion
|
|
21
|
-
* @param {import('#typedefs/journey-types.d.ts').RouteParams} params
|
|
22
|
-
* @returns {import('#typedefs/journey-types.d.ts').ManageListAnswers}
|
|
23
|
-
*/
|
|
24
|
-
export function answerObjectForManageListSaving(response, manageListQuestion, params) {
|
|
25
|
-
const answersList =
|
|
26
|
-
response.answers[manageListQuestion?.fieldName] || (response.answers[manageListQuestion?.fieldName] = []);
|
|
27
|
-
let answers = answersList.find((item) => item.id === params.manageListItemId);
|
|
28
|
-
if (!answers) {
|
|
29
|
-
answers = {
|
|
30
|
-
id: params.manageListItemId
|
|
31
|
-
};
|
|
32
|
-
answersList.push(answers);
|
|
33
|
-
}
|
|
34
|
-
return answers;
|
|
35
|
-
}
|