@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 +2 -1
- package/src/controller.js +29 -18
- package/src/controller.test.js +27 -26
- package/src/journey/journey-types.d.ts +8 -0
- package/src/journey/journey.js +50 -32
- package/src/journey/journey.test.js +25 -19
- package/src/lib/uuid.js +10 -0
- package/src/lib/uuid.test.js +24 -0
- package/src/middleware/dynamic-req-files-to-req-body-files.js +1 -2
- package/src/middleware/dynamic-req-files-to-req-body-files.test.js +2 -2
- package/src/questions/question.js +7 -6
- package/src/section.js +37 -2
- package/src/section.test.js +87 -1
- package/src/validator/validator.js +1 -3
- package/src/validator/validator.test.js +7 -7
- package/test/questions.test.js +15 -8
- package/test/utils/test-server.js +31 -9
- package/test/utils/utils.js +2 -1
- package/tsconfig.json +23 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@planning-inspectorate/dynamic-forms",
|
|
3
|
-
"version": "2.
|
|
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
|
|
146
|
-
const
|
|
152
|
+
const section = journey.getSection(req.params.section);
|
|
153
|
+
const question = journey.getQuestionByParams(req.params);
|
|
147
154
|
|
|
148
|
-
if (!
|
|
155
|
+
if (!question || !section) {
|
|
149
156
|
return res.redirect(journey.taskListUrl);
|
|
150
157
|
}
|
|
151
158
|
|
|
152
|
-
const viewModel =
|
|
153
|
-
|
|
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
|
|
183
|
-
const
|
|
190
|
+
const section = journey.getSection(req.params.section);
|
|
191
|
+
const question = journey.getQuestionByParams(req.params);
|
|
184
192
|
|
|
185
|
-
if (!
|
|
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 =
|
|
199
|
+
const errorViewModel = question.checkForValidationErrors(req, section, journey);
|
|
192
200
|
if (errorViewModel) {
|
|
193
|
-
return
|
|
201
|
+
return question.renderAction(res, errorViewModel);
|
|
194
202
|
}
|
|
195
203
|
|
|
196
204
|
// save
|
|
197
|
-
const data = await
|
|
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 =
|
|
216
|
+
const saveViewModel = question.checkForSavingErrors(req, section, journey);
|
|
209
217
|
if (saveViewModel) {
|
|
210
|
-
return
|
|
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
|
|
224
|
+
return journey.redirectToNextQuestion(res, {
|
|
225
|
+
section: section.segment,
|
|
226
|
+
question: question.fieldName
|
|
227
|
+
});
|
|
217
228
|
} catch (err) {
|
|
218
|
-
const viewModel =
|
|
229
|
+
const viewModel = question.prepQuestionForRendering(section, journey, {
|
|
219
230
|
errorSummary: err.errorSummary ?? [{ text: err.toString(), href: '#' }]
|
|
220
231
|
});
|
|
221
|
-
return
|
|
232
|
+
return question.renderAction(res, viewModel);
|
|
222
233
|
}
|
|
223
234
|
};
|
|
224
235
|
}
|
package/src/controller.test.js
CHANGED
|
@@ -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.
|
|
305
|
-
mockJourney.
|
|
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.
|
|
318
|
-
mockJourney.
|
|
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.
|
|
337
|
-
mockJourney.
|
|
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.
|
|
371
|
-
mockJourney.
|
|
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.
|
|
422
|
-
mockJourney.
|
|
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.
|
|
469
|
-
mockJourney.
|
|
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.
|
|
502
|
-
mockJourney.
|
|
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.
|
|
537
|
-
mockJourney.
|
|
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();
|
package/src/journey/journey.js
CHANGED
|
@@ -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 {
|
|
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(
|
|
143
|
-
return this.#prependPathToUrl(this.baseUrl, `${
|
|
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 {
|
|
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
|
-
|
|
176
|
-
const section = this.getSection(
|
|
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,
|
|
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
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
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(
|
|
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 ===
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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('
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
});
|
package/src/lib/uuid.js
ADDED
|
@@ -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.
|
|
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
|
|
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: {
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
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
|
|
238
|
+
if (question.inputFields) {
|
|
204
239
|
for (const field of question.inputFields) {
|
|
205
240
|
if (question.fieldIsRequired(field.fieldName)) {
|
|
206
241
|
requiredQuestionCount++;
|
package/src/section.test.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
220
|
+
getQuestionByParams: function () {
|
|
221
221
|
return {
|
|
222
222
|
validators: [new AddressValidator()],
|
|
223
223
|
fieldName: 'field1'
|
package/test/questions.test.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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) {
|
package/test/utils/utils.js
CHANGED
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
|
+
}
|