@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
package/src/controller.js CHANGED
@@ -66,6 +66,14 @@ function buildSectionRowViewModel(key, value, action) {
66
66
  };
67
67
  }
68
68
 
69
+ /**
70
+ * @param {object} [viewData]
71
+ * @returns {import('express').Handler}
72
+ */
73
+ export function buildList(viewData = {}) {
74
+ return (req, res) => list(req, res, viewData.pageCaption, viewData);
75
+ }
76
+
69
77
  /**
70
78
  * @param {import('express').Request} req
71
79
  * @param {import('express').Response} res
@@ -135,22 +143,39 @@ export async function list(req, res, pageCaption, viewData) {
135
143
  }
136
144
 
137
145
  /**
146
+ * Render an individual question
147
+ *
138
148
  * @type {import('express').Handler}
139
149
  */
140
150
  export async function question(req, res) {
141
- //render an individual question
142
- const { section, question } = req.params;
143
151
  const { journey } = res.locals;
144
152
 
145
- const sectionObj = journey.getSection(section);
146
- const questionObj = journey.getQuestionBySectionAndName(section, question);
153
+ const section = journey.getSection(req.params.section);
154
+ const question = journey.getQuestionByParams(req.params);
147
155
 
148
- if (!questionObj || !sectionObj) {
156
+ if (!question || !section) {
149
157
  return res.redirect(journey.taskListUrl);
150
158
  }
151
159
 
152
- const viewModel = questionObj.prepQuestionForRendering(sectionObj, journey);
153
- return questionObj.renderAction(res, viewModel);
160
+ let manageListQuestion;
161
+ if (question.isInManageListSection) {
162
+ // find parent question for the manage list
163
+ manageListQuestion = journey.getQuestionByParams({ section: req.params.section, question: req.params.question });
164
+ if (!manageListQuestion) {
165
+ return res.redirect(journey.taskListUrl);
166
+ }
167
+ }
168
+
169
+ const viewModel = question.toViewModel({
170
+ params: req.params,
171
+ manageListQuestion,
172
+ section,
173
+ journey,
174
+ customViewData: {
175
+ originalUrl: req.originalUrl
176
+ }
177
+ });
178
+ return question.renderAction(res, viewModel);
154
179
  }
155
180
 
156
181
  /**
@@ -173,28 +198,36 @@ export async function question(req, res) {
173
198
  */
174
199
  export function buildSave(saveData, redirectToTaskListOnSuccess) {
175
200
  return async (req, res) => {
176
- const { section, question } = req.params;
177
201
  /** @type {import('./journey/journey.js').Journey} */
178
202
  const journey = res.locals.journey;
179
203
  /** @type {import('./journey/journey-response.js').JourneyResponse} */
180
204
  const journeyResponse = res.locals.journeyResponse;
181
205
 
182
- const sectionObj = journey.getSection(section);
183
- const questionObj = journey.getQuestionBySectionAndName(section, question);
206
+ const section = journey.getSection(req.params.section);
207
+ const question = journey.getQuestionByParams(req.params);
184
208
 
185
- if (!questionObj || !sectionObj) {
209
+ if (!question || !section) {
186
210
  return res.redirect(journey.taskListUrl);
187
211
  }
188
212
 
213
+ let manageListQuestion;
214
+ if (question.isInManageListSection) {
215
+ // find parent question for the manage list
216
+ manageListQuestion = journey.getQuestionByParams({ section: req.params.section, question: req.params.question });
217
+ if (!manageListQuestion) {
218
+ return res.redirect(journey.taskListUrl);
219
+ }
220
+ }
221
+
189
222
  try {
190
223
  // check for validation errors
191
- const errorViewModel = questionObj.checkForValidationErrors(req, sectionObj, journey);
224
+ const errorViewModel = question.checkForValidationErrors(req, section, journey, manageListQuestion);
192
225
  if (errorViewModel) {
193
- return questionObj.renderAction(res, errorViewModel);
226
+ return question.renderAction(res, errorViewModel);
194
227
  }
195
228
 
196
229
  // save
197
- const data = await questionObj.getDataToSave(req, journeyResponse);
230
+ const data = await question.getDataToSave(req, journeyResponse);
198
231
 
199
232
  await saveData({
200
233
  req,
@@ -205,20 +238,27 @@ export function buildSave(saveData, redirectToTaskListOnSuccess) {
205
238
  });
206
239
 
207
240
  // check for saving errors
208
- const saveViewModel = questionObj.checkForSavingErrors(req, sectionObj, journey);
241
+ const saveViewModel = question.checkForSavingErrors(req, section, journey);
209
242
  if (saveViewModel) {
210
- return questionObj.renderAction(res, saveViewModel);
243
+ return question.renderAction(res, saveViewModel);
211
244
  }
212
245
  if (redirectToTaskListOnSuccess) {
213
246
  return res.redirect(journey.taskListUrl);
214
247
  }
215
248
  // move to the next question
216
- return questionObj.handleNextQuestion(res, journey, sectionObj.segment, questionObj.fieldName);
249
+ return journey.redirectToNextQuestion(res, req.params, manageListQuestion);
217
250
  } catch (err) {
218
- const viewModel = questionObj.prepQuestionForRendering(sectionObj, journey, {
219
- errorSummary: err.errorSummary ?? [{ text: err.toString(), href: '#' }]
251
+ const viewModel = question.toViewModel({
252
+ params: req.params,
253
+ manageListQuestion,
254
+ section,
255
+ journey,
256
+ customViewData: {
257
+ originalUrl: req.originalUrl,
258
+ errorSummary: err.errorSummary ?? [{ text: err.toString(), href: '#' }]
259
+ }
220
260
  });
221
- return questionObj.renderAction(res, viewModel);
261
+ return question.renderAction(res, viewModel);
222
262
  }
223
263
  };
224
264
  }
@@ -178,10 +178,10 @@ 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
- viewFolder: 'sampleType',
184
- handleNextQuestion: mock.fn()
184
+ viewFolder: 'sampleType'
185
185
  };
186
186
 
187
187
  const mockSection = {
@@ -301,8 +301,8 @@ describe('dynamic-form/controller', () => {
301
301
 
302
302
  describe('question', () => {
303
303
  it('should redirect if question is not found', async () => {
304
- mockJourney.getQuestionBySectionAndName = mock.fn();
305
- mockJourney.getQuestionBySectionAndName.mock.mockImplementationOnce(() => null);
304
+ mockJourney.getQuestionByParams = mock.fn();
305
+ mockJourney.getQuestionByParams.mock.mockImplementationOnce(() => null);
306
306
 
307
307
  await question(req, res);
308
308
 
@@ -314,8 +314,8 @@ describe('dynamic-form/controller', () => {
314
314
  mockJourney.getSection.mock.mockImplementationOnce(() => {
315
315
  return {};
316
316
  });
317
- mockJourney.getQuestionBySectionAndName = mock.fn(() => sampleQuestionObj);
318
- mockJourney.getQuestionBySectionAndName.mock.mockImplementationOnce(() => sampleQuestionObj);
317
+ mockJourney.getQuestionByParams = mock.fn(() => sampleQuestionObj);
318
+ mockJourney.getQuestionByParams.mock.mockImplementationOnce(() => sampleQuestionObj);
319
319
  sampleQuestionObj.renderAction = mock.fn();
320
320
 
321
321
  await question(req, res);
@@ -326,15 +326,16 @@ describe('dynamic-form/controller', () => {
326
326
 
327
327
  it('should render the question template', async () => {
328
328
  sampleQuestionObj.renderAction.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
- mockJourney.getQuestionBySectionAndName = mock.fn();
337
- mockJourney.getQuestionBySectionAndName.mock.mockImplementationOnce(() => sampleQuestionObj);
337
+ mockJourney.getQuestionByParams = mock.fn();
338
+ mockJourney.getQuestionByParams.mock.mockImplementationOnce(() => sampleQuestionObj);
338
339
  mockJourney.response.answers.sampleFieldName = mockAnswer;
339
340
  mockJourney.getNextQuestionUrl = mock.fn(() => mockBackLink);
340
341
  mockJourney.getSection = mock.fn(() => mockSection);
@@ -343,6 +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]);
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);
346
352
  });
347
353
  });
348
354
 
@@ -367,8 +373,8 @@ describe('dynamic-form/controller', () => {
367
373
  notSampleFieldName: 'do not send this'
368
374
  };
369
375
 
370
- mockJourney.getQuestionBySectionAndName = mock.fn();
371
- mockJourney.getQuestionBySectionAndName.mock.mockImplementationOnce(() => sampleQuestionObj);
376
+ mockJourney.getQuestionByParams = mock.fn();
377
+ mockJourney.getQuestionByParams.mock.mockImplementationOnce(() => sampleQuestionObj);
372
378
 
373
379
  const saveData = mock.fn();
374
380
  await buildSave(saveData)(req, res, journeyId);
@@ -387,7 +393,9 @@ describe('dynamic-form/controller', () => {
387
393
  const mocks = [sampleQuestionObj.checkForValidationErrors.mock, sampleQuestionObj.checkForSavingErrors.mock];
388
394
  for (const mockFn of mocks) {
389
395
  assert.strictEqual(mockFn.callCount(), 1);
390
- 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);
391
399
  }
392
400
  });
393
401
 
@@ -397,7 +405,7 @@ describe('dynamic-form/controller', () => {
397
405
  const sampleQuestionObjWithActions = {
398
406
  ...sampleQuestionObj,
399
407
  saveAction: mock.fn(),
400
- prepQuestionForRendering: mock.fn(() => expectedViewModel),
408
+ toViewModel: mock.fn(() => expectedViewModel),
401
409
  renderAction: mock.fn()
402
410
  };
403
411
 
@@ -418,8 +426,8 @@ describe('dynamic-form/controller', () => {
418
426
  notSampleFieldName: 'do not send this'
419
427
  };
420
428
 
421
- mockJourney.getQuestionBySectionAndName = mock.fn();
422
- mockJourney.getQuestionBySectionAndName.mock.mockImplementationOnce(() => sampleQuestionObjWithActions);
429
+ mockJourney.getQuestionByParams = mock.fn();
430
+ mockJourney.getQuestionByParams.mock.mockImplementationOnce(() => sampleQuestionObjWithActions);
423
431
 
424
432
  const saveData = mock.fn(() => {
425
433
  throw new Error('Expected error message');
@@ -427,20 +435,25 @@ describe('dynamic-form/controller', () => {
427
435
  await buildSave(saveData)(req, res, journeyId);
428
436
 
429
437
  assert.strictEqual(saveData.mock.callCount(), 1);
430
- assert.deepStrictEqual(saveData.mock.calls[0].arguments, [
431
- {
432
- req,
433
- res,
434
- journeyId: journeyParams.journeyId,
435
- referenceId: journeyParams.referenceId,
436
- data: undefined
437
- }
438
- ]);
439
-
440
- assert.deepStrictEqual(sampleQuestionObjWithActions.renderAction.mock.calls[0].arguments, [
441
- res,
442
- expectedViewModel
443
- ]);
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
+ );
451
+
452
+ assert.deepStrictEqual(
453
+ sampleQuestionObjWithActions.renderAction.mock.calls[0].arguments,
454
+ [res, expectedViewModel],
455
+ 'render args'
456
+ );
444
457
  });
445
458
 
446
459
  it('should handle validation errors', async () => {
@@ -465,8 +478,8 @@ describe('dynamic-form/controller', () => {
465
478
  answers: {}
466
479
  };
467
480
 
468
- mockJourney.getQuestionBySectionAndName = mock.fn();
469
- mockJourney.getQuestionBySectionAndName.mock.mockImplementationOnce(() => sampleQuestionObjWithActions);
481
+ mockJourney.getQuestionByParams = mock.fn();
482
+ mockJourney.getQuestionByParams.mock.mockImplementationOnce(() => sampleQuestionObjWithActions);
470
483
 
471
484
  const saveData = mock.fn();
472
485
  await buildSave(saveData)(req, res, journeyId);
@@ -480,10 +493,7 @@ describe('dynamic-form/controller', () => {
480
493
  const journeyId = 'has-questionnaire';
481
494
  const expectedUrl = 'redirect-url';
482
495
  const sampleQuestionObjWithActions = {
483
- ...sampleQuestionObj,
484
- handleNextQuestion: mock.fn((res, journey) => {
485
- res.redirect(journey.getNextQuestionUrl());
486
- })
496
+ ...sampleQuestionObj
487
497
  };
488
498
 
489
499
  req.params = {
@@ -498,8 +508,8 @@ describe('dynamic-form/controller', () => {
498
508
  };
499
509
 
500
510
  mockJourney.getSection = mock.fn(() => ({}));
501
- mockJourney.getQuestionBySectionAndName = mock.fn();
502
- mockJourney.getQuestionBySectionAndName.mock.mockImplementationOnce(() => sampleQuestionObjWithActions);
511
+ mockJourney.getQuestionByParams = mock.fn();
512
+ mockJourney.getQuestionByParams.mock.mockImplementationOnce(() => sampleQuestionObjWithActions);
503
513
  mockJourney.getNextQuestionUrl = mock.fn(() => expectedUrl);
504
514
 
505
515
  const saveData = mock.fn();
@@ -515,10 +525,7 @@ describe('dynamic-form/controller', () => {
515
525
  const journeyId = 'has-questionnaire';
516
526
  const expectedUrl = 'redirect-url';
517
527
  const sampleQuestionObjWithActions = {
518
- ...sampleQuestionObj,
519
- handleNextQuestion: mock.fn((res, journey) => {
520
- res.redirect(journey.getNextQuestionUrl());
521
- })
528
+ ...sampleQuestionObj
522
529
  };
523
530
 
524
531
  req.params = {
@@ -533,8 +540,8 @@ describe('dynamic-form/controller', () => {
533
540
  };
534
541
 
535
542
  mockJourney.getSection = mock.fn(() => ({}));
536
- mockJourney.getQuestionBySectionAndName = mock.fn();
537
- mockJourney.getQuestionBySectionAndName.mock.mockImplementationOnce(() => sampleQuestionObjWithActions);
543
+ mockJourney.getQuestionByParams = mock.fn();
544
+ mockJourney.getQuestionByParams.mock.mockImplementationOnce(() => sampleQuestionObjWithActions);
538
545
  mockJourney.getNextQuestionUrl = mock.fn(() => expectedUrl);
539
546
 
540
547
  const saveData = mock.fn();
@@ -545,6 +552,66 @@ describe('dynamic-form/controller', () => {
545
552
  assert.match(res.redirect.mock.calls[0].arguments[0], /\/task-list$/);
546
553
  assert.strictEqual(mockJourney.getNextQuestionUrl.mock.callCount(), 0);
547
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
+ });
548
615
  });
549
616
  });
550
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
  });
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Express route parameters used for journey routes
3
+ */
4
+ export type RouteParams = CoreRouteParams & ManageListRouteParams;
5
+
6
+ export type CoreRouteParams = {
7
+ section: string;
8
+ question: string;
9
+ [key: string]: string;
10
+ };
11
+
12
+ export type ManageListRouteParams =
13
+ | {
14
+ manageListAction: string;
15
+ manageListItemId: string;
16
+ manageListQuestion: string;
17
+ }
18
+ | {};
@@ -3,6 +3,7 @@
3
3
  * (e.g. questionnaire). Specific journeys should be *
4
4
  * instances of this class *
5
5
  ***********************************************************/
6
+ import { END_OF_SECTION } from '#src/section.js';
6
7
 
7
8
  /**
8
9
  * @typedef {import('./journey-response').JourneyResponse} JourneyResponse
@@ -135,12 +136,15 @@ export class Journey {
135
136
 
136
137
  /**
137
138
  * utility function to build up a url to a question
138
- * @param {string} sectionSegment url param for a section
139
- * @param {string} questionSegment url param for a question
139
+ * @param {import('./journey-types.d.ts').RouteParams} params
140
140
  * @returns {string} url for a question
141
141
  */
142
- #buildQuestionUrl(sectionSegment, questionSegment) {
143
- return this.#prependPathToUrl(this.baseUrl, `${sectionSegment}/${questionSegment}`);
142
+ #buildQuestionUrl(params) {
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,39 +162,58 @@ 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
  /**
170
183
  * gets a question from the object's sections based on a section + question names
171
- * @param {string} sectionSegment segment of the section to find the question in
172
- * @param {string} questionSegment fieldname of the question to lookup
184
+ * @param {import('./journey-types.d.ts').RouteParams} params
173
185
  * @returns {Question | undefined} question found by lookup
174
186
  */
175
- getQuestionBySectionAndName(sectionSegment, questionSegment) {
176
- const section = this.getSection(sectionSegment);
187
+ getQuestionByParams(params) {
188
+ const section = this.getSection(params.section);
177
189
 
178
190
  if (!section) {
179
191
  return undefined;
180
192
  }
181
-
182
- return this.#getQuestion(section, questionSegment);
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);
183
202
  }
184
203
 
185
204
  /**
186
205
  * Get the back link for the journey - e.g. the previous question
187
206
  *
188
- * @param {string} sectionSegment - section segment
189
- * @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]
190
210
  * @returns {string|null} url for the next question, or null if unmatched
191
211
  */
192
- getBackLink(sectionSegment, questionSegment) {
193
- const previousQuestion = this.getNextQuestionUrl(sectionSegment, questionSegment, true);
212
+ getBackLink({ params, manageListQuestion }) {
213
+ const previousQuestion = this.getNextQuestionUrl(params, {
214
+ manageListQuestion,
215
+ reverse: true
216
+ });
194
217
  if (!previousQuestion) {
195
218
  return this.initialBackLink;
196
219
  }
@@ -198,15 +221,32 @@ export class Journey {
198
221
  }
199
222
 
200
223
  /**
201
- * Get url for the next question in this section
202
- * @param {string} sectionSegment - section segment
203
- * @param {string} questionSegment - question segment
204
- * @param {boolean} [reverse] - if passed in this will get the previous question
224
+ * Handles redirect to the next question in the journey
225
+ * Used after question post/saving
226
+ *
227
+ * @param {import('express').Response} res
228
+ * @param {import('./journey-types.d.ts').RouteParams} params
229
+ * @param {import('#src/components/manage-list/question.js')} [manageListQuestion]
230
+ * @returns {void}
231
+ */
232
+ redirectToNextQuestion(res, params, manageListQuestion) {
233
+ const next = this.getNextQuestionUrl(params, { manageListQuestion }) ?? this.taskListUrl;
234
+ return res.redirect(next);
235
+ }
236
+
237
+ /**
238
+ * Get url for the next question in the journey
239
+ *
240
+ * @param {import('./journey-types.d.ts').RouteParams} params
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]
205
244
  * @returns {string|null} url for the next question, or null if unmatched
206
245
  */
207
- getNextQuestionUrl(sectionSegment, questionSegment, reverse = false) {
246
+ getNextQuestionUrl(params, { reverse = false, manageListQuestion } = {}) {
208
247
  const numberOfSections = this.sections.length;
209
248
  const sectionsStart = reverse ? numberOfSections - 1 : 0;
249
+ const questionFieldName = manageListQuestion ? params.manageListQuestion : params.question;
210
250
 
211
251
  let currentSectionIndex;
212
252
  let foundSection = false;
@@ -214,9 +254,8 @@ export class Journey {
214
254
 
215
255
  for (let i = sectionsStart; reverse ? i >= 0 : i < numberOfSections; reverse ? i-- : i++) {
216
256
  const currentSection = this.sections[i];
217
- const numberOfQuestions = currentSection.questions.length;
218
257
 
219
- if (currentSection.segment === sectionSegment) {
258
+ if (currentSection.segment === params.section) {
220
259
  foundSection = true;
221
260
  currentSectionIndex = i;
222
261
  }
@@ -225,17 +264,39 @@ export class Journey {
225
264
  if (this.returnToListing && i !== currentSectionIndex) {
226
265
  return null;
227
266
  }
228
-
229
- const questionsStart = reverse ? numberOfQuestions - 1 : 0;
230
- for (let j = questionsStart; reverse ? j >= 0 : j < numberOfQuestions; reverse ? j-- : j++) {
231
- const question = currentSection.questions[j];
232
- if (takeNextQuestion && question.shouldDisplay(this.response)) {
233
- return this.#buildQuestionUrl(currentSection.segment, question.url ? question.url : question.fieldName);
234
- }
235
-
236
- if (question.fieldName === questionSegment) {
237
- takeNextQuestion = true;
267
+ const question = currentSection.getNextQuestion({
268
+ questionFieldName,
269
+ response: this.response,
270
+ manageListQuestion,
271
+ takeNextQuestion,
272
+ reverse
273
+ });
274
+ if (question === END_OF_SECTION) {
275
+ takeNextQuestion = true; // get the first question from the following section
276
+ } else if (question) {
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 = {
285
+ section: currentSection.segment,
286
+ question: question.url || question.fieldName
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
+ };
238
298
  }
299
+ return this.#buildQuestionUrl(newParams);
239
300
  }
240
301
  }
241
302
  }
@@ -264,10 +325,10 @@ export class Journey {
264
325
  return unmatchedUrl;
265
326
  }
266
327
 
267
- return this.#buildQuestionUrl(
268
- matchingSection.segment,
269
- matchingQuestion.url ? matchingQuestion.url : matchingQuestion.fieldName
270
- );
328
+ return this.#buildQuestionUrl({
329
+ section: matchingSection.segment,
330
+ question: matchingQuestion.url || matchingQuestion.fieldName
331
+ });
271
332
  };
272
333
 
273
334
  /**
@@ -303,7 +364,10 @@ export class Journey {
303
364
 
304
365
  const questionUrl = matchingQuestion.url ?? matchingQuestion.fieldName;
305
366
 
306
- return this.#buildQuestionUrl(matchingSection.segment, questionUrl + addition);
367
+ return this.#buildQuestionUrl({
368
+ section: matchingSection.segment,
369
+ question: questionUrl + addition
370
+ });
307
371
  };
308
372
 
309
373
  /**