@planning-inspectorate/dynamic-forms 2.2.1 → 2.4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planning-inspectorate/dynamic-forms",
3
- "version": "2.2.1",
3
+ "version": "2.4.0",
4
4
  "description": "This package is for building GDS forms using a configuration-based approach.",
5
5
  "main": "./src/index.js",
6
6
  "type": "module",
@@ -26,6 +26,7 @@
26
26
  },
27
27
  "imports": {
28
28
  "#question-types": "./src/questions/question-types.d.ts",
29
+ "#src/*": "./src/*",
29
30
  "#test/*": "./test/*"
30
31
  },
31
32
  "devDependencies": {
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
@@ -139,18 +147,19 @@ export async function list(req, res, pageCaption, viewData) {
139
147
  */
140
148
  export async function question(req, res) {
141
149
  //render an individual question
142
- const { section, question } = req.params;
143
150
  const { journey } = res.locals;
144
151
 
145
- const sectionObj = journey.getSection(section);
146
- const questionObj = journey.getQuestionBySectionAndName(section, question);
152
+ const section = journey.getSection(req.params.section);
153
+ const question = journey.getQuestionByParams(req.params);
147
154
 
148
- if (!questionObj || !sectionObj) {
155
+ if (!question || !section) {
149
156
  return res.redirect(journey.taskListUrl);
150
157
  }
151
158
 
152
- const viewModel = questionObj.prepQuestionForRendering(sectionObj, journey);
153
- return questionObj.renderAction(res, viewModel);
159
+ const viewModel = question.prepQuestionForRendering(section, journey, {
160
+ originalUrl: req.originalUrl
161
+ });
162
+ return question.renderAction(res, viewModel);
154
163
  }
155
164
 
156
165
  /**
@@ -173,28 +182,27 @@ export async function question(req, res) {
173
182
  */
174
183
  export function buildSave(saveData, redirectToTaskListOnSuccess) {
175
184
  return async (req, res) => {
176
- const { section, question } = req.params;
177
185
  /** @type {import('./journey/journey.js').Journey} */
178
186
  const journey = res.locals.journey;
179
187
  /** @type {import('./journey/journey-response.js').JourneyResponse} */
180
188
  const journeyResponse = res.locals.journeyResponse;
181
189
 
182
- const sectionObj = journey.getSection(section);
183
- const questionObj = journey.getQuestionBySectionAndName(section, question);
190
+ const section = journey.getSection(req.params.section);
191
+ const question = journey.getQuestionByParams(req.params);
184
192
 
185
- if (!questionObj || !sectionObj) {
193
+ if (!question || !section) {
186
194
  return res.redirect(journey.taskListUrl);
187
195
  }
188
196
 
189
197
  try {
190
198
  // check for validation errors
191
- const errorViewModel = questionObj.checkForValidationErrors(req, sectionObj, journey);
199
+ const errorViewModel = question.checkForValidationErrors(req, section, journey);
192
200
  if (errorViewModel) {
193
- return questionObj.renderAction(res, errorViewModel);
201
+ return question.renderAction(res, errorViewModel);
194
202
  }
195
203
 
196
204
  // save
197
- const data = await questionObj.getDataToSave(req, journeyResponse);
205
+ const data = await question.getDataToSave(req, journeyResponse);
198
206
 
199
207
  await saveData({
200
208
  req,
@@ -205,20 +213,23 @@ export function buildSave(saveData, redirectToTaskListOnSuccess) {
205
213
  });
206
214
 
207
215
  // check for saving errors
208
- const saveViewModel = questionObj.checkForSavingErrors(req, sectionObj, journey);
216
+ const saveViewModel = question.checkForSavingErrors(req, section, journey);
209
217
  if (saveViewModel) {
210
- return questionObj.renderAction(res, saveViewModel);
218
+ return question.renderAction(res, saveViewModel);
211
219
  }
212
220
  if (redirectToTaskListOnSuccess) {
213
221
  return res.redirect(journey.taskListUrl);
214
222
  }
215
223
  // move to the next question
216
- return questionObj.handleNextQuestion(res, journey, sectionObj.segment, questionObj.fieldName);
224
+ return journey.redirectToNextQuestion(res, {
225
+ section: section.segment,
226
+ question: question.fieldName
227
+ });
217
228
  } catch (err) {
218
- const viewModel = questionObj.prepQuestionForRendering(sectionObj, journey, {
229
+ const viewModel = question.prepQuestionForRendering(section, journey, {
219
230
  errorSummary: err.errorSummary ?? [{ text: err.toString(), href: '#' }]
220
231
  });
221
- return questionObj.renderAction(res, viewModel);
232
+ return question.renderAction(res, viewModel);
222
233
  }
223
234
  };
224
235
  }
@@ -180,8 +180,7 @@ const sampleQuestionObj = {
180
180
  checkForSavingErrors: mock.fn(),
181
181
  prepQuestionForRendering: mock.fn(),
182
182
  formatAnswerForSummary: mock.fn(() => [mockAnswer]),
183
- viewFolder: 'sampleType',
184
- handleNextQuestion: mock.fn()
183
+ viewFolder: 'sampleType'
185
184
  };
186
185
 
187
186
  const mockSection = {
@@ -301,8 +300,8 @@ describe('dynamic-form/controller', () => {
301
300
 
302
301
  describe('question', () => {
303
302
  it('should redirect if question is not found', async () => {
304
- mockJourney.getQuestionBySectionAndName = mock.fn();
305
- mockJourney.getQuestionBySectionAndName.mock.mockImplementationOnce(() => null);
303
+ mockJourney.getQuestionByParams = mock.fn();
304
+ mockJourney.getQuestionByParams.mock.mockImplementationOnce(() => null);
306
305
 
307
306
  await question(req, res);
308
307
 
@@ -314,8 +313,8 @@ describe('dynamic-form/controller', () => {
314
313
  mockJourney.getSection.mock.mockImplementationOnce(() => {
315
314
  return {};
316
315
  });
317
- mockJourney.getQuestionBySectionAndName = mock.fn(() => sampleQuestionObj);
318
- mockJourney.getQuestionBySectionAndName.mock.mockImplementationOnce(() => sampleQuestionObj);
316
+ mockJourney.getQuestionByParams = mock.fn(() => sampleQuestionObj);
317
+ mockJourney.getQuestionByParams.mock.mockImplementationOnce(() => sampleQuestionObj);
319
318
  sampleQuestionObj.renderAction = mock.fn();
320
319
 
321
320
  await question(req, res);
@@ -326,6 +325,7 @@ describe('dynamic-form/controller', () => {
326
325
 
327
326
  it('should render the question template', async () => {
328
327
  sampleQuestionObj.renderAction.mock.resetCalls();
328
+ sampleQuestionObj.prepQuestionForRendering.mock.resetCalls();
329
329
  req.params.referenceId = mockRef;
330
330
  const mockAnswer = 'sampleAnswer';
331
331
  const mockBackLink = 'back';
@@ -333,8 +333,8 @@ describe('dynamic-form/controller', () => {
333
333
 
334
334
  sampleQuestionObj.prepQuestionForRendering.mock.mockImplementationOnce(() => mockQuestionRendering);
335
335
 
336
- mockJourney.getQuestionBySectionAndName = mock.fn();
337
- mockJourney.getQuestionBySectionAndName.mock.mockImplementationOnce(() => sampleQuestionObj);
336
+ mockJourney.getQuestionByParams = mock.fn();
337
+ mockJourney.getQuestionByParams.mock.mockImplementationOnce(() => sampleQuestionObj);
338
338
  mockJourney.response.answers.sampleFieldName = mockAnswer;
339
339
  mockJourney.getNextQuestionUrl = mock.fn(() => mockBackLink);
340
340
  mockJourney.getSection = mock.fn(() => mockSection);
@@ -343,6 +343,13 @@ describe('dynamic-form/controller', () => {
343
343
 
344
344
  assert.strictEqual(sampleQuestionObj.renderAction.mock.callCount(), 1);
345
345
  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
+ );
346
353
  });
347
354
  });
348
355
 
@@ -367,8 +374,8 @@ describe('dynamic-form/controller', () => {
367
374
  notSampleFieldName: 'do not send this'
368
375
  };
369
376
 
370
- mockJourney.getQuestionBySectionAndName = mock.fn();
371
- mockJourney.getQuestionBySectionAndName.mock.mockImplementationOnce(() => sampleQuestionObj);
377
+ mockJourney.getQuestionByParams = mock.fn();
378
+ mockJourney.getQuestionByParams.mock.mockImplementationOnce(() => sampleQuestionObj);
372
379
 
373
380
  const saveData = mock.fn();
374
381
  await buildSave(saveData)(req, res, journeyId);
@@ -418,8 +425,8 @@ describe('dynamic-form/controller', () => {
418
425
  notSampleFieldName: 'do not send this'
419
426
  };
420
427
 
421
- mockJourney.getQuestionBySectionAndName = mock.fn();
422
- mockJourney.getQuestionBySectionAndName.mock.mockImplementationOnce(() => sampleQuestionObjWithActions);
428
+ mockJourney.getQuestionByParams = mock.fn();
429
+ mockJourney.getQuestionByParams.mock.mockImplementationOnce(() => sampleQuestionObjWithActions);
423
430
 
424
431
  const saveData = mock.fn(() => {
425
432
  throw new Error('Expected error message');
@@ -465,8 +472,8 @@ describe('dynamic-form/controller', () => {
465
472
  answers: {}
466
473
  };
467
474
 
468
- mockJourney.getQuestionBySectionAndName = mock.fn();
469
- mockJourney.getQuestionBySectionAndName.mock.mockImplementationOnce(() => sampleQuestionObjWithActions);
475
+ mockJourney.getQuestionByParams = mock.fn();
476
+ mockJourney.getQuestionByParams.mock.mockImplementationOnce(() => sampleQuestionObjWithActions);
470
477
 
471
478
  const saveData = mock.fn();
472
479
  await buildSave(saveData)(req, res, journeyId);
@@ -480,10 +487,7 @@ describe('dynamic-form/controller', () => {
480
487
  const journeyId = 'has-questionnaire';
481
488
  const expectedUrl = 'redirect-url';
482
489
  const sampleQuestionObjWithActions = {
483
- ...sampleQuestionObj,
484
- handleNextQuestion: mock.fn((res, journey) => {
485
- res.redirect(journey.getNextQuestionUrl());
486
- })
490
+ ...sampleQuestionObj
487
491
  };
488
492
 
489
493
  req.params = {
@@ -498,8 +502,8 @@ describe('dynamic-form/controller', () => {
498
502
  };
499
503
 
500
504
  mockJourney.getSection = mock.fn(() => ({}));
501
- mockJourney.getQuestionBySectionAndName = mock.fn();
502
- mockJourney.getQuestionBySectionAndName.mock.mockImplementationOnce(() => sampleQuestionObjWithActions);
505
+ mockJourney.getQuestionByParams = mock.fn();
506
+ mockJourney.getQuestionByParams.mock.mockImplementationOnce(() => sampleQuestionObjWithActions);
503
507
  mockJourney.getNextQuestionUrl = mock.fn(() => expectedUrl);
504
508
 
505
509
  const saveData = mock.fn();
@@ -515,10 +519,7 @@ describe('dynamic-form/controller', () => {
515
519
  const journeyId = 'has-questionnaire';
516
520
  const expectedUrl = 'redirect-url';
517
521
  const sampleQuestionObjWithActions = {
518
- ...sampleQuestionObj,
519
- handleNextQuestion: mock.fn((res, journey) => {
520
- res.redirect(journey.getNextQuestionUrl());
521
- })
522
+ ...sampleQuestionObj
522
523
  };
523
524
 
524
525
  req.params = {
@@ -533,8 +534,8 @@ describe('dynamic-form/controller', () => {
533
534
  };
534
535
 
535
536
  mockJourney.getSection = mock.fn(() => ({}));
536
- mockJourney.getQuestionBySectionAndName = mock.fn();
537
- mockJourney.getQuestionBySectionAndName.mock.mockImplementationOnce(() => sampleQuestionObjWithActions);
537
+ mockJourney.getQuestionByParams = mock.fn();
538
+ mockJourney.getQuestionByParams.mock.mockImplementationOnce(() => sampleQuestionObjWithActions);
538
539
  mockJourney.getNextQuestionUrl = mock.fn(() => expectedUrl);
539
540
 
540
541
  const saveData = mock.fn();
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Express route parameters used for journey routes
3
+ */
4
+ export type RouteParams = {
5
+ section: string;
6
+ question: string;
7
+ [key: string]: string;
8
+ };
@@ -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,11 @@ 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
+ return this.#prependPathToUrl(this.baseUrl, `${params.section}/${params.question}`);
144
144
  }
145
145
 
146
146
  /**
@@ -168,18 +168,17 @@ export class Journey {
168
168
 
169
169
  /**
170
170
  * 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
171
+ * @param {import('./journey-types.d.ts').RouteParams} params
173
172
  * @returns {Question | undefined} question found by lookup
174
173
  */
175
- getQuestionBySectionAndName(sectionSegment, questionSegment) {
176
- const section = this.getSection(sectionSegment);
174
+ getQuestionByParams(params) {
175
+ const section = this.getSection(params.section);
177
176
 
178
177
  if (!section) {
179
178
  return undefined;
180
179
  }
181
180
 
182
- return this.#getQuestion(section, questionSegment);
181
+ return this.#getQuestion(section, params.question);
183
182
  }
184
183
 
185
184
  /**
@@ -190,7 +189,7 @@ export class Journey {
190
189
  * @returns {string|null} url for the next question, or null if unmatched
191
190
  */
192
191
  getBackLink(sectionSegment, questionSegment) {
193
- const previousQuestion = this.getNextQuestionUrl(sectionSegment, questionSegment, true);
192
+ const previousQuestion = this.getNextQuestionUrl({ section: sectionSegment, question: questionSegment }, true);
194
193
  if (!previousQuestion) {
195
194
  return this.initialBackLink;
196
195
  }
@@ -198,13 +197,26 @@ export class Journey {
198
197
  }
199
198
 
200
199
  /**
201
- * Get url for the next question in this section
202
- * @param {string} sectionSegment - section segment
203
- * @param {string} questionSegment - question segment
200
+ * Handles redirect to the next question in the journey
201
+ * Used after question post/saving
202
+ *
203
+ * @param {import('express').Response} res
204
+ * @param {import('./journey-types.d.ts').RouteParams} params
205
+ * @returns {void}
206
+ */
207
+ redirectToNextQuestion(res, params) {
208
+ const next = this.getNextQuestionUrl(params) ?? this.taskListUrl;
209
+ return res.redirect(next);
210
+ }
211
+
212
+ /**
213
+ * Get url for the next question in the journey
214
+ *
215
+ * @param {import('./journey-types.d.ts').RouteParams} params
204
216
  * @param {boolean} [reverse] - if passed in this will get the previous question
205
217
  * @returns {string|null} url for the next question, or null if unmatched
206
218
  */
207
- getNextQuestionUrl(sectionSegment, questionSegment, reverse = false) {
219
+ getNextQuestionUrl(params, reverse = false) {
208
220
  const numberOfSections = this.sections.length;
209
221
  const sectionsStart = reverse ? numberOfSections - 1 : 0;
210
222
 
@@ -214,9 +226,8 @@ export class Journey {
214
226
 
215
227
  for (let i = sectionsStart; reverse ? i >= 0 : i < numberOfSections; reverse ? i-- : i++) {
216
228
  const currentSection = this.sections[i];
217
- const numberOfQuestions = currentSection.questions.length;
218
229
 
219
- if (currentSection.segment === sectionSegment) {
230
+ if (currentSection.segment === params.section) {
220
231
  foundSection = true;
221
232
  currentSectionIndex = i;
222
233
  }
@@ -225,17 +236,21 @@ export class Journey {
225
236
  if (this.returnToListing && i !== currentSectionIndex) {
226
237
  return null;
227
238
  }
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;
238
- }
239
+ const question = currentSection.getNextQuestion({
240
+ questionFieldName: params.question,
241
+ response: this.response,
242
+ takeNextQuestion,
243
+ reverse
244
+ });
245
+ if (question === END_OF_SECTION) {
246
+ takeNextQuestion = true; // get the first question from the following section
247
+ } else if (question) {
248
+ const newParams = {
249
+ ...params,
250
+ section: currentSection.segment,
251
+ question: question.url || question.fieldName
252
+ };
253
+ return this.#buildQuestionUrl(newParams);
239
254
  }
240
255
  }
241
256
  }
@@ -264,10 +279,10 @@ export class Journey {
264
279
  return unmatchedUrl;
265
280
  }
266
281
 
267
- return this.#buildQuestionUrl(
268
- matchingSection.segment,
269
- matchingQuestion.url ? matchingQuestion.url : matchingQuestion.fieldName
270
- );
282
+ return this.#buildQuestionUrl({
283
+ section: matchingSection.segment,
284
+ question: matchingQuestion.url || matchingQuestion.fieldName
285
+ });
271
286
  };
272
287
 
273
288
  /**
@@ -303,7 +318,10 @@ export class Journey {
303
318
 
304
319
  const questionUrl = matchingQuestion.url ?? matchingQuestion.fieldName;
305
320
 
306
- return this.#buildQuestionUrl(matchingSection.segment, questionUrl + addition);
321
+ return this.#buildQuestionUrl({
322
+ section: matchingSection.segment,
323
+ question: questionUrl + addition
324
+ });
307
325
  };
308
326
 
309
327
  /**
@@ -1,8 +1,9 @@
1
1
  import { describe, it, mock, beforeEach } from 'node:test';
2
2
  import assert from 'node:assert';
3
3
  import { Journey } from './journey.js';
4
+ import { Section } from '#src/section.js';
4
5
 
5
- const mockSections = [
6
+ const mockSectionDetails = [
6
7
  {
7
8
  segment: 'section1',
8
9
  questions: [
@@ -40,6 +41,11 @@ const mockSections = [
40
41
  ]
41
42
  }
42
43
  ];
44
+ const mockSections = mockSectionDetails.map((data, index) => {
45
+ const section = new Section(`section ${index}`, data.segment);
46
+ section.questions = data.questions;
47
+ return section;
48
+ });
43
49
 
44
50
  describe('Journey class', () => {
45
51
  let constructorArgs;
@@ -193,15 +199,15 @@ describe('Journey class', () => {
193
199
  });
194
200
  });
195
201
 
196
- describe('getQuestionBySectionAndName', () => {
202
+ describe('getQuestionByParams', () => {
197
203
  it('should return the correct question by section and name', () => {
198
204
  const journey = new Journey(constructorArgs);
199
205
  journey.sections = mockSections;
200
206
 
201
- const question = journey.getQuestionBySectionAndName(
202
- mockSections[0].segment,
203
- mockSections[0].questions[1].fieldName
204
- );
207
+ const question = journey.getQuestionByParams({
208
+ section: mockSections[0].segment,
209
+ question: mockSections[0].questions[1].fieldName
210
+ });
205
211
 
206
212
  assert.strictEqual(question, mockSections[0].questions[1]);
207
213
  });
@@ -210,7 +216,7 @@ describe('Journey class', () => {
210
216
  const journey = new Journey(constructorArgs);
211
217
  journey.sections = mockSections;
212
218
 
213
- const question = journey.getQuestionBySectionAndName('a', 'b');
219
+ const question = journey.getQuestionByParams({ section: 'a', question: 'b' });
214
220
 
215
221
  assert.strictEqual(question, undefined);
216
222
  });
@@ -219,7 +225,7 @@ describe('Journey class', () => {
219
225
  const journey = new Journey(constructorArgs);
220
226
  journey.sections = mockSections;
221
227
 
222
- const question = journey.getQuestionBySectionAndName(mockSections[0].segment, 'nope');
228
+ const question = journey.getQuestionByParams({ section: mockSections[0].segment, question: 'nope' });
223
229
 
224
230
  assert.strictEqual(question, undefined);
225
231
  });
@@ -294,7 +300,7 @@ describe('Journey class', () => {
294
300
  journey.sections = mockSections;
295
301
  journey.returnToListing = returnToListing;
296
302
 
297
- const nextQuestionUrl = journey.getNextQuestionUrl(section, name, false);
303
+ const nextQuestionUrl = journey.getNextQuestionUrl({ section: section, question: name }, false);
298
304
 
299
305
  assert.strictEqual(nextQuestionUrl, null);
300
306
  });
@@ -309,7 +315,7 @@ describe('Journey class', () => {
309
315
  journey.sections = mockSections;
310
316
  journey.returnToListing = returnToListing;
311
317
 
312
- const nextQuestionUrl = journey.getNextQuestionUrl(section, name, false);
318
+ const nextQuestionUrl = journey.getNextQuestionUrl({ section: section, question: name }, false);
313
319
 
314
320
  assert.strictEqual(nextQuestionUrl, null);
315
321
  });
@@ -325,7 +331,7 @@ describe('Journey class', () => {
325
331
  journey.sections = mockSections;
326
332
  journey.returnToListing = returnToListing;
327
333
 
328
- const nextQuestionUrl = journey.getNextQuestionUrl(section.segment, name, false);
334
+ const nextQuestionUrl = journey.getNextQuestionUrl({ section: section.segment, question: name }, false);
329
335
 
330
336
  assert.strictEqual(nextQuestionUrl, `${constructorArgs.makeBaseUrl()}/${section.segment}/${nextQuestionName}`);
331
337
  });
@@ -350,7 +356,7 @@ describe('Journey class', () => {
350
356
  journey.sections = mockSections;
351
357
  journey.returnToListing = returnToListing;
352
358
 
353
- const nextQuestionUrl = journey.getNextQuestionUrl(section.segment, name, false);
359
+ const nextQuestionUrl = journey.getNextQuestionUrl({ section: section.segment, question: name }, false);
354
360
 
355
361
  assert.strictEqual(nextQuestionUrl, `${constructorArgs.makeBaseUrl()}/${section.segment}/${nextQuestionName}`);
356
362
  });
@@ -366,7 +372,7 @@ describe('Journey class', () => {
366
372
  journey.sections = mockSections;
367
373
  journey.returnToListing = returnToListing;
368
374
 
369
- const nextQuestionUrl = journey.getNextQuestionUrl(section.segment, name, true);
375
+ const nextQuestionUrl = journey.getNextQuestionUrl({ section: section.segment, question: name }, true);
370
376
 
371
377
  assert.strictEqual(nextQuestionUrl, `${constructorArgs.makeBaseUrl()}/${section.segment}/${prevQuestionName}`);
372
378
  });
@@ -381,7 +387,7 @@ describe('Journey class', () => {
381
387
  journey.sections = mockSections;
382
388
  journey.returnToListing = returnToListing;
383
389
 
384
- const nextQuestionUrl = journey.getNextQuestionUrl(section.segment, name, false);
390
+ const nextQuestionUrl = journey.getNextQuestionUrl({ section: section.segment, question: name }, false);
385
391
 
386
392
  assert.strictEqual(nextQuestionUrl, null);
387
393
  });
@@ -398,7 +404,7 @@ describe('Journey class', () => {
398
404
  journey.sections = mockSections;
399
405
  journey.returnToListing = false;
400
406
 
401
- const nextQuestionUrl = journey.getNextQuestionUrl(section.segment, name, true);
407
+ const nextQuestionUrl = journey.getNextQuestionUrl({ section: section.segment, question: name }, true);
402
408
 
403
409
  assert.strictEqual(
404
410
  nextQuestionUrl,
@@ -416,7 +422,7 @@ describe('Journey class', () => {
416
422
  journey.sections = mockSections;
417
423
  journey.returnToListing = returnToListing;
418
424
 
419
- const nextQuestionUrl = journey.getNextQuestionUrl(section.segment, name, true);
425
+ const nextQuestionUrl = journey.getNextQuestionUrl({ section: section.segment, question: name }, true);
420
426
 
421
427
  assert.strictEqual(nextQuestionUrl, null);
422
428
  });
@@ -431,7 +437,7 @@ describe('Journey class', () => {
431
437
  journey.sections = mockSections;
432
438
  journey.returnToListing = true;
433
439
 
434
- const nextQuestionUrl = journey.getNextQuestionUrl(section.segment, name, false);
440
+ const nextQuestionUrl = journey.getNextQuestionUrl({ section: section.segment, question: name }, false);
435
441
  assert.strictEqual(nextQuestionUrl, null);
436
442
  });
437
443
  }
@@ -445,7 +451,7 @@ describe('Journey class', () => {
445
451
  journey.sections = mockSections;
446
452
  journey.returnToListing = true;
447
453
 
448
- const nextQuestionUrl = journey.getNextQuestionUrl(section.segment, name, true);
454
+ const nextQuestionUrl = journey.getNextQuestionUrl({ section: section.segment, question: name }, true);
449
455
 
450
456
  assert.strictEqual(nextQuestionUrl, null);
451
457
  });
@@ -460,7 +466,7 @@ describe('Journey class', () => {
460
466
  const journey = new Journey(constructorArgs);
461
467
  journey.sections = mockSections;
462
468
 
463
- const nextQuestionUrl = journey.getNextQuestionUrl(section.segment, name, false);
469
+ const nextQuestionUrl = journey.getNextQuestionUrl({ section: section.segment, question: name }, false);
464
470
 
465
471
  assert.strictEqual(nextQuestionUrl, `base/${section.segment}/${nextQuestionName}?id=1`);
466
472
  });
@@ -0,0 +1,10 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ /**
4
+ * Wrapper class so it can be overridden by test mocks
5
+ */
6
+ export class Uuid {
7
+ static randomUUID() {
8
+ return randomUUID();
9
+ }
10
+ }
@@ -0,0 +1,24 @@
1
+ import { Uuid } from '#src/lib/uuid.js';
2
+ import { describe, it } from 'node:test';
3
+ import assert from 'node:assert';
4
+
5
+ /**
6
+ *
7
+ * @param {import('node:test').TestContext} ctx
8
+ * @param {string} [uuid]
9
+ */
10
+ export function mockRandomUUID(ctx, uuid = '00000000-0000-0000-0000-000000000000') {
11
+ const original = Uuid.randomUUID;
12
+ Uuid.randomUUID = ctx.mock.fn(() => uuid);
13
+ // restore after the tests
14
+ ctx.after(() => (Uuid.randomUUID = original));
15
+ }
16
+
17
+ describe('uuid', () => {
18
+ it('should return a valid v4 UUID', () => {
19
+ assert.match(
20
+ Uuid.randomUUID(),
21
+ /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/
22
+ );
23
+ });
24
+ });
@@ -16,10 +16,9 @@ export default () => (req, res, next) => {
16
16
  return next();
17
17
  }
18
18
 
19
- const { section, question } = req.params;
20
19
  const { journey } = res.locals;
21
20
 
22
- const questionObj = journey.getQuestionBySectionAndName(section, question);
21
+ const questionObj = journey.getQuestionByParams(req.params);
23
22
  const filesPropertyPath = questionObj.fieldName;
24
23
 
25
24
  if (typeof req.files[filesPropertyPath] === 'undefined') {
@@ -8,7 +8,7 @@ describe('dynamic-forms/middleware/dynamic-req-files-to-req-body-files', () => {
8
8
  let mockRes;
9
9
  let filesPropertyPath = `example-files-property-path-${counter}`;
10
10
 
11
- const mockGetQuestionBySectionAndName = mock.fn(() => {
11
+ const mockgetQuestionByParams = mock.fn(() => {
12
12
  return { fieldName: filesPropertyPath };
13
13
  });
14
14
 
@@ -24,7 +24,7 @@ describe('dynamic-forms/middleware/dynamic-req-files-to-req-body-files', () => {
24
24
  cookie: mock.fn(),
25
25
  locals: {
26
26
  journeyResponse: mock.fn(),
27
- journey: { getQuestionBySectionAndName: mockGetQuestionBySectionAndName }
27
+ journey: { getQuestionByParams: mockgetQuestionByParams }
28
28
  },
29
29
  redirect: mock.fn(),
30
30
  render: mock.fn(),
@@ -279,19 +279,20 @@ export class Question {
279
279
  }
280
280
 
281
281
  /**
282
- * Handles redirect after saving
282
+ * Handles redirect after saving. Kept around for backwards compatibility.
283
+ *
283
284
  * @param {import('express').Response} res
284
285
  * @param {Journey} journey
285
286
  * @param {string} sectionSegment
286
287
  * @param {string} questionSegment
287
288
  * @returns {void}
289
+ * @deprecated - use `journey.redirectToNextQuestion`
288
290
  */
289
291
  handleNextQuestion(res, journey, sectionSegment, questionSegment) {
290
- let next = journey.getNextQuestionUrl(sectionSegment, questionSegment);
291
- if (next === null) {
292
- next = journey.taskListUrl;
293
- }
294
- return res.redirect(next);
292
+ return journey.redirectToNextQuestion(res, {
293
+ section: sectionSegment,
294
+ question: questionSegment
295
+ });
295
296
  }
296
297
 
297
298
  /**
package/src/section.js CHANGED
@@ -5,7 +5,13 @@
5
5
  */
6
6
 
7
7
  import RequiredValidator from './validator/required-validator.js';
8
- import MultiFieldInputQuestion from './components/multi-field-input/question.js';
8
+
9
+ /**
10
+ * A value indicating the final question of a section has been reached
11
+ *
12
+ * @type {Symbol}
13
+ */
14
+ export const END_OF_SECTION = Symbol('END_OF_SECTION');
9
15
 
10
16
  /**
11
17
  * Defines a section for a questionnaire, a set of Questions
@@ -184,6 +190,35 @@ export class Section {
184
190
  return this;
185
191
  }
186
192
 
193
+ /**
194
+ * Get the next question in this section given a questionParam (question fieldName)
195
+ * @param {Object} params
196
+ * @param {string} params.questionFieldName
197
+ * @param {import('#src/journey/journey-response.js').JourneyResponse} params.response
198
+ * @param {boolean} [params.takeNextQuestion]
199
+ * @param {boolean} [params.reverse]
200
+ * @returns {Question|Symbol|null}
201
+ */
202
+ getNextQuestion({ questionFieldName, response, takeNextQuestion = false, reverse = false }) {
203
+ const numberOfQuestions = this.questions.length;
204
+
205
+ const questionsStart = reverse ? numberOfQuestions - 1 : 0;
206
+ for (let i = questionsStart; reverse ? i >= 0 : i < numberOfQuestions; reverse ? i-- : i++) {
207
+ const question = this.questions[i];
208
+ if (takeNextQuestion && question.shouldDisplay(response)) {
209
+ return question;
210
+ }
211
+
212
+ if (question.fieldName === questionFieldName) {
213
+ takeNextQuestion = true;
214
+ }
215
+ }
216
+ if (takeNextQuestion) {
217
+ return END_OF_SECTION;
218
+ }
219
+ return null;
220
+ }
221
+
187
222
  /**
188
223
  * checks answers on response to ensure that a answer is provided for each required question in the section
189
224
  * @param {JourneyResponse} journeyResponse
@@ -200,7 +235,7 @@ export class Section {
200
235
  continue;
201
236
  }
202
237
  // if question is a multi field input question, check all fields
203
- if (question instanceof MultiFieldInputQuestion) {
238
+ if (question.inputFields) {
204
239
  for (const field of question.inputFields) {
205
240
  if (question.fieldIsRequired(field.fieldName)) {
206
241
  requiredQuestionCount++;
@@ -1,6 +1,6 @@
1
1
  import { describe, it, mock } from 'node:test';
2
2
  import assert from 'node:assert';
3
- import { Section, SECTION_STATUS } from './section.js';
3
+ import { END_OF_SECTION, Section, SECTION_STATUS } from './section.js';
4
4
  import RequiredValidator from './validator/required-validator.js';
5
5
 
6
6
  const mockQuestion = {
@@ -17,6 +17,13 @@ const mockQuestion2 = {
17
17
  isAnswered: () => false
18
18
  };
19
19
 
20
+ const mockQuestion3 = {
21
+ fieldName: 'mockQuestion3',
22
+ isRequired: () => true,
23
+ shouldDisplay: () => true,
24
+ isAnswered: () => false
25
+ };
26
+
20
27
  describe('./src/dynamic-forms/section.js', () => {
21
28
  describe('constructor', () => {
22
29
  it('should create', () => {
@@ -575,4 +582,83 @@ describe('./src/dynamic-forms/section.js', () => {
575
582
  assert.throws(() => section.withRequiredCondition(false));
576
583
  });
577
584
  });
585
+
586
+ describe('getNextQuestion', () => {
587
+ const newSection = () => {
588
+ const q1 = { ...mockQuestion };
589
+ const q2 = { ...mockQuestion2 };
590
+ const q3 = { ...mockQuestion3 };
591
+ const section = new Section('s1', 'S').addQuestion(q1).addQuestion(q2).addQuestion(q3);
592
+ return { section, q1, q2, q3 };
593
+ };
594
+
595
+ it('should take the first question if takeNextQuestion is set', () => {
596
+ const { section, q1 } = newSection();
597
+ const next = section.getNextQuestion({
598
+ questionFieldName: '',
599
+ response: {},
600
+ takeNextQuestion: true,
601
+ reverse: false
602
+ });
603
+ assert.strictEqual(next, q1);
604
+ });
605
+ it('should take second question if first is matched', () => {
606
+ const { section, q1, q2 } = newSection();
607
+ const next = section.getNextQuestion({
608
+ questionFieldName: q1.fieldName,
609
+ response: {},
610
+ takeNextQuestion: false,
611
+ reverse: false
612
+ });
613
+ assert.strictEqual(next, q2);
614
+ });
615
+ it('should return END_OF_SECTION if last question matched', () => {
616
+ const { section, q3 } = newSection();
617
+ const next = section.getNextQuestion({
618
+ questionFieldName: q3.fieldName,
619
+ response: {},
620
+ takeNextQuestion: false,
621
+ reverse: false
622
+ });
623
+ assert.strictEqual(next, END_OF_SECTION);
624
+ });
625
+ it('should return null if no questions match', () => {
626
+ const { section } = newSection();
627
+ const next = section.getNextQuestion({
628
+ questionFieldName: 'random-field-name',
629
+ response: {},
630
+ takeNextQuestion: false,
631
+ reverse: false
632
+ });
633
+ assert.strictEqual(next, null);
634
+ });
635
+ it('should return question only if it "shouldDisplay"', () => {
636
+ const { section, q1, q2, q3 } = newSection();
637
+ // skip q2
638
+ q2.shouldDisplay = mock.fn(() => false);
639
+ const next = section.getNextQuestion({
640
+ questionFieldName: q1.fieldName,
641
+ response: {},
642
+ takeNextQuestion: false,
643
+ reverse: false
644
+ });
645
+ assert.strictEqual(q2.shouldDisplay.mock.callCount(), 1);
646
+ assert.strictEqual(next, q3);
647
+ });
648
+ it(`should return END_OF_SECTION if remaining questions shouldn't display`, () => {
649
+ const { section, q1, q2, q3 } = newSection();
650
+ // skip q2 & q3
651
+ q2.shouldDisplay = mock.fn(() => false);
652
+ q3.shouldDisplay = mock.fn(() => false);
653
+ const next = section.getNextQuestion({
654
+ questionFieldName: q1.fieldName,
655
+ response: {},
656
+ takeNextQuestion: false,
657
+ reverse: false
658
+ });
659
+ assert.strictEqual(q2.shouldDisplay.mock.callCount(), 1);
660
+ assert.strictEqual(q3.shouldDisplay.mock.callCount(), 1);
661
+ assert.strictEqual(next, END_OF_SECTION);
662
+ });
663
+ });
578
664
  });
@@ -2,11 +2,9 @@ import { validationResult } from 'express-validator';
2
2
 
3
3
  /** @type {import('express').Handler} */
4
4
  const validate = async (req, res, next) => {
5
- const { section, question } = req.params;
6
-
7
5
  const { journey, journeyResponse } = res.locals;
8
6
 
9
- const questionObj = journey.getQuestionBySectionAndName(section, question);
7
+ const questionObj = journey.getQuestionByParams(req.params);
10
8
  if (!questionObj) {
11
9
  throw new Error('unknown question type');
12
10
  }
@@ -12,7 +12,7 @@ describe('./src/dynamic-forms/validator/validator.js', () => {
12
12
  locals: {
13
13
  journeyResponse: {},
14
14
  journey: {
15
- getQuestionBySectionAndName: function () {
15
+ getQuestionByParams: function () {
16
16
  return null;
17
17
  }
18
18
  }
@@ -50,7 +50,7 @@ describe('./src/dynamic-forms/validator/validator.js', () => {
50
50
  };
51
51
 
52
52
  mockRes.locals.journey = {
53
- getQuestionBySectionAndName: function () {
53
+ getQuestionByParams: function () {
54
54
  return {
55
55
  validators: [new RequiredValidator()],
56
56
  fieldName: 'field1'
@@ -78,7 +78,7 @@ describe('./src/dynamic-forms/validator/validator.js', () => {
78
78
  };
79
79
 
80
80
  mockRes.locals.journey = {
81
- getQuestionBySectionAndName: function () {
81
+ getQuestionByParams: function () {
82
82
  return {
83
83
  validators: [new RequiredValidator()],
84
84
  fieldName: 'field1'
@@ -106,7 +106,7 @@ describe('./src/dynamic-forms/validator/validator.js', () => {
106
106
  };
107
107
 
108
108
  mockRes.locals.journey = {
109
- getQuestionBySectionAndName: function () {
109
+ getQuestionByParams: function () {
110
110
  return {
111
111
  validators: [new RequiredValidator()],
112
112
  fieldName: 'field1'
@@ -133,7 +133,7 @@ describe('./src/dynamic-forms/validator/validator.js', () => {
133
133
  };
134
134
 
135
135
  mockRes.locals.journey = {
136
- getQuestionBySectionAndName: function () {
136
+ getQuestionByParams: function () {
137
137
  return {
138
138
  validators: [new RequiredValidator(), new ValidOptionValidator()],
139
139
  fieldName: 'field1',
@@ -174,7 +174,7 @@ describe('./src/dynamic-forms/validator/validator.js', () => {
174
174
  };
175
175
 
176
176
  mockRes.locals.journey = {
177
- getQuestionBySectionAndName: function () {
177
+ getQuestionByParams: function () {
178
178
  return {
179
179
  validators: [new RequiredValidator(), new ValidOptionValidator()],
180
180
  fieldName: 'field1',
@@ -217,7 +217,7 @@ describe('./src/dynamic-forms/validator/validator.js', () => {
217
217
  };
218
218
 
219
219
  mockRes.locals.journey = {
220
- getQuestionBySectionAndName: function () {
220
+ getQuestionByParams: function () {
221
221
  return {
222
222
  validators: [new AddressValidator()],
223
223
  fieldName: 'field1'
@@ -42,18 +42,25 @@ export async function createAppWithQuestions(ctx) {
42
42
 
43
43
  app.get('/check-your-answers', getJourneyResponse, getJourney, (req, res) => list(req, res, '', {}));
44
44
 
45
- app.use((error, req, res, next) => {
46
- if (error) {
47
- console.error('Internal Server Error:', error);
48
- }
49
- next();
50
- });
51
-
52
45
  app.use((req, res) => {
53
46
  res.status(404);
54
47
  });
55
48
 
56
- const server = new TestServer(app, { rememberCookies: true });
49
+ /**
50
+ * @type {import('express').ErrorRequestHandler}
51
+ */
52
+ function errorHandler(error, req, res, next) {
53
+ console.error('Internal Server Error:', error);
54
+ if (res.headersSent) {
55
+ return next(error);
56
+ }
57
+ res.status(500);
58
+ res.render('error', { error });
59
+ }
60
+
61
+ app.use(errorHandler);
62
+
63
+ const server = new TestServer(app, { rememberCookies: true, timeoutMs: 2000 });
57
64
  await server.start();
58
65
  ctx.after(async () => await server.stop());
59
66
  return server;
@@ -8,10 +8,12 @@ export class TestServer {
8
8
  // Private cookie jar for storing cookies between requests
9
9
  /** @type {Object<string, string>} */
10
10
  #cookieJar = {};
11
+ #timeoutMs;
11
12
 
12
13
  /**
13
14
  * @param {import('http').RequestListener} requestListener
14
15
  * @param {object} [options]
16
+ * @param {number} [options.timeoutMs] - timeout to use for fetch requests
15
17
  * @param {boolean} [options.rememberCookies] - If true, cookies are stored and sent with each request
16
18
  */
17
19
  constructor(requestListener, options = {}) {
@@ -19,6 +21,7 @@ export class TestServer {
19
21
  this.server = null;
20
22
  this.port = null;
21
23
  this.rememberCookies = Boolean(options.rememberCookies);
24
+ this.#timeoutMs = options.timeoutMs || 5000;
22
25
  }
23
26
 
24
27
  /**
@@ -66,11 +69,7 @@ export class TestServer {
66
69
  }
67
70
  const fetchOptions = { headers: {}, ...DEFAULT_OPTIONS, ...options, method: 'GET' };
68
71
  this.#addCookies(fetchOptions.headers);
69
- const response = await fetch(`http://localhost:${this.port}${path}`, fetchOptions);
70
- if (this.rememberCookies) {
71
- this.#updateCookies(response);
72
- }
73
- return response;
72
+ return this.#fetch(path, fetchOptions);
74
73
  }
75
74
 
76
75
  /**
@@ -131,11 +130,34 @@ export class TestServer {
131
130
  body: JSON.stringify(body)
132
131
  };
133
132
  this.#addCookies(fetchOptions.headers);
134
- const response = await fetch(`http://localhost:${this.port}${path}`, fetchOptions);
135
- if (this.rememberCookies) {
136
- this.#updateCookies(response);
133
+ return this.#fetch(path, fetchOptions);
134
+ }
135
+
136
+ /**
137
+ * Perform a fetch request with a timeout
138
+ *
139
+ * @param {string} path
140
+ * @param {RequestInit} [options]
141
+ */
142
+ async #fetch(path, options) {
143
+ // set up an abort controller to cancel requests after timeout
144
+ const controller = new AbortController();
145
+ const timeoutId = setTimeout(() => controller.abort(), this.#timeoutMs);
146
+ options.signal = controller.signal;
147
+ try {
148
+ const response = await fetch(`http://localhost:${this.port}${path}`, options);
149
+ if (this.rememberCookies) {
150
+ this.#updateCookies(response);
151
+ }
152
+ return response;
153
+ } catch (error) {
154
+ if (error instanceof Error && error.name === 'AbortError') {
155
+ throw new Error(`Request to ${path} timed out after ${this.#timeoutMs}ms`);
156
+ }
157
+ throw error;
158
+ } finally {
159
+ clearTimeout(timeoutId);
137
160
  }
138
- return response;
139
161
  }
140
162
 
141
163
  #addCookies(headers) {
@@ -28,7 +28,8 @@ export function mockLogger() {
28
28
  export const mockReq = () => ({
29
29
  log: mockLogger(),
30
30
  params: {},
31
- body: {}
31
+ body: {},
32
+ originalUrl: '/original-url'
32
33
  });
33
34
  export const mockRes = () => {
34
35
  const res = {
package/tsconfig.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "include": ["**/*.ts", "**/*.js"],
3
+ "exclude": ["node_modules", "packages/database/src/client/**", "**/*.test.ts", "test/**"],
4
+ "compilerOptions": {
5
+ "allowImportingTsExtensions": true,
6
+ "allowJs": true,
7
+ "allowSyntheticDefaultImports": true,
8
+ "baseUrl": ".",
9
+ "checkJs": false,
10
+ "erasableSyntaxOnly": true,
11
+ "esModuleInterop": true,
12
+ "module": "NodeNext",
13
+ "moduleResolution": "nodenext",
14
+ "strict": true,
15
+ "target": "esnext",
16
+ "skipLibCheck": true,
17
+ "paths": {
18
+ "#question-types": ["./src/questions/question-types.d.ts"],
19
+ "#src/*": ["./src/*"],
20
+ "#test/*": ["./test/*"]
21
+ }
22
+ }
23
+ }