@planning-inspectorate/dynamic-forms 2.3.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/components/manage-list/README.md +97 -0
- package/src/components/manage-list/answer-summary-list.njk +10 -0
- package/src/components/manage-list/index.njk +61 -0
- package/src/components/manage-list/manage-list-section.js +39 -0
- package/src/components/manage-list/manage-list-section.test.js +52 -0
- package/src/components/manage-list/question.js +147 -0
- package/src/components/manage-list/question.test.js +139 -0
- package/src/components/utils/nunjucks.js +20 -0
- package/src/controller.js +60 -20
- package/src/controller.test.js +110 -43
- package/src/index.js +3 -2
- package/src/journey/journey-types.d.ts +18 -0
- package/src/journey/journey.js +104 -40
- package/src/journey/journey.test.js +94 -23
- 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-props.d.ts +2 -1
- package/src/questions/question.js +67 -18
- package/src/questions/question.test.js +26 -3
- package/src/questions/questions.js +3 -1
- package/src/section-types.d.ts +18 -0
- package/src/section.js +67 -2
- package/src/section.test.js +176 -1
- package/src/validator/validator.js +1 -3
- package/src/validator/validator.test.js +7 -7
- package/test/journey.js +5 -0
- package/test/mock/journey.js +17 -0
- package/test/questions.js +30 -1
- package/test/questions.test.js +119 -48
- package/test/snapshots/check-your-answers-not-started.html +11 -0
- package/test/snapshots/check-your-answers-with-answers.html +11 -0
- package/test/snapshots/manage-list-answer-summary.html +14 -0
- package/test/snapshots/manage-list-render-questions.html +97 -0
- package/test/snapshots/manage-list-render.html +91 -0
- package/test/snapshots/travel-companions.html +19 -0
- package/test/snapshots/travel-companions_travel-companion-email.html +21 -0
- package/test/snapshots/travel-companions_travel-companion-name.html +21 -0
- package/test/utils/test-server.js +31 -9
- package/test/utils/utils.js +2 -1
- package/tsconfig.json +23 -0
package/src/controller.js
CHANGED
|
@@ -66,6 +66,14 @@ function buildSectionRowViewModel(key, value, action) {
|
|
|
66
66
|
};
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* @param {object} [viewData]
|
|
71
|
+
* @returns {import('express').Handler}
|
|
72
|
+
*/
|
|
73
|
+
export function buildList(viewData = {}) {
|
|
74
|
+
return (req, res) => list(req, res, viewData.pageCaption, viewData);
|
|
75
|
+
}
|
|
76
|
+
|
|
69
77
|
/**
|
|
70
78
|
* @param {import('express').Request} req
|
|
71
79
|
* @param {import('express').Response} res
|
|
@@ -135,22 +143,39 @@ export async function list(req, res, pageCaption, viewData) {
|
|
|
135
143
|
}
|
|
136
144
|
|
|
137
145
|
/**
|
|
146
|
+
* Render an individual question
|
|
147
|
+
*
|
|
138
148
|
* @type {import('express').Handler}
|
|
139
149
|
*/
|
|
140
150
|
export async function question(req, res) {
|
|
141
|
-
//render an individual question
|
|
142
|
-
const { section, question } = req.params;
|
|
143
151
|
const { journey } = res.locals;
|
|
144
152
|
|
|
145
|
-
const
|
|
146
|
-
const
|
|
153
|
+
const section = journey.getSection(req.params.section);
|
|
154
|
+
const question = journey.getQuestionByParams(req.params);
|
|
147
155
|
|
|
148
|
-
if (!
|
|
156
|
+
if (!question || !section) {
|
|
149
157
|
return res.redirect(journey.taskListUrl);
|
|
150
158
|
}
|
|
151
159
|
|
|
152
|
-
|
|
153
|
-
|
|
160
|
+
let manageListQuestion;
|
|
161
|
+
if (question.isInManageListSection) {
|
|
162
|
+
// find parent question for the manage list
|
|
163
|
+
manageListQuestion = journey.getQuestionByParams({ section: req.params.section, question: req.params.question });
|
|
164
|
+
if (!manageListQuestion) {
|
|
165
|
+
return res.redirect(journey.taskListUrl);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const viewModel = question.toViewModel({
|
|
170
|
+
params: req.params,
|
|
171
|
+
manageListQuestion,
|
|
172
|
+
section,
|
|
173
|
+
journey,
|
|
174
|
+
customViewData: {
|
|
175
|
+
originalUrl: req.originalUrl
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
return question.renderAction(res, viewModel);
|
|
154
179
|
}
|
|
155
180
|
|
|
156
181
|
/**
|
|
@@ -173,28 +198,36 @@ export async function question(req, res) {
|
|
|
173
198
|
*/
|
|
174
199
|
export function buildSave(saveData, redirectToTaskListOnSuccess) {
|
|
175
200
|
return async (req, res) => {
|
|
176
|
-
const { section, question } = req.params;
|
|
177
201
|
/** @type {import('./journey/journey.js').Journey} */
|
|
178
202
|
const journey = res.locals.journey;
|
|
179
203
|
/** @type {import('./journey/journey-response.js').JourneyResponse} */
|
|
180
204
|
const journeyResponse = res.locals.journeyResponse;
|
|
181
205
|
|
|
182
|
-
const
|
|
183
|
-
const
|
|
206
|
+
const section = journey.getSection(req.params.section);
|
|
207
|
+
const question = journey.getQuestionByParams(req.params);
|
|
184
208
|
|
|
185
|
-
if (!
|
|
209
|
+
if (!question || !section) {
|
|
186
210
|
return res.redirect(journey.taskListUrl);
|
|
187
211
|
}
|
|
188
212
|
|
|
213
|
+
let manageListQuestion;
|
|
214
|
+
if (question.isInManageListSection) {
|
|
215
|
+
// find parent question for the manage list
|
|
216
|
+
manageListQuestion = journey.getQuestionByParams({ section: req.params.section, question: req.params.question });
|
|
217
|
+
if (!manageListQuestion) {
|
|
218
|
+
return res.redirect(journey.taskListUrl);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
189
222
|
try {
|
|
190
223
|
// check for validation errors
|
|
191
|
-
const errorViewModel =
|
|
224
|
+
const errorViewModel = question.checkForValidationErrors(req, section, journey, manageListQuestion);
|
|
192
225
|
if (errorViewModel) {
|
|
193
|
-
return
|
|
226
|
+
return question.renderAction(res, errorViewModel);
|
|
194
227
|
}
|
|
195
228
|
|
|
196
229
|
// save
|
|
197
|
-
const data = await
|
|
230
|
+
const data = await question.getDataToSave(req, journeyResponse);
|
|
198
231
|
|
|
199
232
|
await saveData({
|
|
200
233
|
req,
|
|
@@ -205,20 +238,27 @@ export function buildSave(saveData, redirectToTaskListOnSuccess) {
|
|
|
205
238
|
});
|
|
206
239
|
|
|
207
240
|
// check for saving errors
|
|
208
|
-
const saveViewModel =
|
|
241
|
+
const saveViewModel = question.checkForSavingErrors(req, section, journey);
|
|
209
242
|
if (saveViewModel) {
|
|
210
|
-
return
|
|
243
|
+
return question.renderAction(res, saveViewModel);
|
|
211
244
|
}
|
|
212
245
|
if (redirectToTaskListOnSuccess) {
|
|
213
246
|
return res.redirect(journey.taskListUrl);
|
|
214
247
|
}
|
|
215
248
|
// move to the next question
|
|
216
|
-
return
|
|
249
|
+
return journey.redirectToNextQuestion(res, req.params, manageListQuestion);
|
|
217
250
|
} catch (err) {
|
|
218
|
-
const viewModel =
|
|
219
|
-
|
|
251
|
+
const viewModel = question.toViewModel({
|
|
252
|
+
params: req.params,
|
|
253
|
+
manageListQuestion,
|
|
254
|
+
section,
|
|
255
|
+
journey,
|
|
256
|
+
customViewData: {
|
|
257
|
+
originalUrl: req.originalUrl,
|
|
258
|
+
errorSummary: err.errorSummary ?? [{ text: err.toString(), href: '#' }]
|
|
259
|
+
}
|
|
220
260
|
});
|
|
221
|
-
return
|
|
261
|
+
return question.renderAction(res, viewModel);
|
|
222
262
|
}
|
|
223
263
|
};
|
|
224
264
|
}
|
package/src/controller.test.js
CHANGED
|
@@ -178,10 +178,10 @@ const sampleQuestionObj = {
|
|
|
178
178
|
getDataToSave: mock.fn(),
|
|
179
179
|
checkForValidationErrors: mock.fn(),
|
|
180
180
|
checkForSavingErrors: mock.fn(),
|
|
181
|
+
toViewModel: mock.fn(),
|
|
181
182
|
prepQuestionForRendering: mock.fn(),
|
|
182
183
|
formatAnswerForSummary: mock.fn(() => [mockAnswer]),
|
|
183
|
-
viewFolder: 'sampleType'
|
|
184
|
-
handleNextQuestion: mock.fn()
|
|
184
|
+
viewFolder: 'sampleType'
|
|
185
185
|
};
|
|
186
186
|
|
|
187
187
|
const mockSection = {
|
|
@@ -301,8 +301,8 @@ describe('dynamic-form/controller', () => {
|
|
|
301
301
|
|
|
302
302
|
describe('question', () => {
|
|
303
303
|
it('should redirect if question is not found', async () => {
|
|
304
|
-
mockJourney.
|
|
305
|
-
mockJourney.
|
|
304
|
+
mockJourney.getQuestionByParams = mock.fn();
|
|
305
|
+
mockJourney.getQuestionByParams.mock.mockImplementationOnce(() => null);
|
|
306
306
|
|
|
307
307
|
await question(req, res);
|
|
308
308
|
|
|
@@ -314,8 +314,8 @@ describe('dynamic-form/controller', () => {
|
|
|
314
314
|
mockJourney.getSection.mock.mockImplementationOnce(() => {
|
|
315
315
|
return {};
|
|
316
316
|
});
|
|
317
|
-
mockJourney.
|
|
318
|
-
mockJourney.
|
|
317
|
+
mockJourney.getQuestionByParams = mock.fn(() => sampleQuestionObj);
|
|
318
|
+
mockJourney.getQuestionByParams.mock.mockImplementationOnce(() => sampleQuestionObj);
|
|
319
319
|
sampleQuestionObj.renderAction = mock.fn();
|
|
320
320
|
|
|
321
321
|
await question(req, res);
|
|
@@ -326,15 +326,16 @@ describe('dynamic-form/controller', () => {
|
|
|
326
326
|
|
|
327
327
|
it('should render the question template', async () => {
|
|
328
328
|
sampleQuestionObj.renderAction.mock.resetCalls();
|
|
329
|
+
sampleQuestionObj.toViewModel.mock.resetCalls();
|
|
329
330
|
req.params.referenceId = mockRef;
|
|
330
331
|
const mockAnswer = 'sampleAnswer';
|
|
331
332
|
const mockBackLink = 'back';
|
|
332
333
|
const mockQuestionRendering = 'test';
|
|
333
334
|
|
|
334
|
-
sampleQuestionObj.
|
|
335
|
+
sampleQuestionObj.toViewModel.mock.mockImplementationOnce(() => mockQuestionRendering);
|
|
335
336
|
|
|
336
|
-
mockJourney.
|
|
337
|
-
mockJourney.
|
|
337
|
+
mockJourney.getQuestionByParams = mock.fn();
|
|
338
|
+
mockJourney.getQuestionByParams.mock.mockImplementationOnce(() => sampleQuestionObj);
|
|
338
339
|
mockJourney.response.answers.sampleFieldName = mockAnswer;
|
|
339
340
|
mockJourney.getNextQuestionUrl = mock.fn(() => mockBackLink);
|
|
340
341
|
mockJourney.getSection = mock.fn(() => mockSection);
|
|
@@ -343,6 +344,11 @@ describe('dynamic-form/controller', () => {
|
|
|
343
344
|
|
|
344
345
|
assert.strictEqual(sampleQuestionObj.renderAction.mock.callCount(), 1);
|
|
345
346
|
assert.deepStrictEqual(sampleQuestionObj.renderAction.mock.calls[0].arguments, [res, mockQuestionRendering]);
|
|
347
|
+
assert.strictEqual(sampleQuestionObj.toViewModel.mock.callCount(), 1);
|
|
348
|
+
const args = sampleQuestionObj.toViewModel.mock.calls[0].arguments[0];
|
|
349
|
+
assert.ok(args);
|
|
350
|
+
assert.ok(args?.customViewData?.originalUrl);
|
|
351
|
+
assert.strictEqual(args?.customViewData?.originalUrl, req.originalUrl);
|
|
346
352
|
});
|
|
347
353
|
});
|
|
348
354
|
|
|
@@ -367,8 +373,8 @@ describe('dynamic-form/controller', () => {
|
|
|
367
373
|
notSampleFieldName: 'do not send this'
|
|
368
374
|
};
|
|
369
375
|
|
|
370
|
-
mockJourney.
|
|
371
|
-
mockJourney.
|
|
376
|
+
mockJourney.getQuestionByParams = mock.fn();
|
|
377
|
+
mockJourney.getQuestionByParams.mock.mockImplementationOnce(() => sampleQuestionObj);
|
|
372
378
|
|
|
373
379
|
const saveData = mock.fn();
|
|
374
380
|
await buildSave(saveData)(req, res, journeyId);
|
|
@@ -387,7 +393,9 @@ describe('dynamic-form/controller', () => {
|
|
|
387
393
|
const mocks = [sampleQuestionObj.checkForValidationErrors.mock, sampleQuestionObj.checkForSavingErrors.mock];
|
|
388
394
|
for (const mockFn of mocks) {
|
|
389
395
|
assert.strictEqual(mockFn.callCount(), 1);
|
|
390
|
-
assert.deepStrictEqual(mockFn.calls[0].arguments
|
|
396
|
+
assert.deepStrictEqual(mockFn.calls[0].arguments[0], req);
|
|
397
|
+
assert.deepStrictEqual(mockFn.calls[0].arguments[1], sections[0]);
|
|
398
|
+
assert.deepStrictEqual(mockFn.calls[0].arguments[2], mockJourney);
|
|
391
399
|
}
|
|
392
400
|
});
|
|
393
401
|
|
|
@@ -397,7 +405,7 @@ describe('dynamic-form/controller', () => {
|
|
|
397
405
|
const sampleQuestionObjWithActions = {
|
|
398
406
|
...sampleQuestionObj,
|
|
399
407
|
saveAction: mock.fn(),
|
|
400
|
-
|
|
408
|
+
toViewModel: mock.fn(() => expectedViewModel),
|
|
401
409
|
renderAction: mock.fn()
|
|
402
410
|
};
|
|
403
411
|
|
|
@@ -418,8 +426,8 @@ describe('dynamic-form/controller', () => {
|
|
|
418
426
|
notSampleFieldName: 'do not send this'
|
|
419
427
|
};
|
|
420
428
|
|
|
421
|
-
mockJourney.
|
|
422
|
-
mockJourney.
|
|
429
|
+
mockJourney.getQuestionByParams = mock.fn();
|
|
430
|
+
mockJourney.getQuestionByParams.mock.mockImplementationOnce(() => sampleQuestionObjWithActions);
|
|
423
431
|
|
|
424
432
|
const saveData = mock.fn(() => {
|
|
425
433
|
throw new Error('Expected error message');
|
|
@@ -427,20 +435,25 @@ describe('dynamic-form/controller', () => {
|
|
|
427
435
|
await buildSave(saveData)(req, res, journeyId);
|
|
428
436
|
|
|
429
437
|
assert.strictEqual(saveData.mock.callCount(), 1);
|
|
430
|
-
assert.deepStrictEqual(
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
438
|
+
assert.deepStrictEqual(
|
|
439
|
+
saveData.mock.calls[0].arguments,
|
|
440
|
+
[
|
|
441
|
+
{
|
|
442
|
+
req,
|
|
443
|
+
res,
|
|
444
|
+
journeyId: journeyParams.journeyId,
|
|
445
|
+
referenceId: journeyParams.referenceId,
|
|
446
|
+
data: undefined
|
|
447
|
+
}
|
|
448
|
+
],
|
|
449
|
+
'save data args'
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
assert.deepStrictEqual(
|
|
453
|
+
sampleQuestionObjWithActions.renderAction.mock.calls[0].arguments,
|
|
454
|
+
[res, expectedViewModel],
|
|
455
|
+
'render args'
|
|
456
|
+
);
|
|
444
457
|
});
|
|
445
458
|
|
|
446
459
|
it('should handle validation errors', async () => {
|
|
@@ -465,8 +478,8 @@ describe('dynamic-form/controller', () => {
|
|
|
465
478
|
answers: {}
|
|
466
479
|
};
|
|
467
480
|
|
|
468
|
-
mockJourney.
|
|
469
|
-
mockJourney.
|
|
481
|
+
mockJourney.getQuestionByParams = mock.fn();
|
|
482
|
+
mockJourney.getQuestionByParams.mock.mockImplementationOnce(() => sampleQuestionObjWithActions);
|
|
470
483
|
|
|
471
484
|
const saveData = mock.fn();
|
|
472
485
|
await buildSave(saveData)(req, res, journeyId);
|
|
@@ -480,10 +493,7 @@ describe('dynamic-form/controller', () => {
|
|
|
480
493
|
const journeyId = 'has-questionnaire';
|
|
481
494
|
const expectedUrl = 'redirect-url';
|
|
482
495
|
const sampleQuestionObjWithActions = {
|
|
483
|
-
...sampleQuestionObj
|
|
484
|
-
handleNextQuestion: mock.fn((res, journey) => {
|
|
485
|
-
res.redirect(journey.getNextQuestionUrl());
|
|
486
|
-
})
|
|
496
|
+
...sampleQuestionObj
|
|
487
497
|
};
|
|
488
498
|
|
|
489
499
|
req.params = {
|
|
@@ -498,8 +508,8 @@ describe('dynamic-form/controller', () => {
|
|
|
498
508
|
};
|
|
499
509
|
|
|
500
510
|
mockJourney.getSection = mock.fn(() => ({}));
|
|
501
|
-
mockJourney.
|
|
502
|
-
mockJourney.
|
|
511
|
+
mockJourney.getQuestionByParams = mock.fn();
|
|
512
|
+
mockJourney.getQuestionByParams.mock.mockImplementationOnce(() => sampleQuestionObjWithActions);
|
|
503
513
|
mockJourney.getNextQuestionUrl = mock.fn(() => expectedUrl);
|
|
504
514
|
|
|
505
515
|
const saveData = mock.fn();
|
|
@@ -515,10 +525,7 @@ describe('dynamic-form/controller', () => {
|
|
|
515
525
|
const journeyId = 'has-questionnaire';
|
|
516
526
|
const expectedUrl = 'redirect-url';
|
|
517
527
|
const sampleQuestionObjWithActions = {
|
|
518
|
-
...sampleQuestionObj
|
|
519
|
-
handleNextQuestion: mock.fn((res, journey) => {
|
|
520
|
-
res.redirect(journey.getNextQuestionUrl());
|
|
521
|
-
})
|
|
528
|
+
...sampleQuestionObj
|
|
522
529
|
};
|
|
523
530
|
|
|
524
531
|
req.params = {
|
|
@@ -533,8 +540,8 @@ describe('dynamic-form/controller', () => {
|
|
|
533
540
|
};
|
|
534
541
|
|
|
535
542
|
mockJourney.getSection = mock.fn(() => ({}));
|
|
536
|
-
mockJourney.
|
|
537
|
-
mockJourney.
|
|
543
|
+
mockJourney.getQuestionByParams = mock.fn();
|
|
544
|
+
mockJourney.getQuestionByParams.mock.mockImplementationOnce(() => sampleQuestionObjWithActions);
|
|
538
545
|
mockJourney.getNextQuestionUrl = mock.fn(() => expectedUrl);
|
|
539
546
|
|
|
540
547
|
const saveData = mock.fn();
|
|
@@ -545,6 +552,66 @@ describe('dynamic-form/controller', () => {
|
|
|
545
552
|
assert.match(res.redirect.mock.calls[0].arguments[0], /\/task-list$/);
|
|
546
553
|
assert.strictEqual(mockJourney.getNextQuestionUrl.mock.callCount(), 0);
|
|
547
554
|
});
|
|
555
|
+
|
|
556
|
+
describe('manageListQuestions', () => {
|
|
557
|
+
const setupJourney = () => {
|
|
558
|
+
const q = {
|
|
559
|
+
fieldName: 'question-2',
|
|
560
|
+
get isInManageListSection() {
|
|
561
|
+
return true;
|
|
562
|
+
},
|
|
563
|
+
checkForValidationErrors: mock.fn(),
|
|
564
|
+
getDataToSave: mock.fn(),
|
|
565
|
+
checkForSavingErrors: mock.fn()
|
|
566
|
+
};
|
|
567
|
+
const manageListQ = {
|
|
568
|
+
get isManageListQuestion() {
|
|
569
|
+
return true;
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
const req = {
|
|
573
|
+
params: {
|
|
574
|
+
section: 'section-1',
|
|
575
|
+
question: 'question-1',
|
|
576
|
+
manageListAction: 'add',
|
|
577
|
+
manageListItemId: 'item-id-1',
|
|
578
|
+
manageListQuestion: 'question-6'
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
const journey = {
|
|
582
|
+
getSection: mock.fn(() => ({ segment: 'section-1' })),
|
|
583
|
+
getQuestionByParams: mock.fn(),
|
|
584
|
+
redirectToNextQuestion: mock.fn()
|
|
585
|
+
};
|
|
586
|
+
const res = {
|
|
587
|
+
locals: {
|
|
588
|
+
journey,
|
|
589
|
+
journeyResponse: { answers: {} }
|
|
590
|
+
},
|
|
591
|
+
redirect: mock.fn()
|
|
592
|
+
};
|
|
593
|
+
journey.getQuestionByParams.mock.mockImplementationOnce(() => q, 0);
|
|
594
|
+
journey.getQuestionByParams.mock.mockImplementationOnce(() => manageListQ, 1);
|
|
595
|
+
return { journey, manageListQ, req, res, q };
|
|
596
|
+
};
|
|
597
|
+
it('should find the parent manage list question', async () => {
|
|
598
|
+
const { journey, manageListQ, req, res } = setupJourney();
|
|
599
|
+
const saveData = mock.fn();
|
|
600
|
+
await buildSave(saveData)(req, res, 'journey-1');
|
|
601
|
+
assert.strictEqual(journey.getSection.mock.callCount(), 1);
|
|
602
|
+
assert.strictEqual(journey.getQuestionByParams.mock.callCount(), 2);
|
|
603
|
+
assert.strictEqual(journey.redirectToNextQuestion.mock.callCount(), 1);
|
|
604
|
+
assert.strictEqual(journey.redirectToNextQuestion.mock.calls[0].arguments[2], manageListQ);
|
|
605
|
+
});
|
|
606
|
+
it('should preserve request route params', async () => {
|
|
607
|
+
const { journey, req, res } = setupJourney();
|
|
608
|
+
const saveData = mock.fn();
|
|
609
|
+
await buildSave(saveData)(req, res, 'journey-1');
|
|
610
|
+
assert.strictEqual(journey.redirectToNextQuestion.mock.callCount(), 1);
|
|
611
|
+
const params = journey.redirectToNextQuestion.mock.calls[0].arguments[1];
|
|
612
|
+
assert.deepStrictEqual(params, req.params);
|
|
613
|
+
});
|
|
614
|
+
});
|
|
548
615
|
});
|
|
549
616
|
});
|
|
550
617
|
|
package/src/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* @type {Readonly<{CHECKBOX: QuestionTypes, BOOLEAN: QuestionTypes, RADIO: QuestionTypes, DATE: QuestionTypes, DATE_PERIOD: QuestionTypes, TEXT_ENTRY: QuestionTypes, TEXT_ENTRY_REDACT: QuestionTypes, SELECT: QuestionTypes, SINGLE_LINE_INPUT: QuestionTypes, MULTI_FIELD_INPUT: QuestionTypes, NUMBER: QuestionTypes, ADDRESS: QuestionTypes, UNIT_OPTION: QuestionTypes, EMAIL: QuestionTypes}>}
|
|
6
|
+
* @type {Readonly<{CHECKBOX: QuestionTypes, BOOLEAN: QuestionTypes, RADIO: QuestionTypes, DATE: QuestionTypes, DATE_PERIOD: QuestionTypes, TEXT_ENTRY: QuestionTypes, TEXT_ENTRY_REDACT: QuestionTypes, SELECT: QuestionTypes, SINGLE_LINE_INPUT: QuestionTypes, MULTI_FIELD_INPUT: QuestionTypes, NUMBER: QuestionTypes, ADDRESS: QuestionTypes, UNIT_OPTION: QuestionTypes, EMAIL: QuestionTypes, MANAGE_LIST: QuestionTypes}>}
|
|
7
7
|
*/
|
|
8
8
|
export const COMPONENT_TYPES = Object.freeze({
|
|
9
9
|
CHECKBOX: 'checkbox',
|
|
@@ -20,5 +20,6 @@ export const COMPONENT_TYPES = Object.freeze({
|
|
|
20
20
|
NUMBER: 'number',
|
|
21
21
|
ADDRESS: 'site-address',
|
|
22
22
|
UNIT_OPTION: 'unit-option',
|
|
23
|
-
EMAIL: 'email'
|
|
23
|
+
EMAIL: 'email',
|
|
24
|
+
MANAGE_LIST: 'manage-list'
|
|
24
25
|
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Express route parameters used for journey routes
|
|
3
|
+
*/
|
|
4
|
+
export type RouteParams = CoreRouteParams & ManageListRouteParams;
|
|
5
|
+
|
|
6
|
+
export type CoreRouteParams = {
|
|
7
|
+
section: string;
|
|
8
|
+
question: string;
|
|
9
|
+
[key: string]: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type ManageListRouteParams =
|
|
13
|
+
| {
|
|
14
|
+
manageListAction: string;
|
|
15
|
+
manageListItemId: string;
|
|
16
|
+
manageListQuestion: string;
|
|
17
|
+
}
|
|
18
|
+
| {};
|
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,15 @@ 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
|
-
|
|
142
|
+
#buildQuestionUrl(params) {
|
|
143
|
+
const parts = [params.section, params.question];
|
|
144
|
+
if (params.manageListAction) {
|
|
145
|
+
parts.push(params.manageListAction, params.manageListItemId, params.manageListQuestion);
|
|
146
|
+
}
|
|
147
|
+
return this.#prependPathToUrl(this.baseUrl, parts.join('/'));
|
|
144
148
|
}
|
|
145
149
|
|
|
146
150
|
/**
|
|
@@ -158,39 +162,58 @@ export class Journey {
|
|
|
158
162
|
* Get question within a section
|
|
159
163
|
* @param {Section} section
|
|
160
164
|
* @param {string} questionSegment
|
|
165
|
+
* @param {{action: string, itemId: string, question: string}} [manageListParams]
|
|
161
166
|
* @returns {Question | undefined} question if it belongs in the given section
|
|
162
167
|
*/
|
|
163
|
-
#getQuestion(section, questionSegment) {
|
|
164
|
-
|
|
165
|
-
return q.fieldName ===
|
|
166
|
-
}
|
|
168
|
+
#getQuestion(section, questionSegment, manageListParams) {
|
|
169
|
+
const matchQuestion = (q, toMatch) => {
|
|
170
|
+
return q.fieldName === toMatch || q.url === toMatch;
|
|
171
|
+
};
|
|
172
|
+
const question = section?.questions.find((q) => matchQuestion(q, questionSegment));
|
|
173
|
+
if (!question) {
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
if (manageListParams && question.isManageListQuestion) {
|
|
177
|
+
return question.section.questions.find((q) => matchQuestion(q, manageListParams.question));
|
|
178
|
+
}
|
|
179
|
+
return question;
|
|
167
180
|
}
|
|
168
181
|
|
|
169
182
|
/**
|
|
170
183
|
* gets a question from the object's sections based on a section + question names
|
|
171
|
-
* @param {
|
|
172
|
-
* @param {string} questionSegment fieldname of the question to lookup
|
|
184
|
+
* @param {import('./journey-types.d.ts').RouteParams} params
|
|
173
185
|
* @returns {Question | undefined} question found by lookup
|
|
174
186
|
*/
|
|
175
|
-
|
|
176
|
-
const section = this.getSection(
|
|
187
|
+
getQuestionByParams(params) {
|
|
188
|
+
const section = this.getSection(params.section);
|
|
177
189
|
|
|
178
190
|
if (!section) {
|
|
179
191
|
return undefined;
|
|
180
192
|
}
|
|
181
|
-
|
|
182
|
-
|
|
193
|
+
let manageListParams;
|
|
194
|
+
if (params.manageListAction && params.manageListItemId && params.manageListQuestion) {
|
|
195
|
+
manageListParams = {
|
|
196
|
+
action: params.manageListAction,
|
|
197
|
+
itemId: params.manageListItemId,
|
|
198
|
+
question: params.manageListQuestion
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
return this.#getQuestion(section, params.question, manageListParams);
|
|
183
202
|
}
|
|
184
203
|
|
|
185
204
|
/**
|
|
186
205
|
* Get the back link for the journey - e.g. the previous question
|
|
187
206
|
*
|
|
188
|
-
* @param {
|
|
189
|
-
* @param {
|
|
207
|
+
* @param {Object} options
|
|
208
|
+
* @param {import('./journey-types.d.ts').RouteParams} options.params
|
|
209
|
+
* @param {import('#src/components/manage-list/question.js')} [options.manageListQuestion]
|
|
190
210
|
* @returns {string|null} url for the next question, or null if unmatched
|
|
191
211
|
*/
|
|
192
|
-
getBackLink(
|
|
193
|
-
const previousQuestion = this.getNextQuestionUrl(
|
|
212
|
+
getBackLink({ params, manageListQuestion }) {
|
|
213
|
+
const previousQuestion = this.getNextQuestionUrl(params, {
|
|
214
|
+
manageListQuestion,
|
|
215
|
+
reverse: true
|
|
216
|
+
});
|
|
194
217
|
if (!previousQuestion) {
|
|
195
218
|
return this.initialBackLink;
|
|
196
219
|
}
|
|
@@ -198,15 +221,32 @@ export class Journey {
|
|
|
198
221
|
}
|
|
199
222
|
|
|
200
223
|
/**
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
* @param {
|
|
224
|
+
* Handles redirect to the next question in the journey
|
|
225
|
+
* Used after question post/saving
|
|
226
|
+
*
|
|
227
|
+
* @param {import('express').Response} res
|
|
228
|
+
* @param {import('./journey-types.d.ts').RouteParams} params
|
|
229
|
+
* @param {import('#src/components/manage-list/question.js')} [manageListQuestion]
|
|
230
|
+
* @returns {void}
|
|
231
|
+
*/
|
|
232
|
+
redirectToNextQuestion(res, params, manageListQuestion) {
|
|
233
|
+
const next = this.getNextQuestionUrl(params, { manageListQuestion }) ?? this.taskListUrl;
|
|
234
|
+
return res.redirect(next);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Get url for the next question in the journey
|
|
239
|
+
*
|
|
240
|
+
* @param {import('./journey-types.d.ts').RouteParams} params
|
|
241
|
+
* @param {Object} options
|
|
242
|
+
* @param {boolean} [options.reverse] - if passed in this will get the previous question
|
|
243
|
+
* @param {import('#src/components/manage-list/question.js')} [options.manageListQuestion]
|
|
205
244
|
* @returns {string|null} url for the next question, or null if unmatched
|
|
206
245
|
*/
|
|
207
|
-
getNextQuestionUrl(
|
|
246
|
+
getNextQuestionUrl(params, { reverse = false, manageListQuestion } = {}) {
|
|
208
247
|
const numberOfSections = this.sections.length;
|
|
209
248
|
const sectionsStart = reverse ? numberOfSections - 1 : 0;
|
|
249
|
+
const questionFieldName = manageListQuestion ? params.manageListQuestion : params.question;
|
|
210
250
|
|
|
211
251
|
let currentSectionIndex;
|
|
212
252
|
let foundSection = false;
|
|
@@ -214,9 +254,8 @@ export class Journey {
|
|
|
214
254
|
|
|
215
255
|
for (let i = sectionsStart; reverse ? i >= 0 : i < numberOfSections; reverse ? i-- : i++) {
|
|
216
256
|
const currentSection = this.sections[i];
|
|
217
|
-
const numberOfQuestions = currentSection.questions.length;
|
|
218
257
|
|
|
219
|
-
if (currentSection.segment ===
|
|
258
|
+
if (currentSection.segment === params.section) {
|
|
220
259
|
foundSection = true;
|
|
221
260
|
currentSectionIndex = i;
|
|
222
261
|
}
|
|
@@ -225,17 +264,39 @@ export class Journey {
|
|
|
225
264
|
if (this.returnToListing && i !== currentSectionIndex) {
|
|
226
265
|
return null;
|
|
227
266
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
267
|
+
const question = currentSection.getNextQuestion({
|
|
268
|
+
questionFieldName,
|
|
269
|
+
response: this.response,
|
|
270
|
+
manageListQuestion,
|
|
271
|
+
takeNextQuestion,
|
|
272
|
+
reverse
|
|
273
|
+
});
|
|
274
|
+
if (question === END_OF_SECTION) {
|
|
275
|
+
takeNextQuestion = true; // get the first question from the following section
|
|
276
|
+
} else if (question) {
|
|
277
|
+
/**
|
|
278
|
+
* if this is a regular question, then only section and question params are required
|
|
279
|
+
* don't include other params which may be set (e.g. manageList* params), as we may now be
|
|
280
|
+
* redirecting to a non-manage list question, having previously been on a manage list question
|
|
281
|
+
*
|
|
282
|
+
* @type {import('./journey-types.d.ts').RouteParams}
|
|
283
|
+
*/
|
|
284
|
+
let newParams = {
|
|
285
|
+
section: currentSection.segment,
|
|
286
|
+
question: question.url || question.fieldName
|
|
287
|
+
};
|
|
288
|
+
if (question.isInManageListSection && manageListQuestion) {
|
|
289
|
+
// if this is in a manage list section, then the manage list params will be set
|
|
290
|
+
// we need to retain the manageListAction and manageListItemId params
|
|
291
|
+
newParams = {
|
|
292
|
+
...params,
|
|
293
|
+
// the question param is for the 'parent' manageListQuestion
|
|
294
|
+
question: manageListQuestion.url,
|
|
295
|
+
// the manageListQuestion param is for the next question
|
|
296
|
+
manageListQuestion: question.url || question.fieldName
|
|
297
|
+
};
|
|
238
298
|
}
|
|
299
|
+
return this.#buildQuestionUrl(newParams);
|
|
239
300
|
}
|
|
240
301
|
}
|
|
241
302
|
}
|
|
@@ -264,10 +325,10 @@ export class Journey {
|
|
|
264
325
|
return unmatchedUrl;
|
|
265
326
|
}
|
|
266
327
|
|
|
267
|
-
return this.#buildQuestionUrl(
|
|
268
|
-
matchingSection.segment,
|
|
269
|
-
|
|
270
|
-
);
|
|
328
|
+
return this.#buildQuestionUrl({
|
|
329
|
+
section: matchingSection.segment,
|
|
330
|
+
question: matchingQuestion.url || matchingQuestion.fieldName
|
|
331
|
+
});
|
|
271
332
|
};
|
|
272
333
|
|
|
273
334
|
/**
|
|
@@ -303,7 +364,10 @@ export class Journey {
|
|
|
303
364
|
|
|
304
365
|
const questionUrl = matchingQuestion.url ?? matchingQuestion.fieldName;
|
|
305
366
|
|
|
306
|
-
return this.#buildQuestionUrl(
|
|
367
|
+
return this.#buildQuestionUrl({
|
|
368
|
+
section: matchingSection.segment,
|
|
369
|
+
question: questionUrl + addition
|
|
370
|
+
});
|
|
307
371
|
};
|
|
308
372
|
|
|
309
373
|
/**
|