@planning-inspectorate/dynamic-forms 2.4.0 → 3.1.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 +12 -1
- package/package.json +3 -2
- package/src/components/address/question.js +25 -19
- package/src/components/address/question.test.js +1 -1
- package/src/components/date/question.js +7 -21
- package/src/components/date/question.test.js +1 -1
- package/src/components/date-period/question.js +17 -25
- package/src/components/date-period/question.test.js +1 -1
- package/src/components/date-time/question.js +10 -23
- package/src/components/identifier/question.js +3 -6
- package/src/components/manage-list/README.md +97 -0
- package/src/components/manage-list/answer-summary-list.njk +10 -0
- package/src/components/manage-list/index.njk +61 -0
- package/src/components/manage-list/manage-list-section.js +39 -0
- package/src/components/manage-list/manage-list-section.test.js +52 -0
- package/src/components/manage-list/question.js +147 -0
- package/src/components/manage-list/question.test.js +139 -0
- package/src/components/multi-field-input/index.njk +1 -1
- package/src/components/multi-field-input/question.js +12 -13
- package/src/components/multi-field-input/question.test.js +8 -8
- package/src/components/number-entry/question.js +7 -13
- package/src/components/radio/question.js +2 -4
- package/src/components/select/question.js +2 -4
- package/src/components/single-line-input/question.js +5 -6
- package/src/components/text-entry/question.js +5 -5
- package/src/components/text-entry-redact/question.js +18 -9
- package/src/components/text-entry-redact/question.test.js +12 -19
- package/src/components/unit-option-entry/question.js +9 -8
- package/src/components/utils/nunjucks.js +20 -0
- package/src/controller.js +39 -10
- package/src/controller.test.js +90 -24
- package/src/index.js +3 -2
- package/src/journey/journey-response.js +2 -2
- package/src/journey/journey-types.d.ts +21 -1
- package/src/journey/journey.js +64 -18
- package/src/journey/journey.test.js +80 -15
- package/src/questions/options-question.js +6 -7
- package/src/questions/question-props.d.ts +2 -1
- package/src/questions/question-types.d.ts +26 -0
- package/src/questions/question.js +119 -15
- package/src/questions/question.test.js +98 -5
- package/src/questions/questions.js +3 -1
- package/src/section-types.d.ts +18 -0
- package/src/section.js +40 -11
- package/src/section.test.js +89 -0
- package/test/journey.js +5 -0
- package/test/mock/journey.js +17 -0
- package/test/questions.js +30 -1
- package/test/questions.test.js +104 -40
- package/test/snapshots/check-your-answers-not-started.html +11 -0
- package/test/snapshots/check-your-answers-with-answers.html +11 -0
- package/test/snapshots/manage-list-answer-summary.html +14 -0
- package/test/snapshots/manage-list-render-questions.html +97 -0
- package/test/snapshots/manage-list-render.html +91 -0
- package/test/snapshots/travel-companions.html +19 -0
- package/test/snapshots/travel-companions_travel-companion-email.html +21 -0
- package/test/snapshots/travel-companions_travel-companion-name.html +21 -0
- package/tsconfig.json +2 -1
|
@@ -69,12 +69,13 @@ export default class OptionsQuestion extends Question {
|
|
|
69
69
|
* @param {Journey} journey - the journey we are in
|
|
70
70
|
* @param {Record<string, unknown>} [customViewData] additional data to send to view
|
|
71
71
|
* @param {Record<string, unknown>} [payload]
|
|
72
|
+
* @param {import('#src/questions/question-types.d.ts').PrepQuestionForRenderingOptions} options
|
|
72
73
|
* @returns {QuestionViewModel}
|
|
73
74
|
*/
|
|
74
|
-
prepQuestionForRendering(section, journey, customViewData, payload) {
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
const
|
|
75
|
+
prepQuestionForRendering(section, journey, customViewData, payload, options) {
|
|
76
|
+
const viewModel = super.prepQuestionForRendering(section, journey, customViewData, payload, options);
|
|
77
|
+
const answers = payload || this.answerObjectFromJourneyResponse(journey.response, options);
|
|
78
|
+
const answer = viewModel.question.value;
|
|
78
79
|
|
|
79
80
|
viewModel.question.options = [];
|
|
80
81
|
|
|
@@ -96,9 +97,7 @@ export default class OptionsQuestion extends Question {
|
|
|
96
97
|
let conditionalField = { ...optionData.conditional };
|
|
97
98
|
|
|
98
99
|
conditionalField.fieldName = getConditionalFieldName(this.fieldName, conditionalField.fieldName);
|
|
99
|
-
conditionalField.value =
|
|
100
|
-
? payload[conditionalField.fieldName]
|
|
101
|
-
: journey.response.answers[conditionalField.fieldName] || '';
|
|
100
|
+
conditionalField.value = answers[conditionalField.fieldName] || '';
|
|
102
101
|
|
|
103
102
|
optionData.conditional = {
|
|
104
103
|
html: nunjucks.render(`./components/conditional/${conditionalField.type}.njk`, {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import BaseValidator from '../validator/base-validator';
|
|
2
2
|
import { JourneyResponse } from '../journey/journey-response';
|
|
3
|
+
import { RouteParams } from '#src/journey/journey-types.d.ts';
|
|
4
|
+
import ManageListQuestion from '#src/components/manage-list/question.js';
|
|
3
5
|
|
|
4
6
|
export interface QuestionParameters {
|
|
5
7
|
title: string;
|
|
@@ -27,3 +29,27 @@ export interface ActionLink {
|
|
|
27
29
|
text: string;
|
|
28
30
|
href: string;
|
|
29
31
|
}
|
|
32
|
+
|
|
33
|
+
export interface QuestionViewModel {
|
|
34
|
+
question: {
|
|
35
|
+
value: string | number | Record<string, any>;
|
|
36
|
+
question: string;
|
|
37
|
+
fieldName: string;
|
|
38
|
+
pageTitle: string;
|
|
39
|
+
description?: string;
|
|
40
|
+
html?: string;
|
|
41
|
+
};
|
|
42
|
+
layoutTemplate: string;
|
|
43
|
+
pageCaption: string;
|
|
44
|
+
continueButtonText: string;
|
|
45
|
+
backLink: string;
|
|
46
|
+
showBackToListLink: string;
|
|
47
|
+
listLink: string;
|
|
48
|
+
journeyTitle: string;
|
|
49
|
+
[k: string]: any;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface PrepQuestionForRenderingOptions {
|
|
53
|
+
params: RouteParams;
|
|
54
|
+
manageListQuestion?: ManageListQuestion;
|
|
55
|
+
}
|
|
@@ -25,12 +25,11 @@ import MultiFieldInputValidator from '../validator/multi-field-input-validator.j
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
|
-
* @typedef {
|
|
28
|
+
* @typedef {Record<string, any>} QuestionViewModel
|
|
29
29
|
* @property {PreppedQuestion} question
|
|
30
30
|
* @property {string} layoutTemplate
|
|
31
31
|
* @property {string} pageCaption
|
|
32
32
|
* @property {string} [continueButtonText]
|
|
33
|
-
* @property {Array.<string>} navigation
|
|
34
33
|
* @property {string} backLink
|
|
35
34
|
* @property {boolean} showBackToListLink
|
|
36
35
|
* @property {string} listLink
|
|
@@ -91,6 +90,7 @@ export class Question {
|
|
|
91
90
|
title: '',
|
|
92
91
|
text: ''
|
|
93
92
|
};
|
|
93
|
+
#isInManageListSection = false;
|
|
94
94
|
|
|
95
95
|
/**
|
|
96
96
|
* @param {import('#question-types').QuestionParameters} params
|
|
@@ -150,19 +150,72 @@ export class Question {
|
|
|
150
150
|
});
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Is this question a manage list question?
|
|
155
|
+
* Implemented as a getter so manage list question implementations can override it,
|
|
156
|
+
* but it cannot be changed at runtime.
|
|
157
|
+
*
|
|
158
|
+
* @returns {boolean}
|
|
159
|
+
*/
|
|
160
|
+
get isManageListQuestion() {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Is this question added to a ManageListSection?
|
|
166
|
+
*
|
|
167
|
+
* @returns {boolean}
|
|
168
|
+
*/
|
|
169
|
+
get isInManageListSection() {
|
|
170
|
+
return this.#isInManageListSection;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
set isInManageListSection(value) {
|
|
174
|
+
if (!value) {
|
|
175
|
+
throw new Error('Question isInManageListSection is false by default');
|
|
176
|
+
}
|
|
177
|
+
this.#isInManageListSection = value;
|
|
178
|
+
}
|
|
179
|
+
|
|
153
180
|
/**
|
|
154
181
|
* gets the view model for this question
|
|
182
|
+
*
|
|
183
|
+
* Wraps prepQuestionForRendering to add the back link - which requires more parameters
|
|
184
|
+
* that prepQuestionForRendering doesn't need
|
|
185
|
+
*
|
|
186
|
+
* @param {Object} options
|
|
187
|
+
* @param {import('#src/journey/journey-types.d.ts').RouteParams} options.params
|
|
188
|
+
* @param {import('#src/components/manage-list/question.js')} [options.manageListQuestion]
|
|
189
|
+
* @param {Section} options.section - the current section
|
|
190
|
+
* @param {Journey} options.journey - the journey we are in
|
|
191
|
+
* @param {Record<string, unknown>} [options.customViewData] additional data to send to view
|
|
192
|
+
* @param {unknown} [options.payload]
|
|
193
|
+
* @returns {QuestionViewModel}
|
|
194
|
+
*/
|
|
195
|
+
toViewModel({ params, manageListQuestion, section, journey, customViewData, payload }) {
|
|
196
|
+
const viewModel = this.prepQuestionForRendering(section, journey, customViewData, payload, {
|
|
197
|
+
params,
|
|
198
|
+
manageListQuestion
|
|
199
|
+
});
|
|
200
|
+
viewModel.backLink = journey.getBackLink({ params, manageListQuestion });
|
|
201
|
+
return viewModel;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* gets the base view model for this question
|
|
206
|
+
*
|
|
155
207
|
* @param {Section} section - the current section
|
|
156
208
|
* @param {Journey} journey - the journey we are in
|
|
157
209
|
* @param {Record<string, unknown>} [customViewData] additional data to send to view
|
|
158
210
|
* @param {unknown} [payload]
|
|
211
|
+
* @param {import('./question-types.d.ts').PrepQuestionForRenderingOptions} [options] - required to support manage list question
|
|
159
212
|
* @returns {QuestionViewModel}
|
|
160
213
|
*/
|
|
161
|
-
prepQuestionForRendering(section, journey, customViewData, payload) {
|
|
162
|
-
const
|
|
163
|
-
const
|
|
214
|
+
prepQuestionForRendering(section, journey, customViewData, payload, options) {
|
|
215
|
+
const answers = payload || this.answerObjectFromJourneyResponse(journey.response, options);
|
|
216
|
+
const answer = this.answerForViewModel(answers, Boolean(payload));
|
|
164
217
|
|
|
165
|
-
|
|
218
|
+
const viewModel = {
|
|
166
219
|
question: {
|
|
167
220
|
value: answer,
|
|
168
221
|
question: this.question,
|
|
@@ -179,8 +232,6 @@ export class Question {
|
|
|
179
232
|
layoutTemplate: journey.journeyTemplate,
|
|
180
233
|
pageCaption: section?.name,
|
|
181
234
|
|
|
182
|
-
navigation: ['', backLink],
|
|
183
|
-
backLink,
|
|
184
235
|
showBackToListLink: this.showBackToListLink,
|
|
185
236
|
listLink: journey.taskListUrl,
|
|
186
237
|
journeyTitle: journey.journeyTitle,
|
|
@@ -191,6 +242,56 @@ export class Question {
|
|
|
191
242
|
...customViewData,
|
|
192
243
|
...this.viewData
|
|
193
244
|
};
|
|
245
|
+
this.addCustomDataToViewModel(viewModel);
|
|
246
|
+
return viewModel;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The answer to this question for use in the viewModel
|
|
251
|
+
*
|
|
252
|
+
* Question implementations can override this for more complex answer types
|
|
253
|
+
*
|
|
254
|
+
* @param {Record<string, any>} answers - collection of answers to pull the answer from, may be from the response or the request/payload
|
|
255
|
+
* @param {Boolean} isPayload - whether the answers object is from the request/payload
|
|
256
|
+
* @returns {*|string}
|
|
257
|
+
*/ // eslint-disable-next-line no-unused-vars
|
|
258
|
+
answerForViewModel(answers, isPayload) {
|
|
259
|
+
return answers[this.fieldName] || '';
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Question implementations can override this to add configuration or other values to the view model
|
|
264
|
+
*
|
|
265
|
+
* If possible override this method instead of prepQuestionForRendering for simple changes to the view model
|
|
266
|
+
*
|
|
267
|
+
* @param {QuestionViewModel} viewModel
|
|
268
|
+
*/ // eslint-disable-next-line no-unused-vars
|
|
269
|
+
addCustomDataToViewModel(viewModel) {}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Get the answers object from the journey response, which may be nested in an array for manage list questions
|
|
273
|
+
*
|
|
274
|
+
* @param {JourneyResponse} response
|
|
275
|
+
* @param {import('./question-types.d.ts').PrepQuestionForRenderingOptions} [options]
|
|
276
|
+
* @returns {Record<string, any>}
|
|
277
|
+
*/
|
|
278
|
+
answerObjectFromJourneyResponse(response, { params, manageListQuestion } = {}) {
|
|
279
|
+
if (this.isInManageListSection) {
|
|
280
|
+
if (!params?.manageListItemId) {
|
|
281
|
+
throw new Error('no list item id for manage list question');
|
|
282
|
+
}
|
|
283
|
+
if (!manageListQuestion) {
|
|
284
|
+
throw new Error('no manageListQuestion for manage list question');
|
|
285
|
+
}
|
|
286
|
+
// if this is a manage list question, the response is within the 'parent' manage list answers array
|
|
287
|
+
const answers = response.answers[manageListQuestion.fieldName];
|
|
288
|
+
if (!Array.isArray(answers)) {
|
|
289
|
+
return {};
|
|
290
|
+
}
|
|
291
|
+
const itemId = params.manageListItemId;
|
|
292
|
+
return answers.find((a) => a.id === itemId) || {};
|
|
293
|
+
}
|
|
294
|
+
return response.answers;
|
|
194
295
|
}
|
|
195
296
|
|
|
196
297
|
/**
|
|
@@ -212,23 +313,26 @@ export class Question {
|
|
|
212
313
|
* check for validation errors
|
|
213
314
|
* @param {import('express').Request} req
|
|
214
315
|
* @param {Journey} journey
|
|
215
|
-
* @param {Section}
|
|
316
|
+
* @param {Section} section
|
|
317
|
+
* @param {import('#src/components/manage-list/question.js')} [manageListQuestion]
|
|
216
318
|
* @returns {QuestionViewModel|undefined} returns the view model for displaying the error or undefined if there are no errors
|
|
217
319
|
*/
|
|
218
|
-
checkForValidationErrors(req,
|
|
320
|
+
checkForValidationErrors(req, section, journey, manageListQuestion) {
|
|
219
321
|
const { body = {} } = req;
|
|
220
322
|
const { errors = {}, errorSummary = [] } = body;
|
|
221
323
|
|
|
222
324
|
if (Object.keys(errors).length > 0) {
|
|
223
|
-
return this.
|
|
224
|
-
|
|
325
|
+
return this.toViewModel({
|
|
326
|
+
params: req.params,
|
|
327
|
+
section,
|
|
225
328
|
journey,
|
|
226
|
-
{
|
|
329
|
+
customViewData: {
|
|
227
330
|
errors,
|
|
228
331
|
errorSummary
|
|
229
332
|
},
|
|
230
|
-
body
|
|
231
|
-
|
|
333
|
+
payload: body,
|
|
334
|
+
manageListQuestion
|
|
335
|
+
});
|
|
232
336
|
}
|
|
233
337
|
}
|
|
234
338
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, it, mock } from 'node:test';
|
|
2
2
|
import assert from 'node:assert';
|
|
3
|
-
import { Question } from '
|
|
4
|
-
import { mockRes } from '
|
|
3
|
+
import { Question } from '#question';
|
|
4
|
+
import { mockRes } from '#test/utils/utils.js';
|
|
5
5
|
|
|
6
6
|
const res = mockRes();
|
|
7
7
|
|
|
@@ -63,6 +63,7 @@ describe('./src/dynamic-forms/question.js', () => {
|
|
|
63
63
|
assert.strictEqual(question.validators, VALIDATORS);
|
|
64
64
|
assert.strictEqual(question.html, HTML);
|
|
65
65
|
assert.strictEqual(question.hint, HINT);
|
|
66
|
+
assert.strictEqual(question.isManageListQuestion, false);
|
|
66
67
|
});
|
|
67
68
|
it('should support viewData field', () => {
|
|
68
69
|
const question = getTestQuestion({ viewData: { test: 'test' } });
|
|
@@ -110,6 +111,30 @@ describe('./src/dynamic-forms/question.js', () => {
|
|
|
110
111
|
});
|
|
111
112
|
});
|
|
112
113
|
|
|
114
|
+
describe('isInManageListSection', () => {
|
|
115
|
+
it('should default to false', () => {
|
|
116
|
+
const question = getTestQuestion();
|
|
117
|
+
assert.strictEqual(question.isInManageListSection, false);
|
|
118
|
+
});
|
|
119
|
+
it('should support setting isInManageListSection', () => {
|
|
120
|
+
const question = getTestQuestion();
|
|
121
|
+
assert.strictEqual(question.isInManageListSection, false);
|
|
122
|
+
question.isInManageListSection = true;
|
|
123
|
+
assert.strictEqual(question.isInManageListSection, true);
|
|
124
|
+
});
|
|
125
|
+
it('should not support setting isInManageListSection false', () => {
|
|
126
|
+
const question = getTestQuestion();
|
|
127
|
+
assert.strictEqual(question.isInManageListSection, false);
|
|
128
|
+
assert.throws(
|
|
129
|
+
() => (question.isInManageListSection = false),
|
|
130
|
+
(error) => {
|
|
131
|
+
assert.strictEqual(error.message, 'Question isInManageListSection is false by default');
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
113
138
|
describe('prepQuestionForRendering', () => {
|
|
114
139
|
it('should prepQuestionForRendering', () => {
|
|
115
140
|
const question = getTestQuestion();
|
|
@@ -150,8 +175,6 @@ describe('./src/dynamic-forms/question.js', () => {
|
|
|
150
175
|
assert.deepStrictEqual(result.answer, journey.response.answers[question.fieldName]);
|
|
151
176
|
assert.deepStrictEqual(result.layoutTemplate, journey.journeyTemplate);
|
|
152
177
|
assert.deepStrictEqual(result.pageCaption, section.name);
|
|
153
|
-
assert.deepStrictEqual(result.navigation, ['', 'back']);
|
|
154
|
-
assert.deepStrictEqual(result.backLink, 'back');
|
|
155
178
|
assert.deepStrictEqual(result.showBackToListLink, question.showBackToListLink);
|
|
156
179
|
assert.deepStrictEqual(result.listLink, journey.taskListUrl);
|
|
157
180
|
assert.deepStrictEqual(result.journeyTitle, journey.journeyTitle);
|
|
@@ -185,6 +208,76 @@ describe('./src/dynamic-forms/question.js', () => {
|
|
|
185
208
|
});
|
|
186
209
|
});
|
|
187
210
|
|
|
211
|
+
describe('answerObjectFromJourney', () => {
|
|
212
|
+
it('should return journey response for regular questions', () => {
|
|
213
|
+
const question = getTestQuestion();
|
|
214
|
+
const answers = { myField: 'my-value' };
|
|
215
|
+
const journeyResponse = {
|
|
216
|
+
answers
|
|
217
|
+
};
|
|
218
|
+
const got = question.answerObjectFromJourneyResponse(journeyResponse);
|
|
219
|
+
assert.strictEqual(got, answers);
|
|
220
|
+
});
|
|
221
|
+
it('should return the manage list item object for manage list questions', () => {
|
|
222
|
+
const question = getTestQuestion();
|
|
223
|
+
question.isInManageListSection = true;
|
|
224
|
+
const manageListQuestion = {
|
|
225
|
+
fieldName: 'manageListQuestion'
|
|
226
|
+
};
|
|
227
|
+
const answers = {
|
|
228
|
+
id: '1',
|
|
229
|
+
myField: 'my-value'
|
|
230
|
+
};
|
|
231
|
+
const journeyResponse = {
|
|
232
|
+
answers: {
|
|
233
|
+
manageListQuestion: [answers]
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
const params = { manageListItemId: '1' };
|
|
237
|
+
const got = question.answerObjectFromJourneyResponse(journeyResponse, { params, manageListQuestion });
|
|
238
|
+
assert.strictEqual(got, answers);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('should fallback to {} if manage list item id not found', () => {
|
|
242
|
+
const question = getTestQuestion();
|
|
243
|
+
question.isInManageListSection = true;
|
|
244
|
+
const manageListQuestion = {
|
|
245
|
+
fieldName: 'manageListQuestion'
|
|
246
|
+
};
|
|
247
|
+
const answers = {
|
|
248
|
+
id: '2',
|
|
249
|
+
myField: 'my-value'
|
|
250
|
+
};
|
|
251
|
+
const journeyResponse = {
|
|
252
|
+
answers: {
|
|
253
|
+
manageListQuestion: [answers]
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
const params = { manageListItemId: '1' };
|
|
257
|
+
let got = question.answerObjectFromJourneyResponse(journeyResponse, { params, manageListQuestion });
|
|
258
|
+
assert.deepStrictEqual(got, {});
|
|
259
|
+
// also fallback to {} if there is no manageListQuestion answer
|
|
260
|
+
delete journeyResponse.answers.manageListQuestion;
|
|
261
|
+
got = question.answerObjectFromJourneyResponse(journeyResponse, { params, manageListQuestion });
|
|
262
|
+
assert.deepStrictEqual(got, {});
|
|
263
|
+
});
|
|
264
|
+
it('should error if a manage list question is missing required parameters', () => {
|
|
265
|
+
const question = getTestQuestion();
|
|
266
|
+
question.isInManageListSection = true;
|
|
267
|
+
const answers = { myField: 'my-value' };
|
|
268
|
+
const journeyResponse = { answers };
|
|
269
|
+
assert.throws(
|
|
270
|
+
() => question.answerObjectFromJourneyResponse(journeyResponse),
|
|
271
|
+
/no list item id for manage list question/
|
|
272
|
+
);
|
|
273
|
+
const params = { manageListItemId: '1' };
|
|
274
|
+
assert.throws(
|
|
275
|
+
() => question.answerObjectFromJourneyResponse(journeyResponse, { params }),
|
|
276
|
+
/no manageListQuestion for manage list question/
|
|
277
|
+
);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
188
281
|
describe('renderAction', () => {
|
|
189
282
|
it('should renderAction', () => {
|
|
190
283
|
const question = getTestQuestion();
|
|
@@ -205,7 +298,7 @@ describe('./src/dynamic-forms/question.js', () => {
|
|
|
205
298
|
const expectedResult = { a: 1 };
|
|
206
299
|
const req = { body: { errors: { error: 'we have an error' } } };
|
|
207
300
|
const question = getTestQuestion();
|
|
208
|
-
question.
|
|
301
|
+
question.toViewModel = mock.fn(() => expectedResult);
|
|
209
302
|
|
|
210
303
|
const result = question.checkForValidationErrors(req);
|
|
211
304
|
|
|
@@ -15,6 +15,7 @@ import EmailQuestion from '../components/email/question.js';
|
|
|
15
15
|
import { COMPONENT_TYPES } from '../index.js';
|
|
16
16
|
import TextEntryRedactQuestion from '../components/text-entry-redact/question.js';
|
|
17
17
|
import DateTimeQuestion from '../components/date-time/question.js';
|
|
18
|
+
import ManageListQuestion from '../components/manage-list/question.js';
|
|
18
19
|
|
|
19
20
|
// This looks a bit grim because so few of our
|
|
20
21
|
// Questions overlap with Question correctly.
|
|
@@ -35,5 +36,6 @@ export const questionClasses = Object.freeze({
|
|
|
35
36
|
[COMPONENT_TYPES.MULTI_FIELD_INPUT]: MultiFieldInputQuestion,
|
|
36
37
|
[COMPONENT_TYPES.NUMBER]: NumberEntryQuestion,
|
|
37
38
|
[COMPONENT_TYPES.UNIT_OPTION]: UnitOptionEntryQuestion,
|
|
38
|
-
[COMPONENT_TYPES.EMAIL]: EmailQuestion
|
|
39
|
+
[COMPONENT_TYPES.EMAIL]: EmailQuestion,
|
|
40
|
+
[COMPONENT_TYPES.MANAGE_LIST]: ManageListQuestion
|
|
39
41
|
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { JourneyResponse } from '#src/journey/journey-response.js';
|
|
2
|
+
import ManageListQuestion from '#src/components/manage-list/question.js';
|
|
3
|
+
import { Question } from '#src/questions/question.js';
|
|
4
|
+
|
|
5
|
+
export interface GetNextQuestionParams {
|
|
6
|
+
questionFieldName: string;
|
|
7
|
+
response: JourneyResponse;
|
|
8
|
+
// if this is part of a manage list section
|
|
9
|
+
manageListQuestion?: ManageListQuestion;
|
|
10
|
+
takeNextQuestion: boolean;
|
|
11
|
+
// to get previous question instead of next
|
|
12
|
+
reverse: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface StaticGetNextQuestionParams extends GetNextQuestionParams {
|
|
16
|
+
manageListQuestion: undefined;
|
|
17
|
+
questions: Question[];
|
|
18
|
+
}
|
package/src/section.js
CHANGED
|
@@ -82,13 +82,20 @@ export class Section {
|
|
|
82
82
|
|
|
83
83
|
/**
|
|
84
84
|
* Fluent API method for adding questions
|
|
85
|
-
* @param {
|
|
85
|
+
* @param {import('#src/questions/question.js').Question} question
|
|
86
|
+
* @param {import('#src/components/manage-list/manage-list-section.js').ManageListSection} [manageListSection]
|
|
86
87
|
* @returns {Section}
|
|
87
88
|
*/
|
|
88
|
-
addQuestion(question) {
|
|
89
|
+
addQuestion(question, manageListSection) {
|
|
89
90
|
if (!question) {
|
|
90
91
|
throw new Error('question is required');
|
|
91
92
|
}
|
|
93
|
+
if (question.isManageListQuestion) {
|
|
94
|
+
if (!manageListSection || !manageListSection.isManageListSection) {
|
|
95
|
+
throw new Error('manage list questions require a ManageListSection');
|
|
96
|
+
}
|
|
97
|
+
question.section = manageListSection;
|
|
98
|
+
}
|
|
92
99
|
this.questions.push(question);
|
|
93
100
|
this.#conditionAdded = false; // reset condition flag
|
|
94
101
|
this.#applyConditions(question);
|
|
@@ -192,24 +199,46 @@ export class Section {
|
|
|
192
199
|
|
|
193
200
|
/**
|
|
194
201
|
* Get the next question in this section given a questionParam (question fieldName)
|
|
195
|
-
* @param {
|
|
196
|
-
* @
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
202
|
+
* @param {import('./section-types.d.ts').GetNextQuestionParams} params
|
|
203
|
+
* @returns {Question|Symbol|null}
|
|
204
|
+
*/
|
|
205
|
+
getNextQuestion(params) {
|
|
206
|
+
const { manageListQuestion } = params;
|
|
207
|
+
if (manageListQuestion) {
|
|
208
|
+
// first check if the next question is within the manage list section
|
|
209
|
+
const next = Section.getNextQuestion({
|
|
210
|
+
...params,
|
|
211
|
+
questions: manageListQuestion.section.questions
|
|
212
|
+
});
|
|
213
|
+
if (next === END_OF_SECTION) {
|
|
214
|
+
// after the manage list section questions, go back to the manage list question
|
|
215
|
+
return manageListQuestion;
|
|
216
|
+
}
|
|
217
|
+
return next;
|
|
218
|
+
}
|
|
219
|
+
return Section.getNextQuestion({
|
|
220
|
+
...params,
|
|
221
|
+
questions: this.questions
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Implementation of getNextQuestion given a list of questions
|
|
227
|
+
*
|
|
228
|
+
* @param {import('./section-types.d.ts').StaticGetNextQuestionParams} params
|
|
200
229
|
* @returns {Question|Symbol|null}
|
|
201
230
|
*/
|
|
202
|
-
getNextQuestion({ questionFieldName, response, takeNextQuestion = false, reverse = false }) {
|
|
203
|
-
const numberOfQuestions =
|
|
231
|
+
static getNextQuestion({ questions, questionFieldName, response, takeNextQuestion = false, reverse = false }) {
|
|
232
|
+
const numberOfQuestions = questions.length;
|
|
204
233
|
|
|
205
234
|
const questionsStart = reverse ? numberOfQuestions - 1 : 0;
|
|
206
235
|
for (let i = questionsStart; reverse ? i >= 0 : i < numberOfQuestions; reverse ? i-- : i++) {
|
|
207
|
-
const question =
|
|
236
|
+
const question = questions[i];
|
|
208
237
|
if (takeNextQuestion && question.shouldDisplay(response)) {
|
|
209
238
|
return question;
|
|
210
239
|
}
|
|
211
240
|
|
|
212
|
-
if (question.fieldName === questionFieldName) {
|
|
241
|
+
if (question.fieldName === questionFieldName || question.url === questionFieldName) {
|
|
213
242
|
takeNextQuestion = true;
|
|
214
243
|
}
|
|
215
244
|
}
|
package/src/section.test.js
CHANGED
|
@@ -53,6 +53,42 @@ describe('./src/dynamic-forms/section.js', () => {
|
|
|
53
53
|
assert.strictEqual(question.isRequired, mockQuestion.isRequired);
|
|
54
54
|
assert.strictEqual(question.shouldDisplay(), mockQuestion.shouldDisplay());
|
|
55
55
|
});
|
|
56
|
+
|
|
57
|
+
it('should support manage list questions', () => {
|
|
58
|
+
const section = new Section('s1', 'S');
|
|
59
|
+
assert.strictEqual(section.questions.length, 0);
|
|
60
|
+
const q = {
|
|
61
|
+
get isManageListQuestion() {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
const manageListSection = {
|
|
66
|
+
get isManageListSection() {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
section.addQuestion(q, manageListSection);
|
|
71
|
+
assert.strictEqual(section.questions.length, 1);
|
|
72
|
+
assert.strictEqual(q.section, manageListSection);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should throw if no manageListSection provided for a manage list question', () => {
|
|
76
|
+
const section = new Section('s1', 'S');
|
|
77
|
+
assert.strictEqual(section.questions.length, 0);
|
|
78
|
+
const q = {
|
|
79
|
+
get isManageListQuestion() {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
assert.throws(
|
|
84
|
+
() => section.addQuestion(q),
|
|
85
|
+
(thrown) => {
|
|
86
|
+
assert.strictEqual(thrown.message, 'manage list questions require a ManageListSection');
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
);
|
|
90
|
+
assert.strictEqual(section.questions.length, 0);
|
|
91
|
+
});
|
|
56
92
|
});
|
|
57
93
|
|
|
58
94
|
describe('getStatus', () => {
|
|
@@ -660,5 +696,58 @@ describe('./src/dynamic-forms/section.js', () => {
|
|
|
660
696
|
assert.strictEqual(q3.shouldDisplay.mock.callCount(), 1);
|
|
661
697
|
assert.strictEqual(next, END_OF_SECTION);
|
|
662
698
|
});
|
|
699
|
+
describe('manageListQuestions', () => {
|
|
700
|
+
const newSectionWithManageList = () => {
|
|
701
|
+
const q1 = { ...mockQuestion };
|
|
702
|
+
const manageListQ = {
|
|
703
|
+
get isManageListQuestion() {
|
|
704
|
+
return true;
|
|
705
|
+
}
|
|
706
|
+
};
|
|
707
|
+
const q3 = { ...mockQuestion2 };
|
|
708
|
+
const q4 = { ...mockQuestion3 };
|
|
709
|
+
const manageListSection = {
|
|
710
|
+
questions: [q3, q4],
|
|
711
|
+
get isManageListSection() {
|
|
712
|
+
return true;
|
|
713
|
+
}
|
|
714
|
+
};
|
|
715
|
+
const section = new Section('s1', 'S').addQuestion(q1).addQuestion(manageListQ, manageListSection);
|
|
716
|
+
return { section, q1, manageListQ, q3, q4 };
|
|
717
|
+
};
|
|
718
|
+
it(`should get first question within a manage list section`, () => {
|
|
719
|
+
const { section, manageListQ, q3 } = newSectionWithManageList();
|
|
720
|
+
const next = section.getNextQuestion({
|
|
721
|
+
questionFieldName: manageListQ.fieldName,
|
|
722
|
+
response: {},
|
|
723
|
+
manageListQuestion: manageListQ,
|
|
724
|
+
takeNextQuestion: true,
|
|
725
|
+
reverse: false
|
|
726
|
+
});
|
|
727
|
+
assert.strictEqual(next, q3);
|
|
728
|
+
});
|
|
729
|
+
it(`should get second question within a manage list section`, () => {
|
|
730
|
+
const { section, manageListQ, q3, q4 } = newSectionWithManageList();
|
|
731
|
+
const next = section.getNextQuestion({
|
|
732
|
+
questionFieldName: q3.fieldName,
|
|
733
|
+
response: {},
|
|
734
|
+
manageListQuestion: manageListQ,
|
|
735
|
+
takeNextQuestion: false,
|
|
736
|
+
reverse: false
|
|
737
|
+
});
|
|
738
|
+
assert.strictEqual(next, q4);
|
|
739
|
+
});
|
|
740
|
+
it(`should return to manage list question after manage list section is complete`, () => {
|
|
741
|
+
const { section, manageListQ, q4 } = newSectionWithManageList();
|
|
742
|
+
const next = section.getNextQuestion({
|
|
743
|
+
questionFieldName: q4.fieldName,
|
|
744
|
+
response: {},
|
|
745
|
+
manageListQuestion: manageListQ,
|
|
746
|
+
takeNextQuestion: false,
|
|
747
|
+
reverse: false
|
|
748
|
+
});
|
|
749
|
+
assert.strictEqual(next, manageListQ);
|
|
750
|
+
});
|
|
751
|
+
});
|
|
663
752
|
});
|
|
664
753
|
});
|
package/test/journey.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Section } from '../src/section.js';
|
|
2
2
|
import { Journey } from '../src/journey/journey.js';
|
|
3
|
+
import { ManageListSection } from '#src/components/manage-list/manage-list-section.js';
|
|
3
4
|
|
|
4
5
|
export const JOURNEY_ID = 'holiday-journey';
|
|
5
6
|
|
|
@@ -29,6 +30,10 @@ export function createJourney(questions, response) {
|
|
|
29
30
|
.addQuestion(questions.hotelAddress)
|
|
30
31
|
.addQuestion(questions.luggageWeight)
|
|
31
32
|
.addQuestion(questions.contactEmail)
|
|
33
|
+
.addQuestion(
|
|
34
|
+
questions.travelCompanions,
|
|
35
|
+
new ManageListSection().addQuestion(questions.travelCompanionName).addQuestion(questions.travelCompanionEmail)
|
|
36
|
+
)
|
|
32
37
|
],
|
|
33
38
|
taskListUrl: 'check-your-answers',
|
|
34
39
|
journeyTemplate: 'views/layout-journey.njk',
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Journey } from '#src/journey/journey.js';
|
|
2
|
+
|
|
3
|
+
export function mockJourney() {
|
|
4
|
+
return new Journey({
|
|
5
|
+
journeyId: 'mock-journey-id',
|
|
6
|
+
makeBaseUrl: () => '/journey',
|
|
7
|
+
taskListUrl: 'check-your-answers',
|
|
8
|
+
response: { answers: {} },
|
|
9
|
+
journeyTemplate: 'views/layout-journey.njk',
|
|
10
|
+
taskListTemplate: 'views/layout-check-your-answers.njk',
|
|
11
|
+
informationPageViewPath: '',
|
|
12
|
+
journeyTitle: 'Mock Journey',
|
|
13
|
+
returnToListing: false,
|
|
14
|
+
sections: [],
|
|
15
|
+
initialBackLink: '/journey'
|
|
16
|
+
});
|
|
17
|
+
}
|