@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.
Files changed (58) hide show
  1. package/README.md +12 -1
  2. package/package.json +3 -2
  3. package/src/components/address/question.js +25 -19
  4. package/src/components/address/question.test.js +1 -1
  5. package/src/components/date/question.js +7 -21
  6. package/src/components/date/question.test.js +1 -1
  7. package/src/components/date-period/question.js +17 -25
  8. package/src/components/date-period/question.test.js +1 -1
  9. package/src/components/date-time/question.js +10 -23
  10. package/src/components/identifier/question.js +3 -6
  11. package/src/components/manage-list/README.md +97 -0
  12. package/src/components/manage-list/answer-summary-list.njk +10 -0
  13. package/src/components/manage-list/index.njk +61 -0
  14. package/src/components/manage-list/manage-list-section.js +39 -0
  15. package/src/components/manage-list/manage-list-section.test.js +52 -0
  16. package/src/components/manage-list/question.js +147 -0
  17. package/src/components/manage-list/question.test.js +139 -0
  18. package/src/components/multi-field-input/index.njk +1 -1
  19. package/src/components/multi-field-input/question.js +12 -13
  20. package/src/components/multi-field-input/question.test.js +8 -8
  21. package/src/components/number-entry/question.js +7 -13
  22. package/src/components/radio/question.js +2 -4
  23. package/src/components/select/question.js +2 -4
  24. package/src/components/single-line-input/question.js +5 -6
  25. package/src/components/text-entry/question.js +5 -5
  26. package/src/components/text-entry-redact/question.js +18 -9
  27. package/src/components/text-entry-redact/question.test.js +12 -19
  28. package/src/components/unit-option-entry/question.js +9 -8
  29. package/src/components/utils/nunjucks.js +20 -0
  30. package/src/controller.js +39 -10
  31. package/src/controller.test.js +90 -24
  32. package/src/index.js +3 -2
  33. package/src/journey/journey-response.js +2 -2
  34. package/src/journey/journey-types.d.ts +21 -1
  35. package/src/journey/journey.js +64 -18
  36. package/src/journey/journey.test.js +80 -15
  37. package/src/questions/options-question.js +6 -7
  38. package/src/questions/question-props.d.ts +2 -1
  39. package/src/questions/question-types.d.ts +26 -0
  40. package/src/questions/question.js +119 -15
  41. package/src/questions/question.test.js +98 -5
  42. package/src/questions/questions.js +3 -1
  43. package/src/section-types.d.ts +18 -0
  44. package/src/section.js +40 -11
  45. package/src/section.test.js +89 -0
  46. package/test/journey.js +5 -0
  47. package/test/mock/journey.js +17 -0
  48. package/test/questions.js +30 -1
  49. package/test/questions.test.js +104 -40
  50. package/test/snapshots/check-your-answers-not-started.html +11 -0
  51. package/test/snapshots/check-your-answers-with-answers.html +11 -0
  52. package/test/snapshots/manage-list-answer-summary.html +14 -0
  53. package/test/snapshots/manage-list-render-questions.html +97 -0
  54. package/test/snapshots/manage-list-render.html +91 -0
  55. package/test/snapshots/travel-companions.html +19 -0
  56. package/test/snapshots/travel-companions_travel-companion-email.html +21 -0
  57. package/test/snapshots/travel-companions_travel-companion-name.html +21 -0
  58. 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 answer = payload ? payload[this.fieldName] : journey.response.answers[this.fieldName] || '';
76
-
77
- const viewModel = super.prepQuestionForRendering(section, journey, customViewData, payload);
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 = payload
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`, {
@@ -16,7 +16,8 @@ type QuestionTypes =
16
16
  | 'multi-field-input'
17
17
  | 'number'
18
18
  | 'site-address'
19
- | 'unit-option';
19
+ | 'unit-option'
20
+ | 'manage-list';
20
21
 
21
22
  interface CommonQuestionProps {
22
23
  type: QuestionTypes;
@@ -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 {Object} QuestionViewModel
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 answer = journey.response.answers[this.fieldName] || '';
163
- const backLink = journey.getBackLink(section.segment, this.fieldName);
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
- return {
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} sectionObj
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, sectionObj, journey) {
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.prepQuestionForRendering(
224
- sectionObj,
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 './question.js';
4
- import { mockRes } from '../../test/utils/utils.js';
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.prepQuestionForRendering = mock.fn(() => expectedResult);
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 {any} question
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 {Object} params
196
- * @param {string} params.questionFieldName
197
- * @param {import('#src/journey/journey-response.js').JourneyResponse} params.response
198
- * @param {boolean} [params.takeNextQuestion]
199
- * @param {boolean} [params.reverse]
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 = this.questions.length;
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 = this.questions[i];
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
  }
@@ -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
+ }