@planning-inspectorate/dynamic-forms 3.2.0 → 3.3.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/README.md +9 -9
- package/package.json +1 -1
- 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 +15 -10
- package/src/components/manage-list/utils.js +14 -0
- package/src/components/manage-list/utils.test.js +47 -0
- package/src/components/utils/question-has-answer.js +45 -11
- package/src/components/utils/question-utils.js +3 -37
- package/src/journey/journey.js +1 -0
- package/src/lib/utils.js +11 -0
- package/src/lib/utils.test.js +19 -0
- package/src/questions/question.js +9 -9
- package/src/questions/question.test.js +2 -0
- package/src/section-types.d.ts +2 -0
- package/src/section.js +5 -1
- package/src/section.test.js +7 -3
- 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
package/README.md
CHANGED
|
@@ -146,12 +146,12 @@ to add conditions to questions in multiple ways.
|
|
|
146
146
|
The simplest condition applies to just one question:
|
|
147
147
|
|
|
148
148
|
```javascript
|
|
149
|
-
import {
|
|
149
|
+
import { whenQuestionHasAnswer } from '@pins/dynamic-forms/src/components/utils/question-has-answer';
|
|
150
150
|
|
|
151
151
|
new Section('section-key', 'Section title')
|
|
152
152
|
.addQuestion(questions.q1)
|
|
153
153
|
.addQuestion(questions.q2)
|
|
154
|
-
.withCondition((
|
|
154
|
+
.withCondition(whenQuestionHasAnswer(questions.q1, 'yes'))
|
|
155
155
|
```
|
|
156
156
|
|
|
157
157
|
In this scenario question two will only be shown if question one has an answer of 'yes'.
|
|
@@ -160,10 +160,10 @@ Conditions can also be added to an entire section:
|
|
|
160
160
|
|
|
161
161
|
```javascript
|
|
162
162
|
new Section('section-key', 'Section title')
|
|
163
|
-
.withSectionCondition((
|
|
163
|
+
.withSectionCondition(whenQuestionHasAnswer(questions.q1, 'yes'))
|
|
164
164
|
.addQuestion(questions.q3)
|
|
165
165
|
.addQuestion(questions.q4)
|
|
166
|
-
.withCondition((
|
|
166
|
+
.withCondition(whenQuestionHasAnswer(questions.q3, 'yes'))
|
|
167
167
|
```
|
|
168
168
|
|
|
169
169
|
In this case q3 and q4 will only be shown if q1 (from a previous section) is answered 'yes'. Q4 will only show if q1 is
|
|
@@ -173,16 +173,16 @@ Finally, conditions can be added to multiple questions - these can overlap:
|
|
|
173
173
|
|
|
174
174
|
```javascript
|
|
175
175
|
new Section('section-key', 'Section title')
|
|
176
|
-
.withSectionCondition((
|
|
176
|
+
.withSectionCondition(whenQuestionHasAnswer(questions.q1, 'yes'))
|
|
177
177
|
.addQuestion(questions.q2)
|
|
178
|
-
.startMultiQuestionCondition('group-1', (
|
|
178
|
+
.startMultiQuestionCondition('group-1', whenQuestionHasAnswer(questions.q2, 'valid'))
|
|
179
179
|
.addQuestion(questions.q3)
|
|
180
180
|
.addQuestion(questions.q4)
|
|
181
|
-
.withCondition((
|
|
181
|
+
.withCondition(whenQuestionHasAnswer(questions.q3, 'yes'))
|
|
182
182
|
.endMultiQuestionCondition('group-1')
|
|
183
183
|
.addQuestion(questions.q5)
|
|
184
|
-
.withCondition((
|
|
185
|
-
.startMultiQuestionCondition('group-2', (
|
|
184
|
+
.withCondition(whenQuestionHasAnswer(questions.q4, 'yes'))
|
|
185
|
+
.startMultiQuestionCondition('group-2', whenQuestionHasAnswer(questions.q5, 90))
|
|
186
186
|
.addQuestion(questions.q6)
|
|
187
187
|
.addQuestion(questions.q7)
|
|
188
188
|
.endMultiQuestionCondition('group-2')
|
package/package.json
CHANGED
|
@@ -59,7 +59,7 @@ const questionProps = {
|
|
|
59
59
|
|
|
60
60
|
### 2. Set up the section within the journey
|
|
61
61
|
|
|
62
|
-
You can use conditional logic
|
|
62
|
+
You can use conditional logic in the `ManageListSection` (see [Conditions](#conditions))
|
|
63
63
|
|
|
64
64
|
```js
|
|
65
65
|
new Section('New Holiday', 'holiday')
|
|
@@ -94,4 +94,23 @@ router.post(
|
|
|
94
94
|
validationErrorHandler,
|
|
95
95
|
buildSave(saveDataToSession)
|
|
96
96
|
);
|
|
97
|
-
```
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Conditions
|
|
100
|
+
|
|
101
|
+
The usual conditions can be used in a `ManageListSection`. However, only answers in the `ManageListSection` will be available to check, not answers for main the journey. For example:
|
|
102
|
+
|
|
103
|
+
```js
|
|
104
|
+
new Section('New Holiday', 'holiday')
|
|
105
|
+
.addQuestion(questions.holidayDestination)
|
|
106
|
+
// add a manage list question like any other, but include the `ManageListSection` as well
|
|
107
|
+
.addQuestion(
|
|
108
|
+
questions.holidayActivitiesList,
|
|
109
|
+
new ManageListSection()
|
|
110
|
+
.addQuestion(questions.holidayActivityType)
|
|
111
|
+
.addQuestion(questions.holidayActivityDate)
|
|
112
|
+
.withCondition(whenQuestionHasAnswer(questions.holidayActivityType, 'swimming'))
|
|
113
|
+
)
|
|
114
|
+
.addQuestion(questions.departureDate)
|
|
115
|
+
.addQuestion(questions.holidayPeriod)
|
|
116
|
+
```
|
|
@@ -31,12 +31,12 @@
|
|
|
31
31
|
<dd class="govuk-summary-list__actions">
|
|
32
32
|
<ul class="govuk-summary-list__actions-list">
|
|
33
33
|
<li class="govuk-summary-list__actions-list-item">
|
|
34
|
-
<a class="govuk-link" href="{{ originalUrl }}/edit/{{ item.id }}/{{ question.firstQuestionUrl }}">
|
|
34
|
+
<a class="govuk-link" href="{{ util.trimTrailingSlash(originalUrl) }}/edit/{{ item.id }}/{{ question.firstQuestionUrl }}">
|
|
35
35
|
Change <span class="govuk-visually-hidden">{{ titleSingular }} {{ loop.index }}</span>
|
|
36
36
|
</a>
|
|
37
37
|
</li>
|
|
38
38
|
<li class="govuk-summary-list__actions-list-item">
|
|
39
|
-
<a class="govuk-link" href="{{ originalUrl }}/remove/{{ item.id }}/confirm">
|
|
39
|
+
<a class="govuk-link" href="{{ util.trimTrailingSlash(originalUrl) }}/remove/{{ item.id }}/confirm">
|
|
40
40
|
Remove <span class="govuk-visually-hidden">{{ titleSingular }} {{ loop.index }}</span>
|
|
41
41
|
</a>
|
|
42
42
|
</li>
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
</dl>
|
|
48
48
|
|
|
49
49
|
<p class="govuk-body">
|
|
50
|
-
<a href="{{ originalUrl }}/{{ question.addAnotherLink }}" class="govuk-link">Add another</a>
|
|
50
|
+
<a href="{{ util.trimTrailingSlash(originalUrl) }}/{{ question.addAnotherLink }}" class="govuk-link">Add another</a>
|
|
51
51
|
</p>
|
|
52
52
|
|
|
53
53
|
{{ govukButton({
|
|
@@ -73,17 +73,22 @@ export default class ManageListQuestion extends Question {
|
|
|
73
73
|
const mockJourney = {
|
|
74
74
|
getCurrentQuestionUrl() {}
|
|
75
75
|
};
|
|
76
|
-
return
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
.
|
|
80
|
-
.
|
|
76
|
+
return (
|
|
77
|
+
this.section.questions
|
|
78
|
+
// only show questions which should be displayed based on any conditional logic
|
|
79
|
+
.filter((q) => q.shouldDisplay({ answers: answer }))
|
|
80
|
+
.map((q) => {
|
|
81
|
+
const formatted = q
|
|
82
|
+
.formatAnswerForSummary('', mockJourney, answer[q.fieldName])
|
|
83
|
+
.map((a) => a.value)
|
|
84
|
+
.join(', ');
|
|
81
85
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
86
|
+
return {
|
|
87
|
+
question: q.title,
|
|
88
|
+
answer: formatted
|
|
89
|
+
};
|
|
90
|
+
})
|
|
91
|
+
);
|
|
87
92
|
}
|
|
88
93
|
|
|
89
94
|
async getDataToSave(req, journeyResponse) {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* @param {import('#src/journey/journey-response.js').JourneyResponse} response
|
|
4
|
+
* @param {import('./question.js')} manageListQuestion
|
|
5
|
+
* @param {string} manageListItemId
|
|
6
|
+
* @returns {Record<string, unknown>}
|
|
7
|
+
*/
|
|
8
|
+
export function answerObjectForManageList(response, manageListQuestion, manageListItemId) {
|
|
9
|
+
const answers = response.answers[manageListQuestion.fieldName];
|
|
10
|
+
if (!Array.isArray(answers)) {
|
|
11
|
+
return {};
|
|
12
|
+
}
|
|
13
|
+
return answers.find((a) => a.id === manageListItemId) || {};
|
|
14
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { answerObjectForManageList } from '#src/components/manage-list/utils.js';
|
|
4
|
+
|
|
5
|
+
describe('manage-list-utils', () => {
|
|
6
|
+
describe('answerObjectForManageList', () => {
|
|
7
|
+
it('should return the manage list item object for manage list questions', () => {
|
|
8
|
+
const manageListQuestion = {
|
|
9
|
+
fieldName: 'manageListQuestion'
|
|
10
|
+
};
|
|
11
|
+
const answers = {
|
|
12
|
+
id: '1',
|
|
13
|
+
myField: 'my-value'
|
|
14
|
+
};
|
|
15
|
+
const journeyResponse = {
|
|
16
|
+
answers: {
|
|
17
|
+
manageListQuestion: [answers]
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
const manageListItemId = '1';
|
|
21
|
+
const got = answerObjectForManageList(journeyResponse, manageListQuestion, manageListItemId);
|
|
22
|
+
assert.strictEqual(got, answers);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should fallback to {} if manage list item id not found', () => {
|
|
26
|
+
const manageListQuestion = {
|
|
27
|
+
fieldName: 'manageListQuestion'
|
|
28
|
+
};
|
|
29
|
+
const answers = {
|
|
30
|
+
id: '2',
|
|
31
|
+
myField: 'my-value'
|
|
32
|
+
};
|
|
33
|
+
const journeyResponse = {
|
|
34
|
+
answers: {
|
|
35
|
+
manageListQuestion: [answers]
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
const manageListItemId = '1';
|
|
39
|
+
let got = answerObjectForManageList(journeyResponse, manageListQuestion, manageListItemId);
|
|
40
|
+
assert.deepStrictEqual(got, {});
|
|
41
|
+
// also fallback to {} if there is no manageListQuestion answer
|
|
42
|
+
delete journeyResponse.answers.manageListQuestion;
|
|
43
|
+
got = answerObjectForManageList(journeyResponse, manageListQuestion, manageListItemId);
|
|
44
|
+
assert.deepStrictEqual(got, {});
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -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/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
|
+
});
|
|
@@ -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
|
}
|
|
@@ -470,7 +471,6 @@ export class Question {
|
|
|
470
471
|
return this.validators?.some(
|
|
471
472
|
(item) =>
|
|
472
473
|
item instanceof RequiredValidator ||
|
|
473
|
-
item instanceof RequiredFileUploadValidator ||
|
|
474
474
|
(item instanceof AddressValidator && item.isRequired()) ||
|
|
475
475
|
(item instanceof MultiFieldInputValidator && item.isRequired())
|
|
476
476
|
);
|
|
@@ -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' } });
|
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
|
});
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
{% from "govuk/components/summary-list/macro.njk" import govukSummaryList %}
|
|
2
|
-
|
|
3
|
-
{% macro answerList(answers) %}
|
|
4
|
-
{% set summaryRows = [] %}
|
|
5
|
-
|
|
6
|
-
{% for item in answers %}
|
|
7
|
-
{% set summaryRows = (summaryRows.push({
|
|
8
|
-
key: {
|
|
9
|
-
text: item.label
|
|
10
|
-
},
|
|
11
|
-
value: {
|
|
12
|
-
html: item.answer | escape | nl2br | safe
|
|
13
|
-
},
|
|
14
|
-
actions: {
|
|
15
|
-
items: [
|
|
16
|
-
{
|
|
17
|
-
href: item.removeLink,
|
|
18
|
-
text: "Remove",
|
|
19
|
-
visuallyHiddenText: item.label + ", " + item.answer
|
|
20
|
-
}
|
|
21
|
-
]
|
|
22
|
-
}
|
|
23
|
-
}), summaryRows) %}
|
|
24
|
-
{% endfor %}
|
|
25
|
-
|
|
26
|
-
{{ govukSummaryList({
|
|
27
|
-
rows: summaryRows
|
|
28
|
-
}) }}
|
|
29
|
-
{% endmacro %}
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @param {string} alreadySubmittedUrl
|
|
3
|
-
* @returns {import('express').Handler}
|
|
4
|
-
*/
|
|
5
|
-
export default (alreadySubmittedUrl) => (req, res, next) => {
|
|
6
|
-
const journeyResponse = res?.locals?.journeyResponse;
|
|
7
|
-
|
|
8
|
-
if (journeyResponse?.answers?.submitted === 'yes' || journeyResponse?.answers?.submitted === true) {
|
|
9
|
-
return res.redirect(alreadySubmittedUrl);
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
return next();
|
|
13
|
-
};
|
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
import { describe, it, mock } from 'node:test';
|
|
2
|
-
import assert from 'node:assert';
|
|
3
|
-
import checkNotSubmitted from './check-not-submitted.js';
|
|
4
|
-
|
|
5
|
-
const testUrl = '/test';
|
|
6
|
-
|
|
7
|
-
describe('dynamic-forms/middleware/check-not-submitted', () => {
|
|
8
|
-
const mockReq = () => ({
|
|
9
|
-
cookies: {},
|
|
10
|
-
log: {},
|
|
11
|
-
params: {}
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
[
|
|
15
|
-
{
|
|
16
|
-
description: 'should do nothing if submitted is undefined',
|
|
17
|
-
given: () => {
|
|
18
|
-
return {
|
|
19
|
-
req: mockReq(),
|
|
20
|
-
res: {}
|
|
21
|
-
};
|
|
22
|
-
},
|
|
23
|
-
expected: (req, res, next) => {
|
|
24
|
-
assert.strictEqual(next.mock.callCount(), 1);
|
|
25
|
-
}
|
|
26
|
-
},
|
|
27
|
-
{
|
|
28
|
-
description: 'should do nothing if submitted is false',
|
|
29
|
-
given: () => {
|
|
30
|
-
return {
|
|
31
|
-
req: mockReq(),
|
|
32
|
-
res: {
|
|
33
|
-
locals: {
|
|
34
|
-
journeyResponse: {
|
|
35
|
-
answers: { submitted: false }
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
};
|
|
40
|
-
},
|
|
41
|
-
expected: (req, res, next) => {
|
|
42
|
-
assert.strictEqual(next.mock.callCount(), 1);
|
|
43
|
-
}
|
|
44
|
-
},
|
|
45
|
-
{
|
|
46
|
-
description: 'should do nothing if submitted is no',
|
|
47
|
-
given: () => {
|
|
48
|
-
return {
|
|
49
|
-
req: mockReq(),
|
|
50
|
-
res: {
|
|
51
|
-
locals: {
|
|
52
|
-
journeyResponse: {
|
|
53
|
-
answers: {
|
|
54
|
-
submitted: 'no'
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
};
|
|
60
|
-
},
|
|
61
|
-
expected: (req, res, next) => {
|
|
62
|
-
assert.strictEqual(next.mock.callCount(), 1);
|
|
63
|
-
}
|
|
64
|
-
},
|
|
65
|
-
{
|
|
66
|
-
description: 'should redirect if submitted is true',
|
|
67
|
-
given: () => {
|
|
68
|
-
return {
|
|
69
|
-
req: mockReq(),
|
|
70
|
-
res: {
|
|
71
|
-
locals: {
|
|
72
|
-
journeyResponse: {
|
|
73
|
-
answers: {
|
|
74
|
-
submitted: true
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
},
|
|
78
|
-
redirect: mock.fn()
|
|
79
|
-
}
|
|
80
|
-
};
|
|
81
|
-
},
|
|
82
|
-
expected: (req, res) => {
|
|
83
|
-
assert.strictEqual(res.redirect.mock.callCount(), 1);
|
|
84
|
-
}
|
|
85
|
-
},
|
|
86
|
-
{
|
|
87
|
-
description: 'should redirect if submitted is yes',
|
|
88
|
-
given: () => {
|
|
89
|
-
return {
|
|
90
|
-
req: mockReq(),
|
|
91
|
-
res: {
|
|
92
|
-
locals: {
|
|
93
|
-
journeyResponse: {
|
|
94
|
-
answers: {
|
|
95
|
-
submitted: 'yes'
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
},
|
|
99
|
-
redirect: mock.fn()
|
|
100
|
-
}
|
|
101
|
-
};
|
|
102
|
-
},
|
|
103
|
-
expected: (req, res) => {
|
|
104
|
-
assert.strictEqual(res.redirect.mock.callCount(), 1);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
].forEach(({ description, given, expected }) => {
|
|
108
|
-
it(description, () => {
|
|
109
|
-
const next = mock.fn();
|
|
110
|
-
const req = given().req;
|
|
111
|
-
const res = given().res;
|
|
112
|
-
|
|
113
|
-
checkNotSubmitted(testUrl)(req, res, next);
|
|
114
|
-
|
|
115
|
-
expected(req, res, next);
|
|
116
|
-
});
|
|
117
|
-
});
|
|
118
|
-
});
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Middleware for converting `req.files` to `req.body.files`.
|
|
3
|
-
*
|
|
4
|
-
* The requirement for this is due to `express-validator` only considering a limited subset of
|
|
5
|
-
* keys on the `req` object as being eligible for validation.
|
|
6
|
-
*
|
|
7
|
-
* This middleware is designed for use with component-based dynamic forms
|
|
8
|
-
*
|
|
9
|
-
* This has not been tested with
|
|
10
|
-
* pages that contain two or more multiple file uploads.
|
|
11
|
-
*
|
|
12
|
-
* @returns {function(*, *, *): (*)}
|
|
13
|
-
*/
|
|
14
|
-
export default () => (req, res, next) => {
|
|
15
|
-
if (!req.body || !req.files || !req.params) {
|
|
16
|
-
return next();
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const { journey } = res.locals;
|
|
20
|
-
|
|
21
|
-
const questionObj = journey.getQuestionByParams(req.params);
|
|
22
|
-
const filesPropertyPath = questionObj.fieldName;
|
|
23
|
-
|
|
24
|
-
if (typeof req.files[filesPropertyPath] === 'undefined') {
|
|
25
|
-
req.body.files = [];
|
|
26
|
-
return next();
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
try {
|
|
30
|
-
req.body.files = {
|
|
31
|
-
...req.body.files,
|
|
32
|
-
// Ensure we are always working with an array. Single files would otherwise be an object.
|
|
33
|
-
[filesPropertyPath]: Array.isArray(req.files[filesPropertyPath])
|
|
34
|
-
? req.files[filesPropertyPath]
|
|
35
|
-
: [req.files[filesPropertyPath]]
|
|
36
|
-
};
|
|
37
|
-
} catch (err) {
|
|
38
|
-
// this feels like it should never happen, as nothing in the try block throws. This is a precaution.
|
|
39
|
-
/* istanbul ignore next */
|
|
40
|
-
req.log.debug({ err, filesPropertyPath }, 'Error extracting req.files[filesPropertyPath]');
|
|
41
|
-
/* istanbul ignore next */
|
|
42
|
-
req.body.files = [];
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
return next();
|
|
46
|
-
};
|
|
@@ -1,221 +0,0 @@
|
|
|
1
|
-
import { describe, it, mock, beforeEach } from 'node:test';
|
|
2
|
-
import assert from 'node:assert';
|
|
3
|
-
import dynamicReqFilesToReqBodyFilesMiddleware from './dynamic-req-files-to-req-body-files.js';
|
|
4
|
-
|
|
5
|
-
describe('dynamic-forms/middleware/dynamic-req-files-to-req-body-files', () => {
|
|
6
|
-
let counter = 0;
|
|
7
|
-
let mockReq;
|
|
8
|
-
let mockRes;
|
|
9
|
-
let filesPropertyPath = `example-files-property-path-${counter}`;
|
|
10
|
-
|
|
11
|
-
const mockgetQuestionByParams = mock.fn(() => {
|
|
12
|
-
return { fieldName: filesPropertyPath };
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
beforeEach(() => {
|
|
16
|
-
mockReq = () => ({
|
|
17
|
-
cookies: {},
|
|
18
|
-
log: {},
|
|
19
|
-
params: {}
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
mockRes = () => ({
|
|
23
|
-
clearCookie: mock.fn(),
|
|
24
|
-
cookie: mock.fn(),
|
|
25
|
-
locals: {
|
|
26
|
-
journeyResponse: mock.fn(),
|
|
27
|
-
journey: { getQuestionByParams: mockgetQuestionByParams }
|
|
28
|
-
},
|
|
29
|
-
redirect: mock.fn(),
|
|
30
|
-
render: mock.fn(),
|
|
31
|
-
sendStatus: mock.fn(),
|
|
32
|
-
status: mock.fn()
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
counter++;
|
|
36
|
-
filesPropertyPath = `example-files-property-path-${counter}`;
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
[
|
|
40
|
-
{
|
|
41
|
-
description: 'should return early if `req.body` is not set',
|
|
42
|
-
given: () => mockReq(),
|
|
43
|
-
expected: (req, res, next) => {
|
|
44
|
-
assert.strictEqual(req.body, undefined);
|
|
45
|
-
assert.strictEqual(next.mock.callCount(), 1);
|
|
46
|
-
}
|
|
47
|
-
},
|
|
48
|
-
{
|
|
49
|
-
description: 'should return early if `req.files` is not set',
|
|
50
|
-
given: () => ({
|
|
51
|
-
...mockReq(),
|
|
52
|
-
body: {}
|
|
53
|
-
}),
|
|
54
|
-
expected: (req, res, next) => {
|
|
55
|
-
assert.strictEqual(req.body.files, undefined);
|
|
56
|
-
assert.strictEqual(next.mock.callCount(), 1);
|
|
57
|
-
}
|
|
58
|
-
},
|
|
59
|
-
{
|
|
60
|
-
description: 'should return early if `req.params` is not set',
|
|
61
|
-
given: () => ({
|
|
62
|
-
cookies: {},
|
|
63
|
-
log: {},
|
|
64
|
-
body: {},
|
|
65
|
-
files: {}
|
|
66
|
-
}),
|
|
67
|
-
expected: (req, res, next) => {
|
|
68
|
-
assert.strictEqual(req.body.files, undefined);
|
|
69
|
-
assert.strictEqual(next.mock.callCount(), 1);
|
|
70
|
-
}
|
|
71
|
-
},
|
|
72
|
-
{
|
|
73
|
-
description:
|
|
74
|
-
'should set `req.body.files = []` if unable to find the given file property path `req.files` - unset',
|
|
75
|
-
given: () => ({
|
|
76
|
-
...mockReq(),
|
|
77
|
-
body: {},
|
|
78
|
-
files: {},
|
|
79
|
-
params: {
|
|
80
|
-
section: 'mock-section',
|
|
81
|
-
question: 'mock-question'
|
|
82
|
-
}
|
|
83
|
-
}),
|
|
84
|
-
expected: (req, res, next) => {
|
|
85
|
-
assert.deepStrictEqual(req.body.files, []);
|
|
86
|
-
assert.strictEqual(next.mock.callCount(), 1);
|
|
87
|
-
}
|
|
88
|
-
},
|
|
89
|
-
{
|
|
90
|
-
description:
|
|
91
|
-
'should set `req.body.files = []` if unable to find the given file property path `req.files` - wrong path',
|
|
92
|
-
given: () => ({
|
|
93
|
-
...mockReq(),
|
|
94
|
-
body: {},
|
|
95
|
-
files: {
|
|
96
|
-
'some-different-path': {
|
|
97
|
-
x: 'y'
|
|
98
|
-
}
|
|
99
|
-
},
|
|
100
|
-
params: {
|
|
101
|
-
section: 'mock-section',
|
|
102
|
-
question: 'mock-question'
|
|
103
|
-
}
|
|
104
|
-
}),
|
|
105
|
-
expected: (req, res, next) => {
|
|
106
|
-
assert.deepStrictEqual(req.body.files, []);
|
|
107
|
-
assert.strictEqual(next.mock.callCount(), 1);
|
|
108
|
-
}
|
|
109
|
-
},
|
|
110
|
-
{
|
|
111
|
-
description: 'should set `req.body.files = [req.files]` when given a single file',
|
|
112
|
-
given: () => ({
|
|
113
|
-
...mockReq(),
|
|
114
|
-
body: {},
|
|
115
|
-
params: {
|
|
116
|
-
section: 'mock-section',
|
|
117
|
-
question: 'mock-question'
|
|
118
|
-
},
|
|
119
|
-
files: {
|
|
120
|
-
[filesPropertyPath]: {
|
|
121
|
-
a: 'b'
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
}),
|
|
125
|
-
expected: (req, res, next) => {
|
|
126
|
-
assert.deepStrictEqual(req.body.files, { [filesPropertyPath]: [{ a: 'b' }] });
|
|
127
|
-
assert.strictEqual(next.mock.callCount(), 1);
|
|
128
|
-
}
|
|
129
|
-
},
|
|
130
|
-
{
|
|
131
|
-
description: 'should set `req.body.files = [...req.files]` when given multiple files',
|
|
132
|
-
given: () => ({
|
|
133
|
-
...mockReq(),
|
|
134
|
-
body: {},
|
|
135
|
-
params: {
|
|
136
|
-
section: 'mock-section',
|
|
137
|
-
question: 'mock-question'
|
|
138
|
-
},
|
|
139
|
-
files: {
|
|
140
|
-
[filesPropertyPath]: [
|
|
141
|
-
{
|
|
142
|
-
a: 'b'
|
|
143
|
-
},
|
|
144
|
-
{
|
|
145
|
-
c: 'd'
|
|
146
|
-
}
|
|
147
|
-
]
|
|
148
|
-
}
|
|
149
|
-
}),
|
|
150
|
-
expected: (req, res, next) => {
|
|
151
|
-
assert.deepStrictEqual(req.body.files, {
|
|
152
|
-
[filesPropertyPath]: [
|
|
153
|
-
{
|
|
154
|
-
a: 'b'
|
|
155
|
-
},
|
|
156
|
-
{
|
|
157
|
-
c: 'd'
|
|
158
|
-
}
|
|
159
|
-
]
|
|
160
|
-
});
|
|
161
|
-
assert.strictEqual(next.mock.callCount(), 1);
|
|
162
|
-
}
|
|
163
|
-
},
|
|
164
|
-
{
|
|
165
|
-
description: 'should retain existing req.body.files when working with a different key',
|
|
166
|
-
given: () => ({
|
|
167
|
-
...mockReq(),
|
|
168
|
-
body: {
|
|
169
|
-
files: {
|
|
170
|
-
'an-existing-key-value-pair': [
|
|
171
|
-
{
|
|
172
|
-
rgb: 'xyz'
|
|
173
|
-
}
|
|
174
|
-
]
|
|
175
|
-
}
|
|
176
|
-
},
|
|
177
|
-
files: {
|
|
178
|
-
[filesPropertyPath]: [
|
|
179
|
-
{
|
|
180
|
-
a: 'b'
|
|
181
|
-
},
|
|
182
|
-
{
|
|
183
|
-
c: 'd'
|
|
184
|
-
}
|
|
185
|
-
]
|
|
186
|
-
},
|
|
187
|
-
params: {
|
|
188
|
-
section: 'mock-section',
|
|
189
|
-
question: 'mock-question'
|
|
190
|
-
}
|
|
191
|
-
}),
|
|
192
|
-
expected: (req, res, next) => {
|
|
193
|
-
assert.deepStrictEqual(req.body.files, {
|
|
194
|
-
'an-existing-key-value-pair': [
|
|
195
|
-
{
|
|
196
|
-
rgb: 'xyz'
|
|
197
|
-
}
|
|
198
|
-
],
|
|
199
|
-
[filesPropertyPath]: [
|
|
200
|
-
{
|
|
201
|
-
a: 'b'
|
|
202
|
-
},
|
|
203
|
-
{
|
|
204
|
-
c: 'd'
|
|
205
|
-
}
|
|
206
|
-
]
|
|
207
|
-
});
|
|
208
|
-
assert.strictEqual(next.mock.callCount(), 1);
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
].forEach(({ description, given, expected }) => {
|
|
212
|
-
it(description, () => {
|
|
213
|
-
const next = mock.fn();
|
|
214
|
-
const req = given();
|
|
215
|
-
|
|
216
|
-
dynamicReqFilesToReqBodyFilesMiddleware()(req, mockRes(), next);
|
|
217
|
-
|
|
218
|
-
expected(req, mockRes(), next);
|
|
219
|
-
});
|
|
220
|
-
});
|
|
221
|
-
});
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import BaseValidator from './base-validator.js';
|
|
2
|
-
import { checkSchema } from 'express-validator';
|
|
3
|
-
|
|
4
|
-
import requiredFileUploadSchema from './schemas/required-file-upload-schema.js';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* @typedef {import('../questions/question.js')} Question
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* enforces that at least one file is present for a question, whether it is being/already uploaded
|
|
12
|
-
* @class
|
|
13
|
-
*/
|
|
14
|
-
export default class RequiredFileUploadValidator extends BaseValidator {
|
|
15
|
-
/**
|
|
16
|
-
* creates an instance of a RequiredFileUploadValidator
|
|
17
|
-
* @param {string} [errorMessage] - custom error message to show on validation failure
|
|
18
|
-
*/
|
|
19
|
-
constructor(errorMessage) {
|
|
20
|
-
super();
|
|
21
|
-
|
|
22
|
-
if (errorMessage) {
|
|
23
|
-
this.errorMessage = errorMessage;
|
|
24
|
-
} else {
|
|
25
|
-
this.errorMessage = 'You must add your documents';
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* validates against path based on questionObj's fieldname
|
|
31
|
-
* @param {Question} questionObj
|
|
32
|
-
*/
|
|
33
|
-
validate(questionObj, journeyResponse) {
|
|
34
|
-
const path = questionObj.fieldName;
|
|
35
|
-
const documentType = questionObj.documentType.name;
|
|
36
|
-
return checkSchema(requiredFileUploadSchema(path, journeyResponse, documentType, this.errorMessage))[0];
|
|
37
|
-
}
|
|
38
|
-
}
|
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
import { describe, it } from 'node:test';
|
|
2
|
-
import assert from 'node:assert';
|
|
3
|
-
import RequiredFileUploadValidator from './required-file-upload-validator.js';
|
|
4
|
-
|
|
5
|
-
describe('./src/dynamic-forms/validator/required-file-upload-validator.js', () => {
|
|
6
|
-
describe('validate', () => {
|
|
7
|
-
it('should have a rule for path featuring fieldName', () => {
|
|
8
|
-
const requiredFileUploadValidator = new RequiredFileUploadValidator();
|
|
9
|
-
|
|
10
|
-
const rule = requiredFileUploadValidator
|
|
11
|
-
.validate({
|
|
12
|
-
fieldName: 'test-field-name',
|
|
13
|
-
documentType: {
|
|
14
|
-
name: 'test-doc-type'
|
|
15
|
-
}
|
|
16
|
-
})
|
|
17
|
-
.builder.build();
|
|
18
|
-
|
|
19
|
-
assert.deepStrictEqual(rule.fields, ['test-field-name']);
|
|
20
|
-
assert.strictEqual(!!rule.optional, false);
|
|
21
|
-
assert.strictEqual(rule.stack.length, 1);
|
|
22
|
-
assert.strictEqual(rule.stack[0].validator.name, 'options');
|
|
23
|
-
assert.strictEqual(!!rule.stack[0].validator.negated, false);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it('should pass if file being uploaded', async () => {
|
|
27
|
-
const questionObj = {
|
|
28
|
-
fieldName: 'test-fieldname',
|
|
29
|
-
documentType: {
|
|
30
|
-
name: 'test-doc-type'
|
|
31
|
-
}
|
|
32
|
-
};
|
|
33
|
-
const journeyResponse = {};
|
|
34
|
-
const req = { files: { test: 'test123' } };
|
|
35
|
-
|
|
36
|
-
const requiredFileUploadValidator = new RequiredFileUploadValidator();
|
|
37
|
-
const validationResult = await requiredFileUploadValidator.validate(questionObj, journeyResponse).run(req);
|
|
38
|
-
|
|
39
|
-
assert.strictEqual(validationResult.errors.length, 0);
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it('should pass if no file being uploaded and file matching question document type already uploaded', async () => {
|
|
43
|
-
const questionObj = {
|
|
44
|
-
fieldName: 'another-test-fieldname',
|
|
45
|
-
documentType: {
|
|
46
|
-
name: 'test-doc-type'
|
|
47
|
-
}
|
|
48
|
-
};
|
|
49
|
-
const uploadedFile = {
|
|
50
|
-
file: 'already-uploaded',
|
|
51
|
-
type: 'test-doc-type'
|
|
52
|
-
};
|
|
53
|
-
const journeyResponse = {
|
|
54
|
-
answers: { SubmissionDocumentUpload: [uploadedFile] }
|
|
55
|
-
};
|
|
56
|
-
const req = { body: {} };
|
|
57
|
-
|
|
58
|
-
const requiredFileUploadValidator = new RequiredFileUploadValidator();
|
|
59
|
-
const validationResult = await requiredFileUploadValidator.validate(questionObj, journeyResponse).run(req);
|
|
60
|
-
|
|
61
|
-
assert.strictEqual(validationResult.errors.length, 0);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it('should fail if no file being uploaded and no file matching question document type already uploaded', async () => {
|
|
65
|
-
const questionObj = {
|
|
66
|
-
fieldName: 'yet-another-test-fieldname',
|
|
67
|
-
documentType: {
|
|
68
|
-
name: 'test-doc-type'
|
|
69
|
-
}
|
|
70
|
-
};
|
|
71
|
-
const journeyResponse = { answers: { SubmissionDocumentUpload: [] } };
|
|
72
|
-
const req = { body: {} };
|
|
73
|
-
|
|
74
|
-
const requiredFileUploadValidator = new RequiredFileUploadValidator();
|
|
75
|
-
const validationResult = await requiredFileUploadValidator.validate(questionObj, journeyResponse).run(req);
|
|
76
|
-
|
|
77
|
-
assert.strictEqual(validationResult.errors.length, 1);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it('should fail if no file being uploaded matching question doc type and all existing files matching doc type being removed', async () => {
|
|
81
|
-
const questionObj = {
|
|
82
|
-
fieldName: 'test-fieldname',
|
|
83
|
-
documentType: {
|
|
84
|
-
name: 'test-doc-type'
|
|
85
|
-
}
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
const journeyResponse = {
|
|
89
|
-
answers: {
|
|
90
|
-
SubmissionDocumentUpload: [
|
|
91
|
-
{
|
|
92
|
-
file: 'to-be-removed',
|
|
93
|
-
type: 'test-doc-type'
|
|
94
|
-
},
|
|
95
|
-
{
|
|
96
|
-
file: 'to-be-removed-too',
|
|
97
|
-
type: 'test-doc-type'
|
|
98
|
-
}
|
|
99
|
-
]
|
|
100
|
-
}
|
|
101
|
-
};
|
|
102
|
-
const req = { body: {} };
|
|
103
|
-
req.body.removedFiles =
|
|
104
|
-
'[{"file": "to-be-removed", "type": "test-doc-type"}, {"file": "to-be-removed-too", "type": "test-doc-type"}]';
|
|
105
|
-
const requiredFileUploadValidator = new RequiredFileUploadValidator();
|
|
106
|
-
const validationResult = await requiredFileUploadValidator.validate(questionObj, journeyResponse).run(req);
|
|
107
|
-
|
|
108
|
-
assert.strictEqual(validationResult.errors.length, 1);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it('should pass if existing files matching question doc type and only some being removed', async () => {
|
|
112
|
-
const questionObj = {
|
|
113
|
-
fieldName: 'test-fieldname',
|
|
114
|
-
documentType: {
|
|
115
|
-
name: 'test-doc-type'
|
|
116
|
-
}
|
|
117
|
-
};
|
|
118
|
-
const journeyResponse = {
|
|
119
|
-
answers: {
|
|
120
|
-
SubmissionDocumentUpload: [
|
|
121
|
-
{
|
|
122
|
-
file: 'to-be-removed',
|
|
123
|
-
type: 'test-doc-type'
|
|
124
|
-
},
|
|
125
|
-
{
|
|
126
|
-
file: 'to-be-kept',
|
|
127
|
-
type: 'test-doc-type'
|
|
128
|
-
}
|
|
129
|
-
]
|
|
130
|
-
}
|
|
131
|
-
};
|
|
132
|
-
const req = { body: {} };
|
|
133
|
-
req.body.removedFiles = '[{"file": "to-be-removed", "type": "test-doc-type"}]';
|
|
134
|
-
|
|
135
|
-
const requiredFileUploadValidator = new RequiredFileUploadValidator();
|
|
136
|
-
const validationResult = await requiredFileUploadValidator.validate(questionObj, journeyResponse).run(req);
|
|
137
|
-
|
|
138
|
-
assert.strictEqual(validationResult.errors.length, 0);
|
|
139
|
-
});
|
|
140
|
-
});
|
|
141
|
-
});
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
const schema = (path, journeyResponse, documentType, errorMsg = 'Select a file to upload') => ({
|
|
2
|
-
[path]: {
|
|
3
|
-
custom: {
|
|
4
|
-
options: async (value, { req }) => {
|
|
5
|
-
if (!req.files) {
|
|
6
|
-
const uploadedFiles = journeyResponse.answers.SubmissionDocumentUpload || [];
|
|
7
|
-
|
|
8
|
-
const relevantUploadedFiles = uploadedFiles.filter((upload) => upload.type === documentType);
|
|
9
|
-
|
|
10
|
-
if (relevantUploadedFiles) {
|
|
11
|
-
//at this stage, uploadedFiles will still contain any files flagged for removal
|
|
12
|
-
//so we need to disregard them when checking uploadedFiles
|
|
13
|
-
let removedFiles = [];
|
|
14
|
-
|
|
15
|
-
if ('removedFiles' in req.body) {
|
|
16
|
-
removedFiles = JSON.parse(req.body.removedFiles) || [];
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
if (relevantUploadedFiles.length - removedFiles.length < 1) {
|
|
20
|
-
throw new Error(errorMsg);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
return true;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
throw new Error(errorMsg);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
return true;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
export default schema;
|