@planning-inspectorate/dynamic-forms 3.1.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 +22 -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/controller.js +4 -0
- package/src/controller.test.js +5 -1
- package/src/journey/journey.js +1 -0
- package/src/lib/session-answer-store.js +12 -3
- package/src/lib/session-answer-store.test.js +42 -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/test/check-your-answers.test.js +19 -1
- package/test/questions.test.js +3 -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,29 @@ 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
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async getDataToSave(req, journeyResponse) {
|
|
95
|
+
let responseToSave = { answers: {} };
|
|
96
|
+
const data = journeyResponse.answers[this.fieldName];
|
|
97
|
+
responseToSave.answers[this.fieldName] = data || [];
|
|
98
|
+
return responseToSave;
|
|
87
99
|
}
|
|
88
100
|
|
|
89
101
|
formatAnswerForSummary(sectionSegment, journey, answer) {
|
|
@@ -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/controller.js
CHANGED
|
@@ -184,6 +184,8 @@ export async function question(req, res) {
|
|
|
184
184
|
* @property {import('express').Response} res
|
|
185
185
|
* @property {string} journeyId
|
|
186
186
|
* @property {string} referenceId
|
|
187
|
+
* @property {boolean} isManageListItem
|
|
188
|
+
* @property {string} [manageListQuestionFieldName]
|
|
187
189
|
* @property {Object<string, any>} data
|
|
188
190
|
*/
|
|
189
191
|
|
|
@@ -234,6 +236,8 @@ export function buildSave(saveData, redirectToTaskListOnSuccess) {
|
|
|
234
236
|
res,
|
|
235
237
|
journeyId: journeyResponse.journeyId,
|
|
236
238
|
referenceId: journeyResponse.referenceId,
|
|
239
|
+
isManageListItem: question.isInManageListSection,
|
|
240
|
+
manageListQuestionFieldName: manageListQuestion?.fieldName,
|
|
237
241
|
data
|
|
238
242
|
});
|
|
239
243
|
|
package/src/controller.test.js
CHANGED
|
@@ -386,6 +386,8 @@ describe('dynamic-form/controller', () => {
|
|
|
386
386
|
res,
|
|
387
387
|
journeyId: journeyParams.journeyId,
|
|
388
388
|
referenceId: journeyParams.referenceId,
|
|
389
|
+
isManageListItem: undefined,
|
|
390
|
+
manageListQuestionFieldName: undefined,
|
|
389
391
|
data: undefined
|
|
390
392
|
}
|
|
391
393
|
]);
|
|
@@ -443,7 +445,9 @@ describe('dynamic-form/controller', () => {
|
|
|
443
445
|
res,
|
|
444
446
|
journeyId: journeyParams.journeyId,
|
|
445
447
|
referenceId: journeyParams.referenceId,
|
|
446
|
-
data: undefined
|
|
448
|
+
data: undefined,
|
|
449
|
+
isManageListItem: undefined,
|
|
450
|
+
manageListQuestionFieldName: undefined
|
|
447
451
|
}
|
|
448
452
|
],
|
|
449
453
|
'save data args'
|
package/src/journey/journey.js
CHANGED
|
@@ -35,11 +35,12 @@ import { booleanToYesNoValue } from '../components/boolean/question.js';
|
|
|
35
35
|
*
|
|
36
36
|
*
|
|
37
37
|
* @param {Object} opts
|
|
38
|
-
* @param {string} [opts.reqParam]
|
|
38
|
+
* @param {string} [opts.reqParam]
|
|
39
|
+
* - optional request parameter to use as a key
|
|
39
40
|
* @returns {import('../controller').SaveDataFn}
|
|
40
41
|
*/
|
|
41
42
|
export function buildSaveDataToSession({ reqParam } = {}) {
|
|
42
|
-
return async ({ req, journeyId, data }) => {
|
|
43
|
+
return async ({ req, journeyId, data, isManageListItem, manageListQuestionFieldName }) => {
|
|
43
44
|
if (!req.session) {
|
|
44
45
|
throw new Error('request session required');
|
|
45
46
|
}
|
|
@@ -50,8 +51,16 @@ export function buildSaveDataToSession({ reqParam } = {}) {
|
|
|
50
51
|
// key by a further param
|
|
51
52
|
forms = forms[reqParamValue] || (forms[reqParamValue] = {});
|
|
52
53
|
}
|
|
53
|
-
|
|
54
|
+
let answers = forms[journeyId] || (forms[journeyId] = {});
|
|
55
|
+
if (isManageListItem) {
|
|
56
|
+
const answersList = answers[manageListQuestionFieldName] || (answers[manageListQuestionFieldName] = []);
|
|
57
|
+
answers = answersList.find((item) => item.id === req.params.manageListItemId);
|
|
54
58
|
|
|
59
|
+
if (!answers) {
|
|
60
|
+
answers = { id: req.params.manageListItemId };
|
|
61
|
+
answersList.push(answers);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
55
64
|
for (const [k, v] of Object.entries(data?.answers || {})) {
|
|
56
65
|
answers[k] = v;
|
|
57
66
|
}
|
|
@@ -80,6 +80,48 @@ describe('session-answer-store', () => {
|
|
|
80
80
|
}
|
|
81
81
|
});
|
|
82
82
|
});
|
|
83
|
+
it('should add a new managed list item when not present in the session', async () => {
|
|
84
|
+
const req = mockReq();
|
|
85
|
+
req.params = { manageListItemId: '12345' };
|
|
86
|
+
req.session = {};
|
|
87
|
+
const journeyId = 'j-1';
|
|
88
|
+
const data = { answers: { q1: 'a1' } };
|
|
89
|
+
const isManageListItem = true;
|
|
90
|
+
const manageListQuestionFieldName = 'myManagedList';
|
|
91
|
+
const saveDataToSession = buildSaveDataToSession();
|
|
92
|
+
await saveDataToSession({ req, journeyId, data, isManageListItem, manageListQuestionFieldName });
|
|
93
|
+
assert.deepStrictEqual(req.session, {
|
|
94
|
+
forms: {
|
|
95
|
+
'j-1': {
|
|
96
|
+
myManagedList: [{ id: '12345', q1: 'a1' }]
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
it('should add to an existing managed list item using the id when present in the session', async () => {
|
|
102
|
+
const req = mockReq();
|
|
103
|
+
req.params = { manageListItemId: '12345' };
|
|
104
|
+
req.session = {
|
|
105
|
+
forms: {
|
|
106
|
+
'j-1': {
|
|
107
|
+
myManagedList: [{ id: '12345', q1: 'old value' }]
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
const journeyId = 'j-1';
|
|
112
|
+
const data = { answers: { q2: 'a1' } };
|
|
113
|
+
const isManageListItem = true;
|
|
114
|
+
const manageListQuestionFieldName = 'myManagedList';
|
|
115
|
+
const saveDataToSession = buildSaveDataToSession();
|
|
116
|
+
await saveDataToSession({ req, journeyId, data, isManageListItem, manageListQuestionFieldName });
|
|
117
|
+
assert.deepStrictEqual(req.session, {
|
|
118
|
+
forms: {
|
|
119
|
+
'j-1': {
|
|
120
|
+
myManagedList: [{ id: '12345', q1: 'old value', q2: 'a1' }]
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
});
|
|
83
125
|
|
|
84
126
|
it('should overwrite existing data by reqParam & journeyId', async () => {
|
|
85
127
|
const req = mockReq();
|
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) {
|