@planning-inspectorate/dynamic-forms 2.4.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 (34) hide show
  1. package/package.json +1 -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 +39 -10
  11. package/src/controller.test.js +90 -24
  12. package/src/index.js +3 -2
  13. package/src/journey/journey-types.d.ts +11 -1
  14. package/src/journey/journey.js +64 -18
  15. package/src/journey/journey.test.js +80 -15
  16. package/src/questions/question-props.d.ts +2 -1
  17. package/src/questions/question.js +60 -12
  18. package/src/questions/question.test.js +26 -3
  19. package/src/questions/questions.js +3 -1
  20. package/src/section-types.d.ts +18 -0
  21. package/src/section.js +40 -11
  22. package/src/section.test.js +89 -0
  23. package/test/journey.js +5 -0
  24. package/test/mock/journey.js +17 -0
  25. package/test/questions.js +30 -1
  26. package/test/questions.test.js +104 -40
  27. package/test/snapshots/check-your-answers-not-started.html +11 -0
  28. package/test/snapshots/check-your-answers-with-answers.html +11 -0
  29. package/test/snapshots/manage-list-answer-summary.html +14 -0
  30. package/test/snapshots/manage-list-render-questions.html +97 -0
  31. package/test/snapshots/manage-list-render.html +91 -0
  32. package/test/snapshots/travel-companions.html +19 -0
  33. package/test/snapshots/travel-companions_travel-companion-email.html +21 -0
  34. package/test/snapshots/travel-companions_travel-companion-name.html +21 -0
@@ -178,6 +178,7 @@ const sampleQuestionObj = {
178
178
  getDataToSave: mock.fn(),
179
179
  checkForValidationErrors: mock.fn(),
180
180
  checkForSavingErrors: mock.fn(),
181
+ toViewModel: mock.fn(),
181
182
  prepQuestionForRendering: mock.fn(),
182
183
  formatAnswerForSummary: mock.fn(() => [mockAnswer]),
183
184
  viewFolder: 'sampleType'
@@ -325,13 +326,13 @@ describe('dynamic-form/controller', () => {
325
326
 
326
327
  it('should render the question template', async () => {
327
328
  sampleQuestionObj.renderAction.mock.resetCalls();
328
- sampleQuestionObj.prepQuestionForRendering.mock.resetCalls();
329
+ sampleQuestionObj.toViewModel.mock.resetCalls();
329
330
  req.params.referenceId = mockRef;
330
331
  const mockAnswer = 'sampleAnswer';
331
332
  const mockBackLink = 'back';
332
333
  const mockQuestionRendering = 'test';
333
334
 
334
- sampleQuestionObj.prepQuestionForRendering.mock.mockImplementationOnce(() => mockQuestionRendering);
335
+ sampleQuestionObj.toViewModel.mock.mockImplementationOnce(() => mockQuestionRendering);
335
336
 
336
337
  mockJourney.getQuestionByParams = mock.fn();
337
338
  mockJourney.getQuestionByParams.mock.mockImplementationOnce(() => sampleQuestionObj);
@@ -343,13 +344,11 @@ describe('dynamic-form/controller', () => {
343
344
 
344
345
  assert.strictEqual(sampleQuestionObj.renderAction.mock.callCount(), 1);
345
346
  assert.deepStrictEqual(sampleQuestionObj.renderAction.mock.calls[0].arguments, [res, mockQuestionRendering]);
346
- assert.strictEqual(sampleQuestionObj.prepQuestionForRendering.mock.callCount(), 1);
347
- assert.ok(sampleQuestionObj.prepQuestionForRendering.mock.calls[0].arguments[2]);
348
- assert.ok(sampleQuestionObj.prepQuestionForRendering.mock.calls[0].arguments[2].originalUrl);
349
- assert.strictEqual(
350
- sampleQuestionObj.prepQuestionForRendering.mock.calls[0].arguments[2].originalUrl,
351
- req.originalUrl
352
- );
347
+ assert.strictEqual(sampleQuestionObj.toViewModel.mock.callCount(), 1);
348
+ const args = sampleQuestionObj.toViewModel.mock.calls[0].arguments[0];
349
+ assert.ok(args);
350
+ assert.ok(args?.customViewData?.originalUrl);
351
+ assert.strictEqual(args?.customViewData?.originalUrl, req.originalUrl);
353
352
  });
354
353
  });
355
354
 
@@ -394,7 +393,9 @@ describe('dynamic-form/controller', () => {
394
393
  const mocks = [sampleQuestionObj.checkForValidationErrors.mock, sampleQuestionObj.checkForSavingErrors.mock];
395
394
  for (const mockFn of mocks) {
396
395
  assert.strictEqual(mockFn.callCount(), 1);
397
- assert.deepStrictEqual(mockFn.calls[0].arguments, [req, sections[0], mockJourney]);
396
+ assert.deepStrictEqual(mockFn.calls[0].arguments[0], req);
397
+ assert.deepStrictEqual(mockFn.calls[0].arguments[1], sections[0]);
398
+ assert.deepStrictEqual(mockFn.calls[0].arguments[2], mockJourney);
398
399
  }
399
400
  });
400
401
 
@@ -404,7 +405,7 @@ describe('dynamic-form/controller', () => {
404
405
  const sampleQuestionObjWithActions = {
405
406
  ...sampleQuestionObj,
406
407
  saveAction: mock.fn(),
407
- prepQuestionForRendering: mock.fn(() => expectedViewModel),
408
+ toViewModel: mock.fn(() => expectedViewModel),
408
409
  renderAction: mock.fn()
409
410
  };
410
411
 
@@ -434,20 +435,25 @@ describe('dynamic-form/controller', () => {
434
435
  await buildSave(saveData)(req, res, journeyId);
435
436
 
436
437
  assert.strictEqual(saveData.mock.callCount(), 1);
437
- assert.deepStrictEqual(saveData.mock.calls[0].arguments, [
438
- {
439
- req,
440
- res,
441
- journeyId: journeyParams.journeyId,
442
- referenceId: journeyParams.referenceId,
443
- data: undefined
444
- }
445
- ]);
438
+ assert.deepStrictEqual(
439
+ saveData.mock.calls[0].arguments,
440
+ [
441
+ {
442
+ req,
443
+ res,
444
+ journeyId: journeyParams.journeyId,
445
+ referenceId: journeyParams.referenceId,
446
+ data: undefined
447
+ }
448
+ ],
449
+ 'save data args'
450
+ );
446
451
 
447
- assert.deepStrictEqual(sampleQuestionObjWithActions.renderAction.mock.calls[0].arguments, [
448
- res,
449
- expectedViewModel
450
- ]);
452
+ assert.deepStrictEqual(
453
+ sampleQuestionObjWithActions.renderAction.mock.calls[0].arguments,
454
+ [res, expectedViewModel],
455
+ 'render args'
456
+ );
451
457
  });
452
458
 
453
459
  it('should handle validation errors', async () => {
@@ -546,6 +552,66 @@ describe('dynamic-form/controller', () => {
546
552
  assert.match(res.redirect.mock.calls[0].arguments[0], /\/task-list$/);
547
553
  assert.strictEqual(mockJourney.getNextQuestionUrl.mock.callCount(), 0);
548
554
  });
555
+
556
+ describe('manageListQuestions', () => {
557
+ const setupJourney = () => {
558
+ const q = {
559
+ fieldName: 'question-2',
560
+ get isInManageListSection() {
561
+ return true;
562
+ },
563
+ checkForValidationErrors: mock.fn(),
564
+ getDataToSave: mock.fn(),
565
+ checkForSavingErrors: mock.fn()
566
+ };
567
+ const manageListQ = {
568
+ get isManageListQuestion() {
569
+ return true;
570
+ }
571
+ };
572
+ const req = {
573
+ params: {
574
+ section: 'section-1',
575
+ question: 'question-1',
576
+ manageListAction: 'add',
577
+ manageListItemId: 'item-id-1',
578
+ manageListQuestion: 'question-6'
579
+ }
580
+ };
581
+ const journey = {
582
+ getSection: mock.fn(() => ({ segment: 'section-1' })),
583
+ getQuestionByParams: mock.fn(),
584
+ redirectToNextQuestion: mock.fn()
585
+ };
586
+ const res = {
587
+ locals: {
588
+ journey,
589
+ journeyResponse: { answers: {} }
590
+ },
591
+ redirect: mock.fn()
592
+ };
593
+ journey.getQuestionByParams.mock.mockImplementationOnce(() => q, 0);
594
+ journey.getQuestionByParams.mock.mockImplementationOnce(() => manageListQ, 1);
595
+ return { journey, manageListQ, req, res, q };
596
+ };
597
+ it('should find the parent manage list question', async () => {
598
+ const { journey, manageListQ, req, res } = setupJourney();
599
+ const saveData = mock.fn();
600
+ await buildSave(saveData)(req, res, 'journey-1');
601
+ assert.strictEqual(journey.getSection.mock.callCount(), 1);
602
+ assert.strictEqual(journey.getQuestionByParams.mock.callCount(), 2);
603
+ assert.strictEqual(journey.redirectToNextQuestion.mock.callCount(), 1);
604
+ assert.strictEqual(journey.redirectToNextQuestion.mock.calls[0].arguments[2], manageListQ);
605
+ });
606
+ it('should preserve request route params', async () => {
607
+ const { journey, req, res } = setupJourney();
608
+ const saveData = mock.fn();
609
+ await buildSave(saveData)(req, res, 'journey-1');
610
+ assert.strictEqual(journey.redirectToNextQuestion.mock.callCount(), 1);
611
+ const params = journey.redirectToNextQuestion.mock.calls[0].arguments[1];
612
+ assert.deepStrictEqual(params, req.params);
613
+ });
614
+ });
549
615
  });
550
616
  });
551
617
 
package/src/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  /**
6
- * @type {Readonly<{CHECKBOX: QuestionTypes, BOOLEAN: QuestionTypes, RADIO: QuestionTypes, DATE: QuestionTypes, DATE_PERIOD: QuestionTypes, TEXT_ENTRY: QuestionTypes, TEXT_ENTRY_REDACT: QuestionTypes, SELECT: QuestionTypes, SINGLE_LINE_INPUT: QuestionTypes, MULTI_FIELD_INPUT: QuestionTypes, NUMBER: QuestionTypes, ADDRESS: QuestionTypes, UNIT_OPTION: QuestionTypes, EMAIL: QuestionTypes}>}
6
+ * @type {Readonly<{CHECKBOX: QuestionTypes, BOOLEAN: QuestionTypes, RADIO: QuestionTypes, DATE: QuestionTypes, DATE_PERIOD: QuestionTypes, TEXT_ENTRY: QuestionTypes, TEXT_ENTRY_REDACT: QuestionTypes, SELECT: QuestionTypes, SINGLE_LINE_INPUT: QuestionTypes, MULTI_FIELD_INPUT: QuestionTypes, NUMBER: QuestionTypes, ADDRESS: QuestionTypes, UNIT_OPTION: QuestionTypes, EMAIL: QuestionTypes, MANAGE_LIST: QuestionTypes}>}
7
7
  */
8
8
  export const COMPONENT_TYPES = Object.freeze({
9
9
  CHECKBOX: 'checkbox',
@@ -20,5 +20,6 @@ export const COMPONENT_TYPES = Object.freeze({
20
20
  NUMBER: 'number',
21
21
  ADDRESS: 'site-address',
22
22
  UNIT_OPTION: 'unit-option',
23
- EMAIL: 'email'
23
+ EMAIL: 'email',
24
+ MANAGE_LIST: 'manage-list'
24
25
  });
@@ -1,8 +1,18 @@
1
1
  /**
2
2
  * Express route parameters used for journey routes
3
3
  */
4
- export type RouteParams = {
4
+ export type RouteParams = CoreRouteParams & ManageListRouteParams;
5
+
6
+ export type CoreRouteParams = {
5
7
  section: string;
6
8
  question: string;
7
9
  [key: string]: string;
8
10
  };
11
+
12
+ export type ManageListRouteParams =
13
+ | {
14
+ manageListAction: string;
15
+ manageListItemId: string;
16
+ manageListQuestion: string;
17
+ }
18
+ | {};
@@ -140,7 +140,11 @@ export class Journey {
140
140
  * @returns {string} url for a question
141
141
  */
142
142
  #buildQuestionUrl(params) {
143
- return this.#prependPathToUrl(this.baseUrl, `${params.section}/${params.question}`);
143
+ const parts = [params.section, params.question];
144
+ if (params.manageListAction) {
145
+ parts.push(params.manageListAction, params.manageListItemId, params.manageListQuestion);
146
+ }
147
+ return this.#prependPathToUrl(this.baseUrl, parts.join('/'));
144
148
  }
145
149
 
146
150
  /**
@@ -158,12 +162,21 @@ export class Journey {
158
162
  * Get question within a section
159
163
  * @param {Section} section
160
164
  * @param {string} questionSegment
165
+ * @param {{action: string, itemId: string, question: string}} [manageListParams]
161
166
  * @returns {Question | undefined} question if it belongs in the given section
162
167
  */
163
- #getQuestion(section, questionSegment) {
164
- return section?.questions.find((q) => {
165
- return q.fieldName === questionSegment || q.url === questionSegment;
166
- });
168
+ #getQuestion(section, questionSegment, manageListParams) {
169
+ const matchQuestion = (q, toMatch) => {
170
+ return q.fieldName === toMatch || q.url === toMatch;
171
+ };
172
+ const question = section?.questions.find((q) => matchQuestion(q, questionSegment));
173
+ if (!question) {
174
+ return undefined;
175
+ }
176
+ if (manageListParams && question.isManageListQuestion) {
177
+ return question.section.questions.find((q) => matchQuestion(q, manageListParams.question));
178
+ }
179
+ return question;
167
180
  }
168
181
 
169
182
  /**
@@ -177,19 +190,30 @@ export class Journey {
177
190
  if (!section) {
178
191
  return undefined;
179
192
  }
180
-
181
- return this.#getQuestion(section, params.question);
193
+ let manageListParams;
194
+ if (params.manageListAction && params.manageListItemId && params.manageListQuestion) {
195
+ manageListParams = {
196
+ action: params.manageListAction,
197
+ itemId: params.manageListItemId,
198
+ question: params.manageListQuestion
199
+ };
200
+ }
201
+ return this.#getQuestion(section, params.question, manageListParams);
182
202
  }
183
203
 
184
204
  /**
185
205
  * Get the back link for the journey - e.g. the previous question
186
206
  *
187
- * @param {string} sectionSegment - section segment
188
- * @param {string} questionSegment - question segment
207
+ * @param {Object} options
208
+ * @param {import('./journey-types.d.ts').RouteParams} options.params
209
+ * @param {import('#src/components/manage-list/question.js')} [options.manageListQuestion]
189
210
  * @returns {string|null} url for the next question, or null if unmatched
190
211
  */
191
- getBackLink(sectionSegment, questionSegment) {
192
- const previousQuestion = this.getNextQuestionUrl({ section: sectionSegment, question: questionSegment }, true);
212
+ getBackLink({ params, manageListQuestion }) {
213
+ const previousQuestion = this.getNextQuestionUrl(params, {
214
+ manageListQuestion,
215
+ reverse: true
216
+ });
193
217
  if (!previousQuestion) {
194
218
  return this.initialBackLink;
195
219
  }
@@ -202,10 +226,11 @@ export class Journey {
202
226
  *
203
227
  * @param {import('express').Response} res
204
228
  * @param {import('./journey-types.d.ts').RouteParams} params
229
+ * @param {import('#src/components/manage-list/question.js')} [manageListQuestion]
205
230
  * @returns {void}
206
231
  */
207
- redirectToNextQuestion(res, params) {
208
- const next = this.getNextQuestionUrl(params) ?? this.taskListUrl;
232
+ redirectToNextQuestion(res, params, manageListQuestion) {
233
+ const next = this.getNextQuestionUrl(params, { manageListQuestion }) ?? this.taskListUrl;
209
234
  return res.redirect(next);
210
235
  }
211
236
 
@@ -213,12 +238,15 @@ export class Journey {
213
238
  * Get url for the next question in the journey
214
239
  *
215
240
  * @param {import('./journey-types.d.ts').RouteParams} params
216
- * @param {boolean} [reverse] - if passed in this will get the previous question
241
+ * @param {Object} options
242
+ * @param {boolean} [options.reverse] - if passed in this will get the previous question
243
+ * @param {import('#src/components/manage-list/question.js')} [options.manageListQuestion]
217
244
  * @returns {string|null} url for the next question, or null if unmatched
218
245
  */
219
- getNextQuestionUrl(params, reverse = false) {
246
+ getNextQuestionUrl(params, { reverse = false, manageListQuestion } = {}) {
220
247
  const numberOfSections = this.sections.length;
221
248
  const sectionsStart = reverse ? numberOfSections - 1 : 0;
249
+ const questionFieldName = manageListQuestion ? params.manageListQuestion : params.question;
222
250
 
223
251
  let currentSectionIndex;
224
252
  let foundSection = false;
@@ -237,19 +265,37 @@ export class Journey {
237
265
  return null;
238
266
  }
239
267
  const question = currentSection.getNextQuestion({
240
- questionFieldName: params.question,
268
+ questionFieldName,
241
269
  response: this.response,
270
+ manageListQuestion,
242
271
  takeNextQuestion,
243
272
  reverse
244
273
  });
245
274
  if (question === END_OF_SECTION) {
246
275
  takeNextQuestion = true; // get the first question from the following section
247
276
  } else if (question) {
248
- const newParams = {
249
- ...params,
277
+ /**
278
+ * if this is a regular question, then only section and question params are required
279
+ * don't include other params which may be set (e.g. manageList* params), as we may now be
280
+ * redirecting to a non-manage list question, having previously been on a manage list question
281
+ *
282
+ * @type {import('./journey-types.d.ts').RouteParams}
283
+ */
284
+ let newParams = {
250
285
  section: currentSection.segment,
251
286
  question: question.url || question.fieldName
252
287
  };
288
+ if (question.isInManageListSection && manageListQuestion) {
289
+ // if this is in a manage list section, then the manage list params will be set
290
+ // we need to retain the manageListAction and manageListItemId params
291
+ newParams = {
292
+ ...params,
293
+ // the question param is for the 'parent' manageListQuestion
294
+ question: manageListQuestion.url,
295
+ // the manageListQuestion param is for the next question
296
+ manageListQuestion: question.url || question.fieldName
297
+ };
298
+ }
253
299
  return this.#buildQuestionUrl(newParams);
254
300
  }
255
301
  }
@@ -241,7 +241,7 @@ describe('Journey class', () => {
241
241
  journey.sections = mockSections;
242
242
  journey.returnToListing = false;
243
243
 
244
- const backLinks = journey.getBackLink(section.segment, name);
244
+ const backLinks = journey.getBackLink({ params: { section: section.segment, question: name } });
245
245
 
246
246
  assert.strictEqual(backLinks, `${constructorArgs.makeBaseUrl()}/${section.segment}/${prevQuestionName}`);
247
247
  });
@@ -257,7 +257,7 @@ describe('Journey class', () => {
257
257
  journey.sections = mockSections;
258
258
  journey.returnToListing = false;
259
259
 
260
- const backLink = journey.getBackLink(section.segment, name, true);
260
+ const backLink = journey.getBackLink({ params: { section: section.segment, question: name } });
261
261
 
262
262
  assert.strictEqual(backLink, `${constructorArgs.makeBaseUrl()}/${prevSection.segment}/${prevQuestionName}`);
263
263
  });
@@ -271,7 +271,7 @@ describe('Journey class', () => {
271
271
  journey.sections = mockSections;
272
272
  journey.returnToListing = false;
273
273
 
274
- const backLink = journey.getBackLink(section.segment, name, true);
274
+ const backLink = journey.getBackLink({ params: { section: section.segment, question: name } });
275
275
 
276
276
  assert.strictEqual(backLink, null);
277
277
  });
@@ -284,7 +284,7 @@ describe('Journey class', () => {
284
284
  journey.sections = mockSections;
285
285
  journey.returnToListing = false;
286
286
 
287
- const backLink = journey.getBackLink(section.segment, name, true);
287
+ const backLink = journey.getBackLink({ params: { section: section.segment, question: name } });
288
288
 
289
289
  assert.strictEqual(backLink, '/some/back/link');
290
290
  });
@@ -300,7 +300,7 @@ describe('Journey class', () => {
300
300
  journey.sections = mockSections;
301
301
  journey.returnToListing = returnToListing;
302
302
 
303
- const nextQuestionUrl = journey.getNextQuestionUrl({ section: section, question: name }, false);
303
+ const nextQuestionUrl = journey.getNextQuestionUrl({ section: section, question: name }, { reverse: false });
304
304
 
305
305
  assert.strictEqual(nextQuestionUrl, null);
306
306
  });
@@ -315,7 +315,7 @@ describe('Journey class', () => {
315
315
  journey.sections = mockSections;
316
316
  journey.returnToListing = returnToListing;
317
317
 
318
- const nextQuestionUrl = journey.getNextQuestionUrl({ section: section, question: name }, false);
318
+ const nextQuestionUrl = journey.getNextQuestionUrl({ section: section, question: name }, { reverse: false });
319
319
 
320
320
  assert.strictEqual(nextQuestionUrl, null);
321
321
  });
@@ -331,7 +331,10 @@ describe('Journey class', () => {
331
331
  journey.sections = mockSections;
332
332
  journey.returnToListing = returnToListing;
333
333
 
334
- const nextQuestionUrl = journey.getNextQuestionUrl({ section: section.segment, question: name }, false);
334
+ const nextQuestionUrl = journey.getNextQuestionUrl(
335
+ { section: section.segment, question: name },
336
+ { reverse: false }
337
+ );
335
338
 
336
339
  assert.strictEqual(nextQuestionUrl, `${constructorArgs.makeBaseUrl()}/${section.segment}/${nextQuestionName}`);
337
340
  });
@@ -356,7 +359,10 @@ describe('Journey class', () => {
356
359
  journey.sections = mockSections;
357
360
  journey.returnToListing = returnToListing;
358
361
 
359
- const nextQuestionUrl = journey.getNextQuestionUrl({ section: section.segment, question: name }, false);
362
+ const nextQuestionUrl = journey.getNextQuestionUrl(
363
+ { section: section.segment, question: name },
364
+ { reverse: false }
365
+ );
360
366
 
361
367
  assert.strictEqual(nextQuestionUrl, `${constructorArgs.makeBaseUrl()}/${section.segment}/${nextQuestionName}`);
362
368
  });
@@ -372,7 +378,10 @@ describe('Journey class', () => {
372
378
  journey.sections = mockSections;
373
379
  journey.returnToListing = returnToListing;
374
380
 
375
- const nextQuestionUrl = journey.getNextQuestionUrl({ section: section.segment, question: name }, true);
381
+ const nextQuestionUrl = journey.getNextQuestionUrl(
382
+ { section: section.segment, question: name },
383
+ { reverse: true }
384
+ );
376
385
 
377
386
  assert.strictEqual(nextQuestionUrl, `${constructorArgs.makeBaseUrl()}/${section.segment}/${prevQuestionName}`);
378
387
  });
@@ -387,7 +396,10 @@ describe('Journey class', () => {
387
396
  journey.sections = mockSections;
388
397
  journey.returnToListing = returnToListing;
389
398
 
390
- const nextQuestionUrl = journey.getNextQuestionUrl({ section: section.segment, question: name }, false);
399
+ const nextQuestionUrl = journey.getNextQuestionUrl(
400
+ { section: section.segment, question: name },
401
+ { reverse: false }
402
+ );
391
403
 
392
404
  assert.strictEqual(nextQuestionUrl, null);
393
405
  });
@@ -404,7 +416,10 @@ describe('Journey class', () => {
404
416
  journey.sections = mockSections;
405
417
  journey.returnToListing = false;
406
418
 
407
- const nextQuestionUrl = journey.getNextQuestionUrl({ section: section.segment, question: name }, true);
419
+ const nextQuestionUrl = journey.getNextQuestionUrl(
420
+ { section: section.segment, question: name },
421
+ { reverse: true }
422
+ );
408
423
 
409
424
  assert.strictEqual(
410
425
  nextQuestionUrl,
@@ -422,7 +437,10 @@ describe('Journey class', () => {
422
437
  journey.sections = mockSections;
423
438
  journey.returnToListing = returnToListing;
424
439
 
425
- const nextQuestionUrl = journey.getNextQuestionUrl({ section: section.segment, question: name }, true);
440
+ const nextQuestionUrl = journey.getNextQuestionUrl(
441
+ { section: section.segment, question: name },
442
+ { reverse: true }
443
+ );
426
444
 
427
445
  assert.strictEqual(nextQuestionUrl, null);
428
446
  });
@@ -437,7 +455,10 @@ describe('Journey class', () => {
437
455
  journey.sections = mockSections;
438
456
  journey.returnToListing = true;
439
457
 
440
- const nextQuestionUrl = journey.getNextQuestionUrl({ section: section.segment, question: name }, false);
458
+ const nextQuestionUrl = journey.getNextQuestionUrl(
459
+ { section: section.segment, question: name },
460
+ { reverse: false }
461
+ );
441
462
  assert.strictEqual(nextQuestionUrl, null);
442
463
  });
443
464
  }
@@ -451,7 +472,10 @@ describe('Journey class', () => {
451
472
  journey.sections = mockSections;
452
473
  journey.returnToListing = true;
453
474
 
454
- const nextQuestionUrl = journey.getNextQuestionUrl({ section: section.segment, question: name }, true);
475
+ const nextQuestionUrl = journey.getNextQuestionUrl(
476
+ { section: section.segment, question: name },
477
+ { reverse: true }
478
+ );
455
479
 
456
480
  assert.strictEqual(nextQuestionUrl, null);
457
481
  });
@@ -466,10 +490,51 @@ describe('Journey class', () => {
466
490
  const journey = new Journey(constructorArgs);
467
491
  journey.sections = mockSections;
468
492
 
469
- const nextQuestionUrl = journey.getNextQuestionUrl({ section: section.segment, question: name }, false);
493
+ const nextQuestionUrl = journey.getNextQuestionUrl(
494
+ { section: section.segment, question: name },
495
+ { reverse: false }
496
+ );
470
497
 
471
498
  assert.strictEqual(nextQuestionUrl, `base/${section.segment}/${nextQuestionName}?id=1`);
472
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
+ });
473
538
  });
474
539
 
475
540
  describe('getCurrentQuestionUrl', () => {
@@ -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