@planning-inspectorate/dynamic-forms 2.3.0 → 3.0.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 (43) hide show
  1. package/package.json +2 -1
  2. package/src/components/manage-list/README.md +97 -0
  3. package/src/components/manage-list/answer-summary-list.njk +10 -0
  4. package/src/components/manage-list/index.njk +61 -0
  5. package/src/components/manage-list/manage-list-section.js +39 -0
  6. package/src/components/manage-list/manage-list-section.test.js +52 -0
  7. package/src/components/manage-list/question.js +147 -0
  8. package/src/components/manage-list/question.test.js +139 -0
  9. package/src/components/utils/nunjucks.js +20 -0
  10. package/src/controller.js +60 -20
  11. package/src/controller.test.js +110 -43
  12. package/src/index.js +3 -2
  13. package/src/journey/journey-types.d.ts +18 -0
  14. package/src/journey/journey.js +104 -40
  15. package/src/journey/journey.test.js +94 -23
  16. package/src/lib/uuid.js +10 -0
  17. package/src/lib/uuid.test.js +24 -0
  18. package/src/middleware/dynamic-req-files-to-req-body-files.js +1 -2
  19. package/src/middleware/dynamic-req-files-to-req-body-files.test.js +2 -2
  20. package/src/questions/question-props.d.ts +2 -1
  21. package/src/questions/question.js +67 -18
  22. package/src/questions/question.test.js +26 -3
  23. package/src/questions/questions.js +3 -1
  24. package/src/section-types.d.ts +18 -0
  25. package/src/section.js +67 -2
  26. package/src/section.test.js +176 -1
  27. package/src/validator/validator.js +1 -3
  28. package/src/validator/validator.test.js +7 -7
  29. package/test/journey.js +5 -0
  30. package/test/mock/journey.js +17 -0
  31. package/test/questions.js +30 -1
  32. package/test/questions.test.js +119 -48
  33. package/test/snapshots/check-your-answers-not-started.html +11 -0
  34. package/test/snapshots/check-your-answers-with-answers.html +11 -0
  35. package/test/snapshots/manage-list-answer-summary.html +14 -0
  36. package/test/snapshots/manage-list-render-questions.html +97 -0
  37. package/test/snapshots/manage-list-render.html +91 -0
  38. package/test/snapshots/travel-companions.html +19 -0
  39. package/test/snapshots/travel-companions_travel-companion-email.html +21 -0
  40. package/test/snapshots/travel-companions_travel-companion-name.html +21 -0
  41. package/test/utils/test-server.js +31 -9
  42. package/test/utils/utils.js +2 -1
  43. package/tsconfig.json +23 -0
@@ -1,8 +1,9 @@
1
1
  import { describe, it, mock, beforeEach } from 'node:test';
2
2
  import assert from 'node:assert';
3
3
  import { Journey } from './journey.js';
4
+ import { Section } from '#src/section.js';
4
5
 
5
- const mockSections = [
6
+ const mockSectionDetails = [
6
7
  {
7
8
  segment: 'section1',
8
9
  questions: [
@@ -40,6 +41,11 @@ const mockSections = [
40
41
  ]
41
42
  }
42
43
  ];
44
+ const mockSections = mockSectionDetails.map((data, index) => {
45
+ const section = new Section(`section ${index}`, data.segment);
46
+ section.questions = data.questions;
47
+ return section;
48
+ });
43
49
 
44
50
  describe('Journey class', () => {
45
51
  let constructorArgs;
@@ -193,15 +199,15 @@ describe('Journey class', () => {
193
199
  });
194
200
  });
195
201
 
196
- describe('getQuestionBySectionAndName', () => {
202
+ describe('getQuestionByParams', () => {
197
203
  it('should return the correct question by section and name', () => {
198
204
  const journey = new Journey(constructorArgs);
199
205
  journey.sections = mockSections;
200
206
 
201
- const question = journey.getQuestionBySectionAndName(
202
- mockSections[0].segment,
203
- mockSections[0].questions[1].fieldName
204
- );
207
+ const question = journey.getQuestionByParams({
208
+ section: mockSections[0].segment,
209
+ question: mockSections[0].questions[1].fieldName
210
+ });
205
211
 
206
212
  assert.strictEqual(question, mockSections[0].questions[1]);
207
213
  });
@@ -210,7 +216,7 @@ describe('Journey class', () => {
210
216
  const journey = new Journey(constructorArgs);
211
217
  journey.sections = mockSections;
212
218
 
213
- const question = journey.getQuestionBySectionAndName('a', 'b');
219
+ const question = journey.getQuestionByParams({ section: 'a', question: 'b' });
214
220
 
215
221
  assert.strictEqual(question, undefined);
216
222
  });
@@ -219,7 +225,7 @@ describe('Journey class', () => {
219
225
  const journey = new Journey(constructorArgs);
220
226
  journey.sections = mockSections;
221
227
 
222
- const question = journey.getQuestionBySectionAndName(mockSections[0].segment, 'nope');
228
+ const question = journey.getQuestionByParams({ section: mockSections[0].segment, question: 'nope' });
223
229
 
224
230
  assert.strictEqual(question, undefined);
225
231
  });
@@ -235,7 +241,7 @@ describe('Journey class', () => {
235
241
  journey.sections = mockSections;
236
242
  journey.returnToListing = false;
237
243
 
238
- const backLinks = journey.getBackLink(section.segment, name);
244
+ const backLinks = journey.getBackLink({ params: { section: section.segment, question: name } });
239
245
 
240
246
  assert.strictEqual(backLinks, `${constructorArgs.makeBaseUrl()}/${section.segment}/${prevQuestionName}`);
241
247
  });
@@ -251,7 +257,7 @@ describe('Journey class', () => {
251
257
  journey.sections = mockSections;
252
258
  journey.returnToListing = false;
253
259
 
254
- const backLink = journey.getBackLink(section.segment, name, true);
260
+ const backLink = journey.getBackLink({ params: { section: section.segment, question: name } });
255
261
 
256
262
  assert.strictEqual(backLink, `${constructorArgs.makeBaseUrl()}/${prevSection.segment}/${prevQuestionName}`);
257
263
  });
@@ -265,7 +271,7 @@ describe('Journey class', () => {
265
271
  journey.sections = mockSections;
266
272
  journey.returnToListing = false;
267
273
 
268
- const backLink = journey.getBackLink(section.segment, name, true);
274
+ const backLink = journey.getBackLink({ params: { section: section.segment, question: name } });
269
275
 
270
276
  assert.strictEqual(backLink, null);
271
277
  });
@@ -278,7 +284,7 @@ describe('Journey class', () => {
278
284
  journey.sections = mockSections;
279
285
  journey.returnToListing = false;
280
286
 
281
- const backLink = journey.getBackLink(section.segment, name, true);
287
+ const backLink = journey.getBackLink({ params: { section: section.segment, question: name } });
282
288
 
283
289
  assert.strictEqual(backLink, '/some/back/link');
284
290
  });
@@ -294,7 +300,7 @@ describe('Journey class', () => {
294
300
  journey.sections = mockSections;
295
301
  journey.returnToListing = returnToListing;
296
302
 
297
- const nextQuestionUrl = journey.getNextQuestionUrl(section, name, false);
303
+ const nextQuestionUrl = journey.getNextQuestionUrl({ section: section, question: name }, { reverse: false });
298
304
 
299
305
  assert.strictEqual(nextQuestionUrl, null);
300
306
  });
@@ -309,7 +315,7 @@ describe('Journey class', () => {
309
315
  journey.sections = mockSections;
310
316
  journey.returnToListing = returnToListing;
311
317
 
312
- const nextQuestionUrl = journey.getNextQuestionUrl(section, name, false);
318
+ const nextQuestionUrl = journey.getNextQuestionUrl({ section: section, question: name }, { reverse: false });
313
319
 
314
320
  assert.strictEqual(nextQuestionUrl, null);
315
321
  });
@@ -325,7 +331,10 @@ describe('Journey class', () => {
325
331
  journey.sections = mockSections;
326
332
  journey.returnToListing = returnToListing;
327
333
 
328
- const nextQuestionUrl = journey.getNextQuestionUrl(section.segment, name, false);
334
+ const nextQuestionUrl = journey.getNextQuestionUrl(
335
+ { section: section.segment, question: name },
336
+ { reverse: false }
337
+ );
329
338
 
330
339
  assert.strictEqual(nextQuestionUrl, `${constructorArgs.makeBaseUrl()}/${section.segment}/${nextQuestionName}`);
331
340
  });
@@ -350,7 +359,10 @@ describe('Journey class', () => {
350
359
  journey.sections = mockSections;
351
360
  journey.returnToListing = returnToListing;
352
361
 
353
- const nextQuestionUrl = journey.getNextQuestionUrl(section.segment, name, false);
362
+ const nextQuestionUrl = journey.getNextQuestionUrl(
363
+ { section: section.segment, question: name },
364
+ { reverse: false }
365
+ );
354
366
 
355
367
  assert.strictEqual(nextQuestionUrl, `${constructorArgs.makeBaseUrl()}/${section.segment}/${nextQuestionName}`);
356
368
  });
@@ -366,7 +378,10 @@ describe('Journey class', () => {
366
378
  journey.sections = mockSections;
367
379
  journey.returnToListing = returnToListing;
368
380
 
369
- const nextQuestionUrl = journey.getNextQuestionUrl(section.segment, name, true);
381
+ const nextQuestionUrl = journey.getNextQuestionUrl(
382
+ { section: section.segment, question: name },
383
+ { reverse: true }
384
+ );
370
385
 
371
386
  assert.strictEqual(nextQuestionUrl, `${constructorArgs.makeBaseUrl()}/${section.segment}/${prevQuestionName}`);
372
387
  });
@@ -381,7 +396,10 @@ describe('Journey class', () => {
381
396
  journey.sections = mockSections;
382
397
  journey.returnToListing = returnToListing;
383
398
 
384
- const nextQuestionUrl = journey.getNextQuestionUrl(section.segment, name, false);
399
+ const nextQuestionUrl = journey.getNextQuestionUrl(
400
+ { section: section.segment, question: name },
401
+ { reverse: false }
402
+ );
385
403
 
386
404
  assert.strictEqual(nextQuestionUrl, null);
387
405
  });
@@ -398,7 +416,10 @@ describe('Journey class', () => {
398
416
  journey.sections = mockSections;
399
417
  journey.returnToListing = false;
400
418
 
401
- const nextQuestionUrl = journey.getNextQuestionUrl(section.segment, name, true);
419
+ const nextQuestionUrl = journey.getNextQuestionUrl(
420
+ { section: section.segment, question: name },
421
+ { reverse: true }
422
+ );
402
423
 
403
424
  assert.strictEqual(
404
425
  nextQuestionUrl,
@@ -416,7 +437,10 @@ describe('Journey class', () => {
416
437
  journey.sections = mockSections;
417
438
  journey.returnToListing = returnToListing;
418
439
 
419
- const nextQuestionUrl = journey.getNextQuestionUrl(section.segment, name, true);
440
+ const nextQuestionUrl = journey.getNextQuestionUrl(
441
+ { section: section.segment, question: name },
442
+ { reverse: true }
443
+ );
420
444
 
421
445
  assert.strictEqual(nextQuestionUrl, null);
422
446
  });
@@ -431,7 +455,10 @@ describe('Journey class', () => {
431
455
  journey.sections = mockSections;
432
456
  journey.returnToListing = true;
433
457
 
434
- const nextQuestionUrl = journey.getNextQuestionUrl(section.segment, name, false);
458
+ const nextQuestionUrl = journey.getNextQuestionUrl(
459
+ { section: section.segment, question: name },
460
+ { reverse: false }
461
+ );
435
462
  assert.strictEqual(nextQuestionUrl, null);
436
463
  });
437
464
  }
@@ -445,7 +472,10 @@ describe('Journey class', () => {
445
472
  journey.sections = mockSections;
446
473
  journey.returnToListing = true;
447
474
 
448
- const nextQuestionUrl = journey.getNextQuestionUrl(section.segment, name, true);
475
+ const nextQuestionUrl = journey.getNextQuestionUrl(
476
+ { section: section.segment, question: name },
477
+ { reverse: true }
478
+ );
449
479
 
450
480
  assert.strictEqual(nextQuestionUrl, null);
451
481
  });
@@ -460,10 +490,51 @@ describe('Journey class', () => {
460
490
  const journey = new Journey(constructorArgs);
461
491
  journey.sections = mockSections;
462
492
 
463
- const nextQuestionUrl = journey.getNextQuestionUrl(section.segment, name, false);
493
+ const nextQuestionUrl = journey.getNextQuestionUrl(
494
+ { section: section.segment, question: name },
495
+ { reverse: false }
496
+ );
464
497
 
465
498
  assert.strictEqual(nextQuestionUrl, `base/${section.segment}/${nextQuestionName}?id=1`);
466
499
  });
500
+
501
+ describe('manageListQuestions', () => {
502
+ it('should pass manageListQuestion to getNextQuestion', () => {
503
+ const getNextQuestion = mock.fn();
504
+ const journey = new Journey(constructorArgs);
505
+ journey.sections = [{ getNextQuestion }];
506
+ const manageListQuestion = { isManageListQuestion: true };
507
+ journey.getNextQuestionUrl({}, { manageListQuestion });
508
+ assert.strictEqual(getNextQuestion.mock.callCount(), 1);
509
+ assert.strictEqual(getNextQuestion.mock.calls[0].arguments[0].manageListQuestion, manageListQuestion);
510
+ });
511
+ it('should preserve params for manage list question', () => {
512
+ const question = {
513
+ url: 'question-3',
514
+ isInManageListSection: true
515
+ };
516
+ const manageListQuestion = {
517
+ url: 'manage-list-question',
518
+ isManageListQuestion: true
519
+ };
520
+ const params = {
521
+ section: 'section-1',
522
+ question: 'question-1,',
523
+ manageListAction: 'add',
524
+ manageListItemId: 'item-id-1',
525
+ manageListQuestion: 'question-2'
526
+ };
527
+ const getNextQuestion = mock.fn(() => question);
528
+ const section = {
529
+ segment: 'section-1',
530
+ getNextQuestion
531
+ };
532
+ const journey = new Journey(constructorArgs);
533
+ journey.sections = [section];
534
+ const url = journey.getNextQuestionUrl(params, { manageListQuestion });
535
+ assert.strictEqual(url, 'base/section-1/manage-list-question/add/item-id-1/question-3');
536
+ });
537
+ });
467
538
  });
468
539
 
469
540
  describe('getCurrentQuestionUrl', () => {
@@ -0,0 +1,10 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ /**
4
+ * Wrapper class so it can be overridden by test mocks
5
+ */
6
+ export class Uuid {
7
+ static randomUUID() {
8
+ return randomUUID();
9
+ }
10
+ }
@@ -0,0 +1,24 @@
1
+ import { Uuid } from '#src/lib/uuid.js';
2
+ import { describe, it } from 'node:test';
3
+ import assert from 'node:assert';
4
+
5
+ /**
6
+ *
7
+ * @param {import('node:test').TestContext} ctx
8
+ * @param {string} [uuid]
9
+ */
10
+ export function mockRandomUUID(ctx, uuid = '00000000-0000-0000-0000-000000000000') {
11
+ const original = Uuid.randomUUID;
12
+ Uuid.randomUUID = ctx.mock.fn(() => uuid);
13
+ // restore after the tests
14
+ ctx.after(() => (Uuid.randomUUID = original));
15
+ }
16
+
17
+ describe('uuid', () => {
18
+ it('should return a valid v4 UUID', () => {
19
+ assert.match(
20
+ Uuid.randomUUID(),
21
+ /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/
22
+ );
23
+ });
24
+ });
@@ -16,10 +16,9 @@ export default () => (req, res, next) => {
16
16
  return next();
17
17
  }
18
18
 
19
- const { section, question } = req.params;
20
19
  const { journey } = res.locals;
21
20
 
22
- const questionObj = journey.getQuestionBySectionAndName(section, question);
21
+ const questionObj = journey.getQuestionByParams(req.params);
23
22
  const filesPropertyPath = questionObj.fieldName;
24
23
 
25
24
  if (typeof req.files[filesPropertyPath] === 'undefined') {
@@ -8,7 +8,7 @@ describe('dynamic-forms/middleware/dynamic-req-files-to-req-body-files', () => {
8
8
  let mockRes;
9
9
  let filesPropertyPath = `example-files-property-path-${counter}`;
10
10
 
11
- const mockGetQuestionBySectionAndName = mock.fn(() => {
11
+ const mockgetQuestionByParams = mock.fn(() => {
12
12
  return { fieldName: filesPropertyPath };
13
13
  });
14
14
 
@@ -24,7 +24,7 @@ describe('dynamic-forms/middleware/dynamic-req-files-to-req-body-files', () => {
24
24
  cookie: mock.fn(),
25
25
  locals: {
26
26
  journeyResponse: mock.fn(),
27
- journey: { getQuestionBySectionAndName: mockGetQuestionBySectionAndName }
27
+ journey: { getQuestionByParams: mockgetQuestionByParams }
28
28
  },
29
29
  redirect: mock.fn(),
30
30
  render: mock.fn(),
@@ -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;
@@ -30,7 +30,6 @@ import MultiFieldInputValidator from '../validator/multi-field-input-validator.j
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,8 +150,57 @@ 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} section - the current section
190
+ * @param {Journey} journey - the journey we are in
191
+ * @param {Record<string, unknown>} [customViewData] additional data to send to view
192
+ * @param {unknown} [payload]
193
+ * @returns {QuestionViewModel}
194
+ */
195
+ toViewModel({ params, manageListQuestion, section, journey, customViewData, payload }) {
196
+ const viewModel = this.prepQuestionForRendering(section, journey, customViewData, payload);
197
+ viewModel.backLink = journey.getBackLink({ params, manageListQuestion });
198
+ return viewModel;
199
+ }
200
+
201
+ /**
202
+ * gets the base view model for this question
203
+ *
155
204
  * @param {Section} section - the current section
156
205
  * @param {Journey} journey - the journey we are in
157
206
  * @param {Record<string, unknown>} [customViewData] additional data to send to view
@@ -160,8 +209,6 @@ export class Question {
160
209
  */
161
210
  prepQuestionForRendering(section, journey, customViewData, payload) {
162
211
  const answer = journey.response.answers[this.fieldName] || '';
163
- const backLink = journey.getBackLink(section.segment, this.fieldName);
164
-
165
212
  return {
166
213
  question: {
167
214
  value: answer,
@@ -179,8 +226,6 @@ export class Question {
179
226
  layoutTemplate: journey.journeyTemplate,
180
227
  pageCaption: section?.name,
181
228
 
182
- navigation: ['', backLink],
183
- backLink,
184
229
  showBackToListLink: this.showBackToListLink,
185
230
  listLink: journey.taskListUrl,
186
231
  journeyTitle: journey.journeyTitle,
@@ -212,23 +257,26 @@ export class Question {
212
257
  * check for validation errors
213
258
  * @param {import('express').Request} req
214
259
  * @param {Journey} journey
215
- * @param {Section} sectionObj
260
+ * @param {Section} section
261
+ * @param {import('#src/components/manage-list/question.js')} [manageListQuestion]
216
262
  * @returns {QuestionViewModel|undefined} returns the view model for displaying the error or undefined if there are no errors
217
263
  */
218
- checkForValidationErrors(req, sectionObj, journey) {
264
+ checkForValidationErrors(req, section, journey, manageListQuestion) {
219
265
  const { body = {} } = req;
220
266
  const { errors = {}, errorSummary = [] } = body;
221
267
 
222
268
  if (Object.keys(errors).length > 0) {
223
- return this.prepQuestionForRendering(
224
- sectionObj,
269
+ return this.toViewModel({
270
+ params: req.params,
271
+ section,
225
272
  journey,
226
- {
273
+ customViewData: {
227
274
  errors,
228
275
  errorSummary
229
276
  },
230
- body
231
- );
277
+ payload: body,
278
+ manageListQuestion
279
+ });
232
280
  }
233
281
  }
234
282
 
@@ -279,19 +327,20 @@ export class Question {
279
327
  }
280
328
 
281
329
  /**
282
- * Handles redirect after saving
330
+ * Handles redirect after saving. Kept around for backwards compatibility.
331
+ *
283
332
  * @param {import('express').Response} res
284
333
  * @param {Journey} journey
285
334
  * @param {string} sectionSegment
286
335
  * @param {string} questionSegment
287
336
  * @returns {void}
337
+ * @deprecated - use `journey.redirectToNextQuestion`
288
338
  */
289
339
  handleNextQuestion(res, journey, sectionSegment, questionSegment) {
290
- let next = journey.getNextQuestionUrl(sectionSegment, questionSegment);
291
- if (next === null) {
292
- next = journey.taskListUrl;
293
- }
294
- return res.redirect(next);
340
+ return journey.redirectToNextQuestion(res, {
341
+ section: sectionSegment,
342
+ question: questionSegment
343
+ });
295
344
  }
296
345
 
297
346
  /**
@@ -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);
@@ -205,7 +228,7 @@ describe('./src/dynamic-forms/question.js', () => {
205
228
  const expectedResult = { a: 1 };
206
229
  const req = { body: { errors: { error: 'we have an error' } } };
207
230
  const question = getTestQuestion();
208
- question.prepQuestionForRendering = mock.fn(() => expectedResult);
231
+ question.toViewModel = mock.fn(() => expectedResult);
209
232
 
210
233
  const result = question.checkForValidationErrors(req);
211
234
 
@@ -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
@@ -6,6 +6,13 @@
6
6
 
7
7
  import RequiredValidator from './validator/required-validator.js';
8
8
 
9
+ /**
10
+ * A value indicating the final question of a section has been reached
11
+ *
12
+ * @type {Symbol}
13
+ */
14
+ export const END_OF_SECTION = Symbol('END_OF_SECTION');
15
+
9
16
  /**
10
17
  * Defines a section for a questionnaire, a set of Questions
11
18
  * @class
@@ -75,13 +82,20 @@ export class Section {
75
82
 
76
83
  /**
77
84
  * Fluent API method for adding questions
78
- * @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]
79
87
  * @returns {Section}
80
88
  */
81
- addQuestion(question) {
89
+ addQuestion(question, manageListSection) {
82
90
  if (!question) {
83
91
  throw new Error('question is required');
84
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
+ }
85
99
  this.questions.push(question);
86
100
  this.#conditionAdded = false; // reset condition flag
87
101
  this.#applyConditions(question);
@@ -183,6 +197,57 @@ export class Section {
183
197
  return this;
184
198
  }
185
199
 
200
+ /**
201
+ * Get the next question in this section given a questionParam (question fieldName)
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
229
+ * @returns {Question|Symbol|null}
230
+ */
231
+ static getNextQuestion({ questions, questionFieldName, response, takeNextQuestion = false, reverse = false }) {
232
+ const numberOfQuestions = questions.length;
233
+
234
+ const questionsStart = reverse ? numberOfQuestions - 1 : 0;
235
+ for (let i = questionsStart; reverse ? i >= 0 : i < numberOfQuestions; reverse ? i-- : i++) {
236
+ const question = questions[i];
237
+ if (takeNextQuestion && question.shouldDisplay(response)) {
238
+ return question;
239
+ }
240
+
241
+ if (question.fieldName === questionFieldName || question.url === questionFieldName) {
242
+ takeNextQuestion = true;
243
+ }
244
+ }
245
+ if (takeNextQuestion) {
246
+ return END_OF_SECTION;
247
+ }
248
+ return null;
249
+ }
250
+
186
251
  /**
187
252
  * checks answers on response to ensure that a answer is provided for each required question in the section
188
253
  * @param {JourneyResponse} journeyResponse