@planning-inspectorate/dynamic-forms 3.2.0 → 3.3.1
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/README.md +9 -9
- package/package.json +1 -1
- package/src/components/address/question.js +2 -4
- package/src/components/boolean/question.js +6 -11
- package/src/components/date/question.js +5 -11
- package/src/components/date-period/question.js +5 -9
- package/src/components/date-time/question.js +5 -8
- package/src/components/date-time/question.test.js +3 -24
- package/src/components/manage-list/README.md +21 -2
- package/src/components/manage-list/index.njk +3 -3
- package/src/components/manage-list/question.js +24 -15
- package/src/components/manage-list/question.test.js +38 -1
- package/src/components/manage-list/utils.js +35 -0
- package/src/components/manage-list/utils.test.js +80 -0
- package/src/components/multi-field-input/question.js +7 -11
- package/src/components/multi-field-input/question.test.js +29 -1
- package/src/components/text-entry-redact/question.js +3 -4
- package/src/components/unit-option-entry/question.js +6 -13
- package/src/components/utils/question-has-answer.js +45 -11
- package/src/components/utils/question-utils.js +3 -37
- package/src/controller.js +11 -0
- package/src/controller.test.js +13 -1
- package/src/journey/journey.js +1 -0
- package/src/lib/utils.js +11 -0
- package/src/lib/utils.test.js +19 -0
- package/src/lib/uuid.test.js +0 -12
- package/src/questions/options-question.js +6 -13
- package/src/questions/question.js +17 -31
- package/src/questions/question.test.js +5 -19
- package/src/section-types.d.ts +2 -0
- package/src/section.js +5 -1
- package/src/section.test.js +7 -3
- package/test/check-your-answers.test.js +1 -1
- package/test/mock/uuid.js +13 -0
- package/test/questions.test.js +3 -200
- package/test/utils/question-test-utils.js +200 -0
- package/src/components/macros/answer-list.njk +0 -29
- package/src/middleware/check-not-submitted.js +0 -13
- package/src/middleware/check-not-submitted.test.js +0 -118
- package/src/middleware/dynamic-req-files-to-req-body-files.js +0 -46
- package/src/middleware/dynamic-req-files-to-req-body-files.test.js +0 -221
- package/src/validator/required-file-upload-validator.js +0 -38
- package/src/validator/required-file-upload-validator.test.js +0 -141
- package/src/validator/schemas/required-file-upload-schema.js +0 -35
|
@@ -60,10 +60,9 @@ export default class TextEntryRedactQuestion extends Question {
|
|
|
60
60
|
async getDataToSave(req, journeyResponse) {
|
|
61
61
|
if (this.useRedactedFieldNameForSave) {
|
|
62
62
|
const fieldName = this.fieldName + 'Redacted';
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
return responseToSave;
|
|
63
|
+
const answers = {};
|
|
64
|
+
answers[fieldName] = req.body[this.fieldName];
|
|
65
|
+
return { answers };
|
|
67
66
|
}
|
|
68
67
|
return super.getDataToSave(req, journeyResponse);
|
|
69
68
|
}
|
|
@@ -127,17 +127,13 @@ export default class UnitOptionEntryQuestion extends Question {
|
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
/**
|
|
130
|
-
*
|
|
131
|
-
* side effect: modifies journeyResponse with the new answers
|
|
130
|
+
* Get the data to save from the request, returns an object of answers
|
|
132
131
|
* @param {import('express').Request} req
|
|
133
132
|
* @param {JourneyResponse} journeyResponse - current journey response, modified with the new answers
|
|
134
133
|
* @returns {Promise<{ answers: Record<string, unknown> }>}
|
|
135
|
-
*/
|
|
134
|
+
*/ //eslint-disable-next-line no-unused-vars -- journeyResponse kept for other questions to use
|
|
136
135
|
async getDataToSave(req, journeyResponse) {
|
|
137
|
-
|
|
138
|
-
* @type {{ answers: Record<string, unknown> }}
|
|
139
|
-
*/
|
|
140
|
-
let responseToSave = { answers: {} };
|
|
136
|
+
const answers = {};
|
|
141
137
|
|
|
142
138
|
/** @type {string[]} */
|
|
143
139
|
const fields = Array.isArray(req.body[this.fieldName]) ? req.body[this.fieldName] : [req.body[this.fieldName]];
|
|
@@ -150,8 +146,7 @@ export default class UnitOptionEntryQuestion extends Question {
|
|
|
150
146
|
if (!selectedOptions.length)
|
|
151
147
|
throw new Error(`User submitted option(s) did not correlate with valid answers to ${this.fieldName} question`);
|
|
152
148
|
|
|
153
|
-
|
|
154
|
-
journeyResponse.answers[this.fieldName] = fieldValues;
|
|
149
|
+
answers[this.fieldName] = fieldValues.join(this.optionJoinString);
|
|
155
150
|
|
|
156
151
|
this.options.forEach((option) => {
|
|
157
152
|
if (!option.conditional) return;
|
|
@@ -162,13 +157,11 @@ export default class UnitOptionEntryQuestion extends Question {
|
|
|
162
157
|
if (optionIsSelectedOption) {
|
|
163
158
|
if (conditionalIsJustHTML(option.conditional)) return;
|
|
164
159
|
const conversionFactor = option.conditional.conversionFactor || 1;
|
|
165
|
-
|
|
166
|
-
responseToSave.answers[this.conditionalFieldName] = value;
|
|
167
|
-
journeyResponse.answers[this.conditionalFieldName] = value;
|
|
160
|
+
answers[this.conditionalFieldName] = req.body[option.conditional.fieldName] * conversionFactor;
|
|
168
161
|
}
|
|
169
162
|
});
|
|
170
163
|
|
|
171
|
-
return
|
|
164
|
+
return { answers };
|
|
172
165
|
}
|
|
173
166
|
|
|
174
167
|
/**
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
/** @typedef {
|
|
2
|
-
/** @typedef {
|
|
1
|
+
/** @typedef {[any, unknown][]} QuestionKeyTuples */
|
|
2
|
+
/** @typedef {(questionKeyTuples: QuestionKeyTuples) => boolean} CombinationFunc */
|
|
3
3
|
|
|
4
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* @param {import('#src/journey/journey-response.js').JourneyResponse} response
|
|
6
|
+
* @returns {{and: CombinationFunc, or: CombinationFunc}}
|
|
7
|
+
*/
|
|
5
8
|
export const logicalCombinations = (response) => ({
|
|
6
9
|
and: (questionKeyTuples) =>
|
|
7
10
|
questionKeyTuples.every((questionKeyTuple) => questionHasAnswer(response, ...questionKeyTuple)),
|
|
@@ -9,8 +12,28 @@ export const logicalCombinations = (response) => ({
|
|
|
9
12
|
questionKeyTuples.some((questionKeyTuple) => questionHasAnswer(response, ...questionKeyTuple))
|
|
10
13
|
});
|
|
11
14
|
|
|
12
|
-
|
|
13
|
-
|
|
15
|
+
/**
|
|
16
|
+
* A wrapper around questionHasAnswer to use as a condition directly
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* .withCondition(whenQuestionHasAnswer(question.q1, 'answer-1'))
|
|
20
|
+
*
|
|
21
|
+
* @param {import('#question').Question} question
|
|
22
|
+
* @param {unknown} expectedValue
|
|
23
|
+
* @returns {import('#src/section.js').QuestionCondition}
|
|
24
|
+
*/
|
|
25
|
+
export const whenQuestionHasAnswer = (question, expectedValue) => {
|
|
26
|
+
return (response) => questionHasAnswer(response, question, expectedValue);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Does the question have the expected answer?
|
|
31
|
+
*
|
|
32
|
+
* @param {import('#src/journey/journey-response.js').JourneyResponse} response
|
|
33
|
+
* @param {import('#question').Question|{optionJoinString: string}} question
|
|
34
|
+
* @param {unknown} expectedValue
|
|
35
|
+
* @returns {boolean}
|
|
36
|
+
*/
|
|
14
37
|
export const questionHasAnswer = (response, question, expectedValue) => {
|
|
15
38
|
if (!response.answers) return false;
|
|
16
39
|
const answerField = response.answers[question.fieldName];
|
|
@@ -46,8 +69,13 @@ export const questionArrayMeetsCondition = (response, question, conditionFn = ()
|
|
|
46
69
|
return false;
|
|
47
70
|
};
|
|
48
71
|
|
|
49
|
-
|
|
50
|
-
|
|
72
|
+
/**
|
|
73
|
+
* @param {import('#src/journey/journey-response.js').JourneyResponse} response
|
|
74
|
+
* @param {QuestionKeyTuples} questionKeyTuples
|
|
75
|
+
* @param {Object} [options]
|
|
76
|
+
* @param {'and' | 'or'} options.logicalCombinator
|
|
77
|
+
* @returns {boolean}
|
|
78
|
+
*/
|
|
51
79
|
export const questionsHaveAnswers = (
|
|
52
80
|
response,
|
|
53
81
|
questionKeyTuples,
|
|
@@ -58,16 +86,22 @@ export const questionsHaveAnswers = (
|
|
|
58
86
|
return combinators[logicalCombinator](questionKeyTuples);
|
|
59
87
|
};
|
|
60
88
|
|
|
61
|
-
|
|
62
|
-
|
|
89
|
+
/**
|
|
90
|
+
* @param {import('#src/journey/journey-response.js').JourneyResponse} response
|
|
91
|
+
* @param {import('#question').Question} question
|
|
92
|
+
* @returns {boolean}
|
|
93
|
+
*/
|
|
63
94
|
export const questionHasNonEmptyStringAnswer = (response, question) => {
|
|
64
95
|
if (!response.answers) return false;
|
|
65
96
|
const answerField = response.answers[question.fieldName];
|
|
66
97
|
return typeof answerField === 'string' && answerField.trim().length > 0;
|
|
67
98
|
};
|
|
68
99
|
|
|
69
|
-
|
|
70
|
-
|
|
100
|
+
/**
|
|
101
|
+
* @param {import('#src/journey/journey-response.js').JourneyResponse} response
|
|
102
|
+
* @param {import('#question').Question} question
|
|
103
|
+
* @returns {boolean}
|
|
104
|
+
*/
|
|
71
105
|
export const questionHasNonEmptyNumberAnswer = (response, question) => {
|
|
72
106
|
if (!response.answers) return false;
|
|
73
107
|
const answerField = response.answers[question.fieldName];
|
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @typedef {import('../../journey/journey-response').JourneyResponse} JourneyResponse
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
1
|
export function getConditionalFieldName(parentField, conditionalField) {
|
|
6
2
|
return `${parentField}_${conditionalField}`;
|
|
7
3
|
}
|
|
@@ -12,44 +8,14 @@ export function getConditionalAnswer(answers, question, answer) {
|
|
|
12
8
|
}
|
|
13
9
|
|
|
14
10
|
/**
|
|
15
|
-
* @param {
|
|
16
|
-
* @
|
|
17
|
-
*/
|
|
18
|
-
export function getAddressesForQuestion(journeyResponse, fieldName) {
|
|
19
|
-
const addresses = journeyResponse.answers?.SubmissionAddress || [];
|
|
20
|
-
|
|
21
|
-
return addresses.filter((address) => address.fieldName === fieldName);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* @param {JourneyResponse} journeyResponse
|
|
26
|
-
* @param {string} fieldName
|
|
11
|
+
* @param {unknown} conditional
|
|
12
|
+
* @returns {conditional is {html: string}}
|
|
27
13
|
*/
|
|
28
|
-
export function getLinkedCasesForQuestion(journeyResponse, fieldName) {
|
|
29
|
-
const linkedCases = journeyResponse.answers?.SubmissionLinkedCase || [];
|
|
30
|
-
|
|
31
|
-
return linkedCases.filter((linkedCase) => linkedCase.fieldName === fieldName);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* @param {JourneyResponse} journeyResponse
|
|
36
|
-
* @param {string} fieldName
|
|
37
|
-
*/
|
|
38
|
-
export function getListedBuildingForQuestion(journeyResponse, fieldName) {
|
|
39
|
-
const listedBuildings = journeyResponse.answers?.SubmissionListedBuilding || [];
|
|
40
|
-
|
|
41
|
-
return listedBuildings.filter((listedBuilding) => listedBuilding.fieldName === fieldName);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** @type {(conditional: unknown) => conditional is { html: string }} */
|
|
45
14
|
export function conditionalIsJustHTML(conditional) {
|
|
46
15
|
return !!conditional && Object.hasOwn(conditional, 'html') && Object.keys(conditional).length === 1;
|
|
47
16
|
}
|
|
48
17
|
|
|
49
18
|
export default {
|
|
50
|
-
getAddressesForQuestion,
|
|
51
19
|
getConditionalAnswer,
|
|
52
|
-
getConditionalFieldName
|
|
53
|
-
getListedBuildingForQuestion,
|
|
54
|
-
getLinkedCasesForQuestion
|
|
20
|
+
getConditionalFieldName
|
|
55
21
|
};
|
package/src/controller.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { SECTION_STATUS } from './section.js';
|
|
2
2
|
import questionUtils from './components/utils/question-utils.js';
|
|
3
|
+
import { answerObjectForManageListSaving } from '#src/components/manage-list/utils.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* @typedef {import('./journey/journey.js').Journey} Journey
|
|
@@ -249,6 +250,16 @@ export function buildSave(saveData, redirectToTaskListOnSuccess) {
|
|
|
249
250
|
if (redirectToTaskListOnSuccess) {
|
|
250
251
|
return res.redirect(journey.taskListUrl);
|
|
251
252
|
}
|
|
253
|
+
// edit the journey.response which question.shouldDisplay uses
|
|
254
|
+
// we need to ensure the latest answer just submitted is included
|
|
255
|
+
// as question.shouldDisplay checks the response and is used to determine the next question
|
|
256
|
+
let answers = journeyResponse.answers;
|
|
257
|
+
if (question.isInManageListSection) {
|
|
258
|
+
answers = answerObjectForManageListSaving(journeyResponse, manageListQuestion, req.params);
|
|
259
|
+
}
|
|
260
|
+
for (const [k, v] of Object.entries(data?.answers || {})) {
|
|
261
|
+
answers[k] = v;
|
|
262
|
+
}
|
|
252
263
|
// move to the next question
|
|
253
264
|
return journey.redirectToNextQuestion(res, req.params, manageListQuestion);
|
|
254
265
|
} catch (err) {
|
package/src/controller.test.js
CHANGED
|
@@ -379,6 +379,7 @@ describe('dynamic-form/controller', () => {
|
|
|
379
379
|
const saveData = mock.fn();
|
|
380
380
|
await buildSave(saveData)(req, res, journeyId);
|
|
381
381
|
|
|
382
|
+
assert.strictEqual(sampleQuestionObj.getDataToSave.mock.callCount(), 1);
|
|
382
383
|
assert.strictEqual(saveData.mock.callCount(), 1);
|
|
383
384
|
assert.deepStrictEqual(saveData.mock.calls[0].arguments, [
|
|
384
385
|
{
|
|
@@ -499,6 +500,9 @@ describe('dynamic-form/controller', () => {
|
|
|
499
500
|
const sampleQuestionObjWithActions = {
|
|
500
501
|
...sampleQuestionObj
|
|
501
502
|
};
|
|
503
|
+
sampleQuestionObjWithActions.getDataToSave.mock.mockImplementationOnce(() => ({
|
|
504
|
+
answers: { [sampleQuestionObj.fieldName]: 'my-answer' }
|
|
505
|
+
}));
|
|
502
506
|
|
|
503
507
|
req.params = {
|
|
504
508
|
referenceId: mockRef,
|
|
@@ -522,6 +526,8 @@ describe('dynamic-form/controller', () => {
|
|
|
522
526
|
assert.strictEqual(saveData.mock.callCount(), 1);
|
|
523
527
|
assert.strictEqual(res.redirect.mock.callCount(), 1);
|
|
524
528
|
assert.strictEqual(res.redirect.mock.calls[0].arguments[0], expectedUrl);
|
|
529
|
+
// journey response should be edited if redirecting to next question
|
|
530
|
+
assert.strictEqual(res.locals.journeyResponse.answers?.sampleFieldName, 'my-answer');
|
|
525
531
|
});
|
|
526
532
|
|
|
527
533
|
it('should redirect to task list if configured', async () => {
|
|
@@ -531,6 +537,9 @@ describe('dynamic-form/controller', () => {
|
|
|
531
537
|
const sampleQuestionObjWithActions = {
|
|
532
538
|
...sampleQuestionObj
|
|
533
539
|
};
|
|
540
|
+
sampleQuestionObjWithActions.getDataToSave.mock.mockImplementationOnce(() => ({
|
|
541
|
+
answers: { [sampleQuestionObj.fieldName]: 'my-answer' }
|
|
542
|
+
}));
|
|
534
543
|
|
|
535
544
|
req.params = {
|
|
536
545
|
referenceId: mockRef,
|
|
@@ -555,6 +564,8 @@ describe('dynamic-form/controller', () => {
|
|
|
555
564
|
assert.strictEqual(res.redirect.mock.callCount(), 1);
|
|
556
565
|
assert.match(res.redirect.mock.calls[0].arguments[0], /\/task-list$/);
|
|
557
566
|
assert.strictEqual(mockJourney.getNextQuestionUrl.mock.callCount(), 0);
|
|
567
|
+
// journey response should not be edited if redirecting to task list
|
|
568
|
+
assert.strictEqual(Object.keys(res.locals.journeyResponse.answers).length, 0);
|
|
558
569
|
});
|
|
559
570
|
|
|
560
571
|
describe('manageListQuestions', () => {
|
|
@@ -585,7 +596,8 @@ describe('dynamic-form/controller', () => {
|
|
|
585
596
|
const journey = {
|
|
586
597
|
getSection: mock.fn(() => ({ segment: 'section-1' })),
|
|
587
598
|
getQuestionByParams: mock.fn(),
|
|
588
|
-
redirectToNextQuestion: mock.fn()
|
|
599
|
+
redirectToNextQuestion: mock.fn(),
|
|
600
|
+
response: { answers: {} }
|
|
589
601
|
};
|
|
590
602
|
const res = {
|
|
591
603
|
locals: {
|
package/src/journey/journey.js
CHANGED
package/src/lib/utils.js
CHANGED
|
@@ -17,3 +17,14 @@ export function capitalize(str) {
|
|
|
17
17
|
if (typeof str !== 'string') return '';
|
|
18
18
|
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
19
19
|
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Trim a trailing slash if present
|
|
23
|
+
*
|
|
24
|
+
* @param {string} [str]
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
export function trimTrailingSlash(str) {
|
|
28
|
+
if (typeof str !== 'string') return str;
|
|
29
|
+
return str.replace(/\/$/, '');
|
|
30
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { trimTrailingSlash } from '#src/lib/utils.js';
|
|
4
|
+
|
|
5
|
+
describe('utils', () => {
|
|
6
|
+
describe('trimTrailingSlash', () => {
|
|
7
|
+
it('should trim a trailing slash', () => {
|
|
8
|
+
assert.strictEqual(trimTrailingSlash('my-url.com/hello/'), 'my-url.com/hello');
|
|
9
|
+
});
|
|
10
|
+
it('should do nothing if no trailing slash', () => {
|
|
11
|
+
assert.strictEqual(trimTrailingSlash('my-url.com/hello'), 'my-url.com/hello');
|
|
12
|
+
});
|
|
13
|
+
it('should return non-string types', () => {
|
|
14
|
+
assert.strictEqual(trimTrailingSlash(null), null);
|
|
15
|
+
assert.strictEqual(trimTrailingSlash(undefined), undefined);
|
|
16
|
+
assert.strictEqual(trimTrailingSlash(5), 5);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
});
|
package/src/lib/uuid.test.js
CHANGED
|
@@ -2,18 +2,6 @@ import { Uuid } from '#src/lib/uuid.js';
|
|
|
2
2
|
import { describe, it } from 'node:test';
|
|
3
3
|
import assert from 'node:assert';
|
|
4
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
5
|
describe('uuid', () => {
|
|
18
6
|
it('should return a valid v4 UUID', () => {
|
|
19
7
|
assert.match(
|
|
@@ -120,17 +120,13 @@ export default class OptionsQuestion extends Question {
|
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
/**
|
|
123
|
-
*
|
|
124
|
-
* side effect: modifies journeyResponse with the new answers
|
|
123
|
+
* Get the data to save from the request, returns an object of answers
|
|
125
124
|
* @param {import('express').Request} req
|
|
126
125
|
* @param {JourneyResponse} journeyResponse - current journey response, modified with the new answers
|
|
127
126
|
* @returns {Promise<{ answers: Record<string, unknown> }>}
|
|
128
|
-
*/
|
|
127
|
+
*/ //eslint-disable-next-line no-unused-vars -- journeyResponse kept for other questions to use
|
|
129
128
|
async getDataToSave(req, journeyResponse) {
|
|
130
|
-
|
|
131
|
-
* @type {{ answers: Record<string, unknown> }}
|
|
132
|
-
*/
|
|
133
|
-
let responseToSave = { answers: {} };
|
|
129
|
+
const answers = {};
|
|
134
130
|
|
|
135
131
|
const fields = Array.isArray(req.body[this.fieldName]) ? req.body[this.fieldName] : [req.body[this.fieldName]];
|
|
136
132
|
const fieldValues = fields.map((x) => x.trim());
|
|
@@ -142,8 +138,7 @@ export default class OptionsQuestion extends Question {
|
|
|
142
138
|
if (!selectedOptions.length)
|
|
143
139
|
throw new Error(`User submitted option(s) did not correlate with valid answers to ${this.fieldName} question`);
|
|
144
140
|
|
|
145
|
-
|
|
146
|
-
journeyResponse.answers[this.fieldName] = fieldValues;
|
|
141
|
+
answers[this.fieldName] = fieldValues.join(this.optionJoinString);
|
|
147
142
|
|
|
148
143
|
this.options.forEach((option) => {
|
|
149
144
|
if (!option.conditional) return;
|
|
@@ -152,11 +147,9 @@ export default class OptionsQuestion extends Question {
|
|
|
152
147
|
(selectedOption) => option.text === selectedOption.text && option.value === selectedOption.value
|
|
153
148
|
);
|
|
154
149
|
|
|
155
|
-
|
|
156
|
-
responseToSave.answers[key] = value;
|
|
157
|
-
journeyResponse.answers[key] = value;
|
|
150
|
+
answers[key] = optionIsSelectedOption ? req.body[key]?.trim() : null;
|
|
158
151
|
});
|
|
159
152
|
|
|
160
|
-
return
|
|
153
|
+
return { answers };
|
|
161
154
|
}
|
|
162
155
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import escape from 'escape-html';
|
|
2
2
|
import RequiredValidator from '../validator/required-validator.js';
|
|
3
|
-
import
|
|
4
|
-
import { capitalize, nl2br } from '../lib/utils.js';
|
|
3
|
+
import { capitalize, nl2br, trimTrailingSlash } from '../lib/utils.js';
|
|
5
4
|
import AddressValidator from '../validator/address-validator.js';
|
|
6
5
|
import MultiFieldInputValidator from '../validator/multi-field-input-validator.js';
|
|
6
|
+
import { answerObjectForManageList } from '#src/components/manage-list/utils.js';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* @typedef {import('../validator/base-validator.js')} BaseValidator
|
|
@@ -33,6 +33,8 @@ import MultiFieldInputValidator from '../validator/multi-field-input-validator.j
|
|
|
33
33
|
* @property {string} backLink
|
|
34
34
|
* @property {boolean} showBackToListLink
|
|
35
35
|
* @property {string} listLink
|
|
36
|
+
* @property {Object} util
|
|
37
|
+
* @property {function(string): string} util.trimTrailingSlash
|
|
36
38
|
*/
|
|
37
39
|
|
|
38
40
|
/**
|
|
@@ -239,6 +241,10 @@ export class Question {
|
|
|
239
241
|
|
|
240
242
|
continueButtonText: this.continueButtonText,
|
|
241
243
|
|
|
244
|
+
util: {
|
|
245
|
+
trimTrailingSlash
|
|
246
|
+
},
|
|
247
|
+
|
|
242
248
|
...customViewData,
|
|
243
249
|
...this.viewData
|
|
244
250
|
};
|
|
@@ -284,12 +290,7 @@ export class Question {
|
|
|
284
290
|
throw new Error('no manageListQuestion for manage list question');
|
|
285
291
|
}
|
|
286
292
|
// if this is a manage list question, the response is within the 'parent' manage list answers array
|
|
287
|
-
|
|
288
|
-
if (!Array.isArray(answers)) {
|
|
289
|
-
return {};
|
|
290
|
-
}
|
|
291
|
-
const itemId = params.manageListItemId;
|
|
292
|
-
return answers.find((a) => a.id === itemId) || {};
|
|
293
|
+
return answerObjectForManageList(response, manageListQuestion, params.manageListItemId);
|
|
293
294
|
}
|
|
294
295
|
return response.answers;
|
|
295
296
|
}
|
|
@@ -337,38 +338,24 @@ export class Question {
|
|
|
337
338
|
}
|
|
338
339
|
|
|
339
340
|
/**
|
|
340
|
-
*
|
|
341
|
-
*
|
|
341
|
+
* Get the data to save from the request, returns an object of answers
|
|
342
|
+
*
|
|
342
343
|
* @param {import('express').Request} req
|
|
343
|
-
* @param {JourneyResponse} journeyResponse - current journey response
|
|
344
|
+
* @param {JourneyResponse} journeyResponse - current journey response
|
|
344
345
|
* @returns {Promise<{ answers: Record<string, unknown> }>}
|
|
345
|
-
*/
|
|
346
|
+
*/ //eslint-disable-next-line no-unused-vars -- journeyResponse kept for other questions to use
|
|
346
347
|
async getDataToSave(req, journeyResponse) {
|
|
347
|
-
|
|
348
|
-
* @type {{ answers: Record<string, unknown> }}
|
|
349
|
-
*/
|
|
350
|
-
let responseToSave = { answers: {} };
|
|
348
|
+
const answers = {};
|
|
351
349
|
|
|
352
|
-
|
|
350
|
+
answers[this.fieldName] = req.body[this.fieldName];
|
|
353
351
|
|
|
354
352
|
for (const propName in req.body) {
|
|
355
353
|
if (propName.startsWith(this.fieldName + '_')) {
|
|
356
|
-
|
|
357
|
-
journeyResponse.answers[propName] = req.body[propName];
|
|
354
|
+
answers[propName] = req.body[propName];
|
|
358
355
|
}
|
|
359
|
-
// todo: sort this
|
|
360
|
-
// } else if (numericFields.has(propName)) {
|
|
361
|
-
// const numericValue = Number(req.body[propName]);
|
|
362
|
-
// if (!isNaN(numericValue)) {
|
|
363
|
-
// responseToSave.answers[propName] = numericValue;
|
|
364
|
-
// journeyResponse.answers[propName] = numericValue;
|
|
365
|
-
// }
|
|
366
|
-
// }
|
|
367
356
|
}
|
|
368
357
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
return responseToSave;
|
|
358
|
+
return { answers };
|
|
372
359
|
}
|
|
373
360
|
|
|
374
361
|
/**
|
|
@@ -470,7 +457,6 @@ export class Question {
|
|
|
470
457
|
return this.validators?.some(
|
|
471
458
|
(item) =>
|
|
472
459
|
item instanceof RequiredValidator ||
|
|
473
|
-
item instanceof RequiredFileUploadValidator ||
|
|
474
460
|
(item instanceof AddressValidator && item.isRequired()) ||
|
|
475
461
|
(item instanceof MultiFieldInputValidator && item.isRequired())
|
|
476
462
|
);
|
|
@@ -179,6 +179,8 @@ describe('./src/dynamic-forms/question.js', () => {
|
|
|
179
179
|
assert.deepStrictEqual(result.listLink, journey.taskListUrl);
|
|
180
180
|
assert.deepStrictEqual(result.journeyTitle, journey.journeyTitle);
|
|
181
181
|
assert.deepStrictEqual(result.hello, 'hi');
|
|
182
|
+
assert.ok(result.util);
|
|
183
|
+
assert.ok(typeof result.util.trimTrailingSlash === 'function');
|
|
182
184
|
});
|
|
183
185
|
it('should include viewData in viewModel', () => {
|
|
184
186
|
const question = getTestQuestion({ viewData: { test: 'data' } });
|
|
@@ -323,7 +325,7 @@ describe('./src/dynamic-forms/question.js', () => {
|
|
|
323
325
|
});
|
|
324
326
|
|
|
325
327
|
describe('getDataToSave', () => {
|
|
326
|
-
it('should return answer from req.body
|
|
328
|
+
it('should return answer from req.body', async () => {
|
|
327
329
|
const question = getTestQuestion();
|
|
328
330
|
|
|
329
331
|
const req = {
|
|
@@ -331,14 +333,8 @@ describe('./src/dynamic-forms/question.js', () => {
|
|
|
331
333
|
[question.fieldName]: { a: 1 }
|
|
332
334
|
}
|
|
333
335
|
};
|
|
334
|
-
const journeyResponse = {
|
|
335
|
-
answers: {
|
|
336
|
-
[question.fieldName]: { b: 1 },
|
|
337
|
-
other: 'another-answer'
|
|
338
|
-
}
|
|
339
|
-
};
|
|
340
336
|
|
|
341
|
-
const result = await question.getDataToSave(req
|
|
337
|
+
const result = await question.getDataToSave(req);
|
|
342
338
|
|
|
343
339
|
const expectedResult = {
|
|
344
340
|
answers: {
|
|
@@ -346,8 +342,6 @@ describe('./src/dynamic-forms/question.js', () => {
|
|
|
346
342
|
}
|
|
347
343
|
};
|
|
348
344
|
assert.deepStrictEqual(result, expectedResult);
|
|
349
|
-
expectedResult.answers.other = 'another-answer';
|
|
350
|
-
assert.deepStrictEqual(journeyResponse, expectedResult);
|
|
351
345
|
});
|
|
352
346
|
|
|
353
347
|
it('should handle nested properties', async () => {
|
|
@@ -360,14 +354,8 @@ describe('./src/dynamic-forms/question.js', () => {
|
|
|
360
354
|
[question.fieldName + '_2']: { a: 3 }
|
|
361
355
|
}
|
|
362
356
|
};
|
|
363
|
-
const journeyResponse = {
|
|
364
|
-
answers: {
|
|
365
|
-
[question.fieldName]: { b: 1 },
|
|
366
|
-
other: 'another-answer'
|
|
367
|
-
}
|
|
368
|
-
};
|
|
369
357
|
|
|
370
|
-
const result = await question.getDataToSave(req
|
|
358
|
+
const result = await question.getDataToSave(req);
|
|
371
359
|
|
|
372
360
|
const expectedResult = {
|
|
373
361
|
answers: {
|
|
@@ -377,8 +365,6 @@ describe('./src/dynamic-forms/question.js', () => {
|
|
|
377
365
|
}
|
|
378
366
|
};
|
|
379
367
|
assert.deepStrictEqual(result, expectedResult);
|
|
380
|
-
expectedResult.answers.other = 'another-answer';
|
|
381
|
-
assert.deepStrictEqual(journeyResponse, expectedResult);
|
|
382
368
|
});
|
|
383
369
|
});
|
|
384
370
|
|
package/src/section-types.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { JourneyResponse } from '#src/journey/journey-response.js';
|
|
2
2
|
import ManageListQuestion from '#src/components/manage-list/question.js';
|
|
3
3
|
import { Question } from '#src/questions/question.js';
|
|
4
|
+
import { RouteParams } from '#src/journey/journey-types.d.ts';
|
|
4
5
|
|
|
5
6
|
export interface GetNextQuestionParams {
|
|
6
7
|
questionFieldName: string;
|
|
@@ -8,6 +9,7 @@ export interface GetNextQuestionParams {
|
|
|
8
9
|
// if this is part of a manage list section
|
|
9
10
|
manageListQuestion?: ManageListQuestion;
|
|
10
11
|
takeNextQuestion: boolean;
|
|
12
|
+
routeParams: RouteParams;
|
|
11
13
|
// to get previous question instead of next
|
|
12
14
|
reverse: boolean;
|
|
13
15
|
}
|
package/src/section.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import RequiredValidator from './validator/required-validator.js';
|
|
8
|
+
import { answerObjectForManageList } from '#src/components/manage-list/utils.js';
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* A value indicating the final question of a section has been reached
|
|
@@ -203,11 +204,14 @@ export class Section {
|
|
|
203
204
|
* @returns {Question|Symbol|null}
|
|
204
205
|
*/
|
|
205
206
|
getNextQuestion(params) {
|
|
206
|
-
const { manageListQuestion } = params;
|
|
207
|
+
const { response, manageListQuestion, routeParams } = params;
|
|
207
208
|
if (manageListQuestion) {
|
|
208
209
|
// first check if the next question is within the manage list section
|
|
210
|
+
// here we get the answers for the manage list item for the question.shouldDisplay logic
|
|
211
|
+
const answers = answerObjectForManageList(response, manageListQuestion, routeParams.manageListItemId);
|
|
209
212
|
const next = Section.getNextQuestion({
|
|
210
213
|
...params,
|
|
214
|
+
response: { answers },
|
|
211
215
|
questions: manageListQuestion.section.questions
|
|
212
216
|
});
|
|
213
217
|
if (next === END_OF_SECTION) {
|
package/src/section.test.js
CHANGED
|
@@ -715,12 +715,14 @@ describe('./src/dynamic-forms/section.js', () => {
|
|
|
715
715
|
const section = new Section('s1', 'S').addQuestion(q1).addQuestion(manageListQ, manageListSection);
|
|
716
716
|
return { section, q1, manageListQ, q3, q4 };
|
|
717
717
|
};
|
|
718
|
+
const response = { answers: {} };
|
|
718
719
|
it(`should get first question within a manage list section`, () => {
|
|
719
720
|
const { section, manageListQ, q3 } = newSectionWithManageList();
|
|
720
721
|
const next = section.getNextQuestion({
|
|
721
722
|
questionFieldName: manageListQ.fieldName,
|
|
722
|
-
response
|
|
723
|
+
response,
|
|
723
724
|
manageListQuestion: manageListQ,
|
|
725
|
+
routeParams: {},
|
|
724
726
|
takeNextQuestion: true,
|
|
725
727
|
reverse: false
|
|
726
728
|
});
|
|
@@ -730,8 +732,9 @@ describe('./src/dynamic-forms/section.js', () => {
|
|
|
730
732
|
const { section, manageListQ, q3, q4 } = newSectionWithManageList();
|
|
731
733
|
const next = section.getNextQuestion({
|
|
732
734
|
questionFieldName: q3.fieldName,
|
|
733
|
-
response
|
|
735
|
+
response,
|
|
734
736
|
manageListQuestion: manageListQ,
|
|
737
|
+
routeParams: {},
|
|
735
738
|
takeNextQuestion: false,
|
|
736
739
|
reverse: false
|
|
737
740
|
});
|
|
@@ -741,8 +744,9 @@ describe('./src/dynamic-forms/section.js', () => {
|
|
|
741
744
|
const { section, manageListQ, q4 } = newSectionWithManageList();
|
|
742
745
|
const next = section.getNextQuestion({
|
|
743
746
|
questionFieldName: q4.fieldName,
|
|
744
|
-
response
|
|
747
|
+
response,
|
|
745
748
|
manageListQuestion: manageListQ,
|
|
749
|
+
routeParams: {},
|
|
746
750
|
takeNextQuestion: false,
|
|
747
751
|
reverse: false
|
|
748
752
|
});
|
|
@@ -3,7 +3,7 @@ import assert from 'assert';
|
|
|
3
3
|
import { manageListQuestions, questionsInOrder } from './questions.js';
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import { escapeForRegExp, snapshotsDir } from './utils/utils.js';
|
|
6
|
-
import { createAppWithQuestions, mockAnswer, mockAnswerBody } from './
|
|
6
|
+
import { createAppWithQuestions, mockAnswer, mockAnswerBody } from './utils/question-test-utils.js';
|
|
7
7
|
import { COMPONENT_TYPES } from '#src/index.js';
|
|
8
8
|
|
|
9
9
|
describe('check-your-answers', () => {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Uuid } from '#src/lib/uuid.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
*
|
|
5
|
+
* @param {import('node:test').TestContext} ctx
|
|
6
|
+
* @param {string} [uuid]
|
|
7
|
+
*/
|
|
8
|
+
export function mockRandomUUID(ctx, uuid = '00000000-0000-0000-0000-000000000000') {
|
|
9
|
+
const original = Uuid.randomUUID;
|
|
10
|
+
Uuid.randomUUID = ctx.mock.fn(() => uuid);
|
|
11
|
+
// restore after the tests
|
|
12
|
+
ctx.after(() => (Uuid.randomUUID = original));
|
|
13
|
+
}
|