@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
@@ -0,0 +1,20 @@
1
+ import path from 'path';
2
+ import nunjucks from 'nunjucks';
3
+
4
+ /**
5
+ * Returns a nunjucks environment configured with the `src` folder
6
+ * @returns {import('nunjucks').Environment}
7
+ */
8
+ export function nunjucksEnv() {
9
+ const utilsDir = path.dirname(new URL(import.meta.url).pathname);
10
+ const srcDir = path.resolve(utilsDir, '..', '..');
11
+
12
+ return nunjucks.configure([srcDir], {
13
+ // output with dangerous characters are escaped automatically
14
+ autoescape: true,
15
+ // automatically remove trailing newlines from a block/tag
16
+ trimBlocks: true,
17
+ // automatically remove leading whitespace from a block/tag
18
+ lstripBlocks: true
19
+ });
20
+ }
package/src/controller.js CHANGED
@@ -143,10 +143,11 @@ export async function list(req, res, pageCaption, viewData) {
143
143
  }
144
144
 
145
145
  /**
146
+ * Render an individual question
147
+ *
146
148
  * @type {import('express').Handler}
147
149
  */
148
150
  export async function question(req, res) {
149
- //render an individual question
150
151
  const { journey } = res.locals;
151
152
 
152
153
  const section = journey.getSection(req.params.section);
@@ -156,8 +157,23 @@ export async function question(req, res) {
156
157
  return res.redirect(journey.taskListUrl);
157
158
  }
158
159
 
159
- const viewModel = question.prepQuestionForRendering(section, journey, {
160
- originalUrl: req.originalUrl
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
+ }
161
177
  });
162
178
  return question.renderAction(res, viewModel);
163
179
  }
@@ -194,9 +210,18 @@ export function buildSave(saveData, redirectToTaskListOnSuccess) {
194
210
  return res.redirect(journey.taskListUrl);
195
211
  }
196
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
+
197
222
  try {
198
223
  // check for validation errors
199
- const errorViewModel = question.checkForValidationErrors(req, section, journey);
224
+ const errorViewModel = question.checkForValidationErrors(req, section, journey, manageListQuestion);
200
225
  if (errorViewModel) {
201
226
  return question.renderAction(res, errorViewModel);
202
227
  }
@@ -221,13 +246,17 @@ export function buildSave(saveData, redirectToTaskListOnSuccess) {
221
246
  return res.redirect(journey.taskListUrl);
222
247
  }
223
248
  // move to the next question
224
- return journey.redirectToNextQuestion(res, {
225
- section: section.segment,
226
- question: question.fieldName
227
- });
249
+ return journey.redirectToNextQuestion(res, req.params, manageListQuestion);
228
250
  } catch (err) {
229
- const viewModel = question.prepQuestionForRendering(section, journey, {
230
- 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
+ }
231
260
  });
232
261
  return question.renderAction(res, viewModel);
233
262
  }
@@ -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
  });
@@ -18,7 +18,7 @@ export class JourneyResponse {
18
18
  journeyId;
19
19
 
20
20
  /**
21
- * @type {Record<string, unknown>} - answers to the journey
21
+ * @type {import('./journey-types.d.ts').JourneyAnswers} - answers to the journey
22
22
  */
23
23
  answers;
24
24
 
@@ -26,7 +26,7 @@ export class JourneyResponse {
26
26
  * creates an instance of a JourneyResponse
27
27
  * @param {JourneyType} journeyId
28
28
  * @param {string} referenceId
29
- * @param {Record<string, unknown> | null} answers
29
+ * @param {import('./journey-types.d.ts').JourneyAnswers | null} answers
30
30
  * @param {string} [lpaCode]
31
31
  */
32
32
  constructor(journeyId, referenceId, answers, lpaCode) {
@@ -1,8 +1,28 @@
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
+ | {};
19
+
20
+ export interface JourneyAnswers {
21
+ // if the answer is for a manage list question, the answer will be an array of answer objects
22
+ [k: string]: unknown | ManageListAnswers[];
23
+ }
24
+
25
+ export interface ManageListAnswers {
26
+ id: string;
27
+ [k: string]: unknown;
28
+ }
@@ -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', () => {