@planning-inspectorate/dynamic-forms 3.3.3 → 3.3.4
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/.gitattributes +2 -0
- package/package.json +5 -3
- package/src/components/manage-list/question.test.js +4 -19
- package/src/components/text-entry-redact/index.njk +15 -11
- package/test/check-your-answers.test.js +3 -8
- package/test/conditional-journey.test.js +431 -0
- package/test/questions.test.js +15 -28
- package/test/snapshots/secret-wish.html +15 -11
- package/test/utils/question-test-utils.js +52 -4
- package/test/utils/test-server.js +1 -0
- package/test/utils/utils.js +16 -1
package/.gitattributes
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@planning-inspectorate/dynamic-forms",
|
|
3
|
-
"version": "3.3.
|
|
3
|
+
"version": "3.3.4",
|
|
4
4
|
"description": "This package is for building GDS forms using a configuration-based approach.",
|
|
5
5
|
"main": "./src/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -33,14 +33,16 @@
|
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"@commitlint/cli": "^20.0.0",
|
|
35
35
|
"@commitlint/config-conventional": "^20.0.0",
|
|
36
|
+
"@eslint/js": "^10.0.1",
|
|
36
37
|
"body-parser": "^2.2.0",
|
|
37
38
|
"conventional-changelog-conventionalcommits": "^9.1.0",
|
|
38
39
|
"cookie-parser": "^1.4.7",
|
|
39
|
-
"eslint": "^
|
|
40
|
+
"eslint": "^10.0.0",
|
|
40
41
|
"eslint-config-prettier": "^10.1.5",
|
|
41
42
|
"express": "^5.1.0",
|
|
42
43
|
"express-session": "^1.18.2",
|
|
43
|
-
"
|
|
44
|
+
"globals": "^17.3.0",
|
|
45
|
+
"govuk-frontend": "^6.0.0",
|
|
44
46
|
"husky": "^9.1.7",
|
|
45
47
|
"lint-staged": "^16.1.2",
|
|
46
48
|
"prettier": "^3.6.2",
|
|
@@ -5,8 +5,7 @@ import { ManageListSection } from '#src/components/manage-list/manage-list-secti
|
|
|
5
5
|
import { mockJourney } from '#test/mock/journey.js';
|
|
6
6
|
import { mockRandomUUID } from '#test/mock/uuid.js';
|
|
7
7
|
import { configureNunjucksTestEnv } from '#test/utils/nunjucks.js';
|
|
8
|
-
import
|
|
9
|
-
import { snapshotsDir } from '#test/utils/utils.js';
|
|
8
|
+
import { assertSnapshot } from '#test/utils/utils.js';
|
|
10
9
|
|
|
11
10
|
describe('components/manage-list/question', () => {
|
|
12
11
|
const TITLE = 'Things';
|
|
@@ -96,13 +95,7 @@ describe('components/manage-list/question', () => {
|
|
|
96
95
|
const { q } = questionWithManageQuestions(ctx, { showAnswersInSummary: true });
|
|
97
96
|
const answerForSummary = q.formatAnswerForSummary('section-1', mockJourney(), [{}, {}, {}]);
|
|
98
97
|
assert.strictEqual(answerForSummary.length, 1);
|
|
99
|
-
ctx.
|
|
100
|
-
answerForSummary[0].value.replaceAll('\r\n', '\n'),
|
|
101
|
-
path.join(snapshotsDir(), 'manage-list-answer-summary.html'),
|
|
102
|
-
{
|
|
103
|
-
serializers: [(v) => v]
|
|
104
|
-
}
|
|
105
|
-
);
|
|
98
|
+
assertSnapshot(ctx, answerForSummary[0].value, 'manage-list-answer-summary.html');
|
|
106
99
|
});
|
|
107
100
|
|
|
108
101
|
it('should render with answers', (ctx) => {
|
|
@@ -114,9 +107,7 @@ describe('components/manage-list/question', () => {
|
|
|
114
107
|
};
|
|
115
108
|
q.renderAction(res, viewModel);
|
|
116
109
|
assert.strictEqual(res.render.mock.callCount(), 1);
|
|
117
|
-
ctx
|
|
118
|
-
serializers: [(v) => v]
|
|
119
|
-
});
|
|
110
|
+
assertSnapshot(ctx, res.render.mock.calls[0].result, 'manage-list-render.html');
|
|
120
111
|
});
|
|
121
112
|
|
|
122
113
|
it('should render with answers & questions', (ctx) => {
|
|
@@ -130,13 +121,7 @@ describe('components/manage-list/question', () => {
|
|
|
130
121
|
};
|
|
131
122
|
q.renderAction(res, viewModel);
|
|
132
123
|
assert.strictEqual(res.render.mock.callCount(), 1);
|
|
133
|
-
ctx.
|
|
134
|
-
res.render.mock.calls[0].result,
|
|
135
|
-
path.join(snapshotsDir(), 'manage-list-render-questions.html'),
|
|
136
|
-
{
|
|
137
|
-
serializers: [(v) => v]
|
|
138
|
-
}
|
|
139
|
-
);
|
|
124
|
+
assertSnapshot(ctx, res.render.mock.calls[0].result, 'manage-list-render-questions.html');
|
|
140
125
|
});
|
|
141
126
|
|
|
142
127
|
describe('getDataToSave', () => {
|
|
@@ -123,7 +123,7 @@
|
|
|
123
123
|
}) }}
|
|
124
124
|
|
|
125
125
|
{{ govukButton({
|
|
126
|
-
text: "
|
|
126
|
+
text: "Remove all redactions",
|
|
127
127
|
classes: "govuk-button--secondary",
|
|
128
128
|
id: "redact-undo-all",
|
|
129
129
|
type: "button"
|
|
@@ -192,16 +192,20 @@
|
|
|
192
192
|
showError('No changes to undo');
|
|
193
193
|
}
|
|
194
194
|
});
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
195
|
+
redactUndoAll.addEventListener('click', () => {
|
|
196
|
+
const unredated = unredactedText.dataset.original;
|
|
197
|
+
if (history.length === 0 && textArea.value === unredated) {
|
|
198
|
+
showError('No changes to undo');
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
hideError();
|
|
202
|
+
textArea.value = unredated || '';
|
|
203
|
+
history.length = 0;
|
|
204
|
+
|
|
205
|
+
for (const rejectLink of rejectLinks) {
|
|
206
|
+
rejectLink.text = 'Accept';
|
|
207
|
+
}
|
|
208
|
+
});
|
|
205
209
|
for (const rejectLink of rejectLinks) {
|
|
206
210
|
rejectLink.addEventListener('click', () => {
|
|
207
211
|
const index = parseInt(rejectLink.getAttribute('data-redact').replace('suggestion-reject-', ''));
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { describe, it } from 'node:test';
|
|
2
2
|
import assert from 'assert';
|
|
3
3
|
import { manageListQuestions, questionsInOrder } from './questions.js';
|
|
4
|
-
import
|
|
5
|
-
import { escapeForRegExp, snapshotsDir } from './utils/utils.js';
|
|
4
|
+
import { assertSnapshot, escapeForRegExp } from './utils/utils.js';
|
|
6
5
|
import { createAppWithQuestions, mockAnswer, mockAnswerBody } from './utils/question-test-utils.js';
|
|
7
6
|
import { COMPONENT_TYPES } from '#src/index.js';
|
|
8
7
|
|
|
@@ -30,9 +29,7 @@ describe('check-your-answers', () => {
|
|
|
30
29
|
new RegExp(`<a[^>]* href="questions/${q.url}">\\s*Answer\\s*<span[^>]*>\\s*${escapedQ}\\s*</span></a>`, 'i')
|
|
31
30
|
);
|
|
32
31
|
}
|
|
33
|
-
ctx
|
|
34
|
-
serializers: [(v) => v]
|
|
35
|
-
});
|
|
32
|
+
assertSnapshot(ctx, text, 'check-your-answers-not-started.html');
|
|
36
33
|
});
|
|
37
34
|
|
|
38
35
|
it(`should render all question answers`, async (ctx) => {
|
|
@@ -80,8 +77,6 @@ describe('check-your-answers', () => {
|
|
|
80
77
|
new RegExp(`<a[^>]* href="questions/${q.url}">\\s*Change\\s*<span[^>]*>\\s*${escapedQ}\\s*</span></a>`, 'i')
|
|
81
78
|
);
|
|
82
79
|
}
|
|
83
|
-
ctx
|
|
84
|
-
serializers: [(v) => v]
|
|
85
|
-
});
|
|
80
|
+
assertSnapshot(ctx, text, 'check-your-answers-with-answers.html');
|
|
86
81
|
});
|
|
87
82
|
});
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'assert';
|
|
3
|
+
import { Section } from '#src/section.js';
|
|
4
|
+
import { Journey } from '#src/journey/journey.js';
|
|
5
|
+
import { COMPONENT_TYPES } from '#src/index.js';
|
|
6
|
+
import { createQuestions } from '#src/questions/create-questions.js';
|
|
7
|
+
import { questionClasses } from '#src/questions/questions.js';
|
|
8
|
+
import { whenQuestionHasAnswer } from '#src/components/utils/question-has-answer.js';
|
|
9
|
+
import { BOOLEAN_OPTIONS } from '#src/components/boolean/question.js';
|
|
10
|
+
import { createAppWithQuestions, renderQuestionCheck, postAnswer } from '#test/utils/question-test-utils.js';
|
|
11
|
+
|
|
12
|
+
const JOURNEY_ID = 'conditional-journey';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Question props for conditional journey tests
|
|
16
|
+
* @type {Record<string, import('#src/questions/question-props.js').QuestionProps>}
|
|
17
|
+
*/
|
|
18
|
+
const conditionalQuestionProps = {
|
|
19
|
+
// Section 1: Insurance (simple + chained conditions)
|
|
20
|
+
wantsInsurance: {
|
|
21
|
+
type: COMPONENT_TYPES.BOOLEAN,
|
|
22
|
+
title: 'Want Insurance',
|
|
23
|
+
question: 'Do you want to add travel insurance?',
|
|
24
|
+
fieldName: 'wantsInsurance',
|
|
25
|
+
url: 'wants-insurance',
|
|
26
|
+
label: 'Add insurance?'
|
|
27
|
+
},
|
|
28
|
+
insuranceLevel: {
|
|
29
|
+
type: COMPONENT_TYPES.RADIO,
|
|
30
|
+
title: 'Insurance Level',
|
|
31
|
+
question: 'What level of insurance do you want?',
|
|
32
|
+
fieldName: 'insuranceLevel',
|
|
33
|
+
url: 'insurance-level',
|
|
34
|
+
label: 'Select an insurance level',
|
|
35
|
+
options: [
|
|
36
|
+
{ value: 'basic', text: 'Basic' },
|
|
37
|
+
{ value: 'premium', text: 'Premium' }
|
|
38
|
+
]
|
|
39
|
+
},
|
|
40
|
+
premiumBenefits: {
|
|
41
|
+
type: COMPONENT_TYPES.CHECKBOX,
|
|
42
|
+
title: 'Premium Benefits',
|
|
43
|
+
question: 'Which premium benefits do you want?',
|
|
44
|
+
fieldName: 'premiumBenefits',
|
|
45
|
+
url: 'premium-benefits',
|
|
46
|
+
label: 'Select all that apply',
|
|
47
|
+
options: [
|
|
48
|
+
{ value: 'cancellation', text: 'Cancellation cover' },
|
|
49
|
+
{ value: 'medical', text: 'Medical cover' },
|
|
50
|
+
{ value: 'luggage', text: 'Luggage cover' }
|
|
51
|
+
]
|
|
52
|
+
},
|
|
53
|
+
// Section 2: Trip (leads to section condition)
|
|
54
|
+
tripType: {
|
|
55
|
+
type: COMPONENT_TYPES.RADIO,
|
|
56
|
+
title: 'Trip Type',
|
|
57
|
+
question: 'What type of trip are you planning?',
|
|
58
|
+
fieldName: 'tripType',
|
|
59
|
+
url: 'trip-type',
|
|
60
|
+
label: 'Select a trip type',
|
|
61
|
+
options: [
|
|
62
|
+
{ value: 'domestic', text: 'Domestic' },
|
|
63
|
+
{ value: 'international', text: 'International' }
|
|
64
|
+
]
|
|
65
|
+
},
|
|
66
|
+
// Section 3: Visa (section-wide condition based on tripType)
|
|
67
|
+
visaRequired: {
|
|
68
|
+
type: COMPONENT_TYPES.BOOLEAN,
|
|
69
|
+
title: 'Visa Required',
|
|
70
|
+
question: 'Do you need a visa for your destination?',
|
|
71
|
+
fieldName: 'visaRequired',
|
|
72
|
+
url: 'visa-required',
|
|
73
|
+
label: 'Visa required?'
|
|
74
|
+
},
|
|
75
|
+
visaType: {
|
|
76
|
+
type: COMPONENT_TYPES.RADIO,
|
|
77
|
+
title: 'Visa Type',
|
|
78
|
+
question: 'What type of visa do you need?',
|
|
79
|
+
fieldName: 'visaType',
|
|
80
|
+
url: 'visa-type',
|
|
81
|
+
label: 'Select a visa type',
|
|
82
|
+
options: [
|
|
83
|
+
{ value: 'tourist', text: 'Tourist' },
|
|
84
|
+
{ value: 'business', text: 'Business' }
|
|
85
|
+
]
|
|
86
|
+
},
|
|
87
|
+
// Section 4: Accommodation (multi-question condition)
|
|
88
|
+
accommodation: {
|
|
89
|
+
type: COMPONENT_TYPES.RADIO,
|
|
90
|
+
title: 'Accommodation',
|
|
91
|
+
question: 'Where will you stay?',
|
|
92
|
+
fieldName: 'accommodation',
|
|
93
|
+
url: 'accommodation',
|
|
94
|
+
label: 'Select accommodation type',
|
|
95
|
+
options: [
|
|
96
|
+
{ value: 'hotel', text: 'Hotel' },
|
|
97
|
+
{ value: 'hostel', text: 'Hostel' },
|
|
98
|
+
{ value: 'apartment', text: 'Apartment' }
|
|
99
|
+
]
|
|
100
|
+
},
|
|
101
|
+
hotelStars: {
|
|
102
|
+
type: COMPONENT_TYPES.RADIO,
|
|
103
|
+
title: 'Hotel Stars',
|
|
104
|
+
question: 'How many stars for your hotel?',
|
|
105
|
+
fieldName: 'hotelStars',
|
|
106
|
+
url: 'hotel-stars',
|
|
107
|
+
label: 'Select star rating',
|
|
108
|
+
options: [
|
|
109
|
+
{ value: '3', text: '3 stars' },
|
|
110
|
+
{ value: '4', text: '4 stars' },
|
|
111
|
+
{ value: '5', text: '5 stars' }
|
|
112
|
+
]
|
|
113
|
+
},
|
|
114
|
+
hotelBreakfast: {
|
|
115
|
+
type: COMPONENT_TYPES.BOOLEAN,
|
|
116
|
+
title: 'Hotel Breakfast',
|
|
117
|
+
question: 'Do you want breakfast included?',
|
|
118
|
+
fieldName: 'hotelBreakfast',
|
|
119
|
+
url: 'hotel-breakfast',
|
|
120
|
+
label: 'Include breakfast?'
|
|
121
|
+
},
|
|
122
|
+
// Section 5: Contact (always shown)
|
|
123
|
+
contactEmail: {
|
|
124
|
+
type: COMPONENT_TYPES.EMAIL,
|
|
125
|
+
title: 'Contact Email',
|
|
126
|
+
question: 'What is your email address?',
|
|
127
|
+
fieldName: 'contactEmail',
|
|
128
|
+
url: 'contact-email',
|
|
129
|
+
label: 'Email address'
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Create the questions for conditional tests
|
|
135
|
+
*/
|
|
136
|
+
const conditionalQuestions = createQuestions(conditionalQuestionProps, questionClasses, {});
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Create a single journey with various conditional logic scenarios:
|
|
140
|
+
*
|
|
141
|
+
* Section 1 - Insurance (simple + chained conditions):
|
|
142
|
+
* - wantsInsurance: always shown
|
|
143
|
+
* - insuranceLevel: shown if wantsInsurance = 'yes' (simple condition)
|
|
144
|
+
* - premiumBenefits: shown if insuranceLevel = 'premium' (chained condition)
|
|
145
|
+
*
|
|
146
|
+
* Section 2 - Trip:
|
|
147
|
+
* - tripType: always shown
|
|
148
|
+
*
|
|
149
|
+
* Section 3 - Visa (section-wide condition):
|
|
150
|
+
* - Entire section shown only if tripType = 'international'
|
|
151
|
+
* - visaRequired: shown if section condition met
|
|
152
|
+
* - visaType: shown if section condition met AND visaRequired = 'yes'
|
|
153
|
+
*
|
|
154
|
+
* Section 4 - Accommodation (multi-question condition):
|
|
155
|
+
* - accommodation: always shown
|
|
156
|
+
* - hotelStars + hotelBreakfast: shown only if accommodation = 'hotel' (multi-question group)
|
|
157
|
+
*
|
|
158
|
+
* Section 5 - Contact:
|
|
159
|
+
* - contactEmail: always shown
|
|
160
|
+
*/
|
|
161
|
+
function createConditionalJourney(questions, response) {
|
|
162
|
+
return new Journey({
|
|
163
|
+
journeyId: JOURNEY_ID,
|
|
164
|
+
sections: [
|
|
165
|
+
// Section 1: Insurance with simple and chained conditions
|
|
166
|
+
new Section('Insurance', 'insurance')
|
|
167
|
+
.addQuestion(questions.wantsInsurance)
|
|
168
|
+
.addQuestion(questions.insuranceLevel)
|
|
169
|
+
.withCondition(whenQuestionHasAnswer(questions.wantsInsurance, BOOLEAN_OPTIONS.YES))
|
|
170
|
+
.addQuestion(questions.premiumBenefits)
|
|
171
|
+
.withCondition(whenQuestionHasAnswer(questions.insuranceLevel, 'premium')),
|
|
172
|
+
|
|
173
|
+
// Section 2: Trip type (determines section condition for visa)
|
|
174
|
+
new Section('Trip Details', 'trip').addQuestion(questions.tripType),
|
|
175
|
+
|
|
176
|
+
// Section 3: Visa with section-wide condition
|
|
177
|
+
new Section('Visa', 'visa')
|
|
178
|
+
.withSectionCondition(whenQuestionHasAnswer(questions.tripType, 'international'))
|
|
179
|
+
.addQuestion(questions.visaRequired)
|
|
180
|
+
.addQuestion(questions.visaType)
|
|
181
|
+
.withCondition(whenQuestionHasAnswer(questions.visaRequired, BOOLEAN_OPTIONS.YES)),
|
|
182
|
+
|
|
183
|
+
// Section 4: Accommodation with multi-question condition
|
|
184
|
+
new Section('Accommodation', 'accommodation')
|
|
185
|
+
.addQuestion(questions.accommodation)
|
|
186
|
+
.startMultiQuestionCondition('hotel-group', whenQuestionHasAnswer(questions.accommodation, 'hotel'))
|
|
187
|
+
.addQuestion(questions.hotelStars)
|
|
188
|
+
.addQuestion(questions.hotelBreakfast)
|
|
189
|
+
.endMultiQuestionCondition('hotel-group'),
|
|
190
|
+
|
|
191
|
+
// Section 5: Contact (always shown)
|
|
192
|
+
new Section('Contact', 'contact').addQuestion(questions.contactEmail)
|
|
193
|
+
],
|
|
194
|
+
taskListUrl: 'check-your-answers',
|
|
195
|
+
journeyTemplate: 'views/layout-journey.njk',
|
|
196
|
+
taskListTemplate: 'views/layout-check-your-answers.njk',
|
|
197
|
+
journeyTitle: 'Conditional Journey',
|
|
198
|
+
returnToListing: false,
|
|
199
|
+
makeBaseUrl: () => '/',
|
|
200
|
+
initialBackLink: '/start',
|
|
201
|
+
response
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Helper to create an app with the conditional journey
|
|
207
|
+
* @param {import('node:test').TestContext} ctx
|
|
208
|
+
* @returns {Promise<import('#test/utils/test-server.js').TestServer>}
|
|
209
|
+
*/
|
|
210
|
+
function createAppWithJourney(ctx) {
|
|
211
|
+
return createAppWithQuestions(ctx, {
|
|
212
|
+
journeyId: JOURNEY_ID,
|
|
213
|
+
questions: conditionalQuestions,
|
|
214
|
+
createJourneyFn: createConditionalJourney
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const q = conditionalQuestionProps;
|
|
219
|
+
|
|
220
|
+
describe('conditional journey tests', () => {
|
|
221
|
+
describe('simple condition (whenQuestionHasAnswer on single question)', () => {
|
|
222
|
+
it('should show conditional question when condition is met', async (ctx) => {
|
|
223
|
+
const testServer = await createAppWithJourney(ctx);
|
|
224
|
+
|
|
225
|
+
// First, render the initial question
|
|
226
|
+
await renderQuestionCheck(ctx, testServer, '/insurance/wants-insurance', q.wantsInsurance.question);
|
|
227
|
+
|
|
228
|
+
// Answer 'yes' to wantsInsurance
|
|
229
|
+
const location = await postAnswer(testServer, '/insurance/wants-insurance', {
|
|
230
|
+
[q.wantsInsurance.fieldName]: BOOLEAN_OPTIONS.YES
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// Should redirect to insuranceLevel (conditional question)
|
|
234
|
+
assert.strictEqual(location, 'insurance/insurance-level');
|
|
235
|
+
|
|
236
|
+
// Render the conditional question
|
|
237
|
+
await renderQuestionCheck(ctx, testServer, '/insurance/insurance-level', q.insuranceLevel.question);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('should skip conditional question when condition is not met', async (ctx) => {
|
|
241
|
+
const testServer = await createAppWithJourney(ctx);
|
|
242
|
+
|
|
243
|
+
// Answer 'no' to wantsInsurance
|
|
244
|
+
const location = await postAnswer(testServer, '/insurance/wants-insurance', {
|
|
245
|
+
[q.wantsInsurance.fieldName]: BOOLEAN_OPTIONS.NO
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// Should skip insuranceLevel and premiumBenefits, go to next section (trip)
|
|
249
|
+
assert.strictEqual(location, 'trip/trip-type');
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
describe('chained conditions (multiple dependent conditions)', () => {
|
|
254
|
+
it('should show both conditional questions when all conditions are met', async (ctx) => {
|
|
255
|
+
const testServer = await createAppWithJourney(ctx);
|
|
256
|
+
|
|
257
|
+
// Answer 'yes' to wantsInsurance
|
|
258
|
+
await postAnswer(testServer, '/insurance/wants-insurance', {
|
|
259
|
+
[q.wantsInsurance.fieldName]: BOOLEAN_OPTIONS.YES
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// Answer 'premium' to insuranceLevel
|
|
263
|
+
const location = await postAnswer(testServer, '/insurance/insurance-level', {
|
|
264
|
+
[q.insuranceLevel.fieldName]: 'premium'
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// Should go to premiumBenefits
|
|
268
|
+
assert.strictEqual(location, 'insurance/premium-benefits');
|
|
269
|
+
|
|
270
|
+
// Render the premium benefits question
|
|
271
|
+
await renderQuestionCheck(ctx, testServer, '/insurance/premium-benefits', q.premiumBenefits.question);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('should skip second conditional question when its condition is not met', async (ctx) => {
|
|
275
|
+
const testServer = await createAppWithJourney(ctx);
|
|
276
|
+
|
|
277
|
+
// Answer 'yes' to wantsInsurance
|
|
278
|
+
await postAnswer(testServer, '/insurance/wants-insurance', {
|
|
279
|
+
[q.wantsInsurance.fieldName]: BOOLEAN_OPTIONS.YES
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// Answer 'basic' to insuranceLevel (not 'premium')
|
|
283
|
+
const location = await postAnswer(testServer, '/insurance/insurance-level', {
|
|
284
|
+
[q.insuranceLevel.fieldName]: 'basic'
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// Should skip premiumBenefits and go to next section (trip)
|
|
288
|
+
assert.strictEqual(location, 'trip/trip-type');
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('should skip all conditional questions when first condition is not met', async (ctx) => {
|
|
292
|
+
const testServer = await createAppWithJourney(ctx);
|
|
293
|
+
|
|
294
|
+
// Answer 'no' to wantsInsurance
|
|
295
|
+
const location = await postAnswer(testServer, '/insurance/wants-insurance', {
|
|
296
|
+
[q.wantsInsurance.fieldName]: BOOLEAN_OPTIONS.NO
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// Should skip insuranceLevel and premiumBenefits, go to next section (trip)
|
|
300
|
+
assert.strictEqual(location, 'trip/trip-type');
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
describe('section-wide conditions (withSectionCondition)', () => {
|
|
305
|
+
it('should show section questions when section condition is met', async (ctx) => {
|
|
306
|
+
const testServer = await createAppWithJourney(ctx);
|
|
307
|
+
|
|
308
|
+
// Answer 'international' to tripType
|
|
309
|
+
const location = await postAnswer(testServer, '/trip/trip-type', {
|
|
310
|
+
[q.tripType.fieldName]: 'international'
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// Should go to visa section (visaRequired)
|
|
314
|
+
assert.strictEqual(location, 'visa/visa-required');
|
|
315
|
+
|
|
316
|
+
// Render the visa question
|
|
317
|
+
await renderQuestionCheck(ctx, testServer, '/visa/visa-required', q.visaRequired.question);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('should skip entire section when section condition is not met', async (ctx) => {
|
|
321
|
+
const testServer = await createAppWithJourney(ctx);
|
|
322
|
+
|
|
323
|
+
// Answer 'domestic' to tripType
|
|
324
|
+
const location = await postAnswer(testServer, '/trip/trip-type', {
|
|
325
|
+
[q.tripType.fieldName]: 'domestic'
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// Should skip visa section and go to accommodation section
|
|
329
|
+
assert.strictEqual(location, 'accommodation/accommodation');
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('should combine section condition with question condition', async (ctx) => {
|
|
333
|
+
const testServer = await createAppWithJourney(ctx);
|
|
334
|
+
|
|
335
|
+
// Answer 'international' to tripType
|
|
336
|
+
await postAnswer(testServer, '/trip/trip-type', {
|
|
337
|
+
[q.tripType.fieldName]: 'international'
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// Answer 'yes' to visaRequired
|
|
341
|
+
const location = await postAnswer(testServer, '/visa/visa-required', {
|
|
342
|
+
[q.visaRequired.fieldName]: BOOLEAN_OPTIONS.YES
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// Should go to visaType (condition met)
|
|
346
|
+
assert.strictEqual(location, 'visa/visa-type');
|
|
347
|
+
|
|
348
|
+
// Render the visa type question
|
|
349
|
+
await renderQuestionCheck(ctx, testServer, '/visa/visa-type', q.visaType.question);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('should skip conditional question within section when its condition is not met', async (ctx) => {
|
|
353
|
+
const testServer = await createAppWithJourney(ctx);
|
|
354
|
+
|
|
355
|
+
// Answer 'international' to tripType
|
|
356
|
+
await postAnswer(testServer, '/trip/trip-type', {
|
|
357
|
+
[q.tripType.fieldName]: 'international'
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// Answer 'no' to visaRequired
|
|
361
|
+
const location = await postAnswer(testServer, '/visa/visa-required', {
|
|
362
|
+
[q.visaRequired.fieldName]: BOOLEAN_OPTIONS.NO
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// Should skip visaType and go to accommodation section
|
|
366
|
+
assert.strictEqual(location, 'accommodation/accommodation');
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
describe('multi-question conditions (startMultiQuestionCondition)', () => {
|
|
371
|
+
it('should show all questions in group when condition is met', async (ctx) => {
|
|
372
|
+
const testServer = await createAppWithJourney(ctx);
|
|
373
|
+
|
|
374
|
+
// Answer 'hotel' to accommodation
|
|
375
|
+
const location = await postAnswer(testServer, '/accommodation/accommodation', {
|
|
376
|
+
[q.accommodation.fieldName]: 'hotel'
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// Should go to hotelStars (first question in group)
|
|
380
|
+
assert.strictEqual(location, 'accommodation/hotel-stars');
|
|
381
|
+
|
|
382
|
+
// Render the hotel stars question
|
|
383
|
+
await renderQuestionCheck(ctx, testServer, '/accommodation/hotel-stars', q.hotelStars.question);
|
|
384
|
+
|
|
385
|
+
// Answer hotelStars
|
|
386
|
+
const nextLocation = await postAnswer(testServer, '/accommodation/hotel-stars', {
|
|
387
|
+
[q.hotelStars.fieldName]: '4'
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
// Should go to hotelBreakfast (second question in group)
|
|
391
|
+
assert.strictEqual(nextLocation, 'accommodation/hotel-breakfast');
|
|
392
|
+
|
|
393
|
+
// Render the hotel breakfast question
|
|
394
|
+
await renderQuestionCheck(ctx, testServer, '/accommodation/hotel-breakfast', q.hotelBreakfast.question);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('should skip all questions in group when condition is not met', async (ctx) => {
|
|
398
|
+
const testServer = await createAppWithJourney(ctx);
|
|
399
|
+
|
|
400
|
+
// Answer 'hostel' to accommodation (not 'hotel')
|
|
401
|
+
const location = await postAnswer(testServer, '/accommodation/accommodation', {
|
|
402
|
+
[q.accommodation.fieldName]: 'hostel'
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
// Should skip hotelStars and hotelBreakfast, go to contact section
|
|
406
|
+
assert.strictEqual(location, 'contact/contact-email');
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it('should proceed to question after group ends', async (ctx) => {
|
|
410
|
+
const testServer = await createAppWithJourney(ctx);
|
|
411
|
+
|
|
412
|
+
// Answer 'hotel' to accommodation
|
|
413
|
+
await postAnswer(testServer, '/accommodation/accommodation', {
|
|
414
|
+
[q.accommodation.fieldName]: 'hotel'
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// Answer hotelStars
|
|
418
|
+
await postAnswer(testServer, '/accommodation/hotel-stars', {
|
|
419
|
+
[q.hotelStars.fieldName]: '5'
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
// Answer hotelBreakfast (last in group)
|
|
423
|
+
const location = await postAnswer(testServer, '/accommodation/hotel-breakfast', {
|
|
424
|
+
[q.hotelBreakfast.fieldName]: BOOLEAN_OPTIONS.YES
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
// Should go to contact section (after group ends)
|
|
428
|
+
assert.strictEqual(location, 'contact/contact-email');
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
});
|
package/test/questions.test.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { describe, it } from 'node:test';
|
|
2
2
|
import assert from 'assert';
|
|
3
3
|
import { manageListQuestions, questionsInOrder } from './questions.js';
|
|
4
|
-
import path from 'path';
|
|
5
4
|
import { COMPONENT_TYPES } from '../src/index.js';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
5
|
+
import { assertSnapshot } from './utils/utils.js';
|
|
6
|
+
import {
|
|
7
|
+
createAppWithQuestions,
|
|
8
|
+
mockAnswerBody,
|
|
9
|
+
renderQuestionCheck,
|
|
10
|
+
postAnswer
|
|
11
|
+
} from '#test/utils/question-test-utils.js';
|
|
9
12
|
|
|
10
13
|
describe('question pages', () => {
|
|
11
14
|
it('should have a test for each question type', () => {
|
|
@@ -31,18 +34,11 @@ describe('question pages', () => {
|
|
|
31
34
|
* @param {import('#src/questions/question.js')} q
|
|
32
35
|
* @returns {Promise<void>}
|
|
33
36
|
*/
|
|
34
|
-
async function
|
|
35
|
-
mockRandomUUID(ctx);
|
|
37
|
+
async function renderQuestionAndSnapshot(ctx, url, snapshotName, q) {
|
|
36
38
|
const testServer = await createAppWithQuestions(ctx);
|
|
39
|
+
const text = await renderQuestionCheck(ctx, testServer, url, q.question);
|
|
37
40
|
|
|
38
|
-
|
|
39
|
-
redirect: 'manual'
|
|
40
|
-
});
|
|
41
|
-
assert.strictEqual(response.status, 200);
|
|
42
|
-
const text = await response.text();
|
|
43
|
-
// be sure to escape parentheses in the question text for the regex
|
|
44
|
-
assert.match(text, new RegExp(escapeForRegExp(q.question), 'i'));
|
|
45
|
-
assert.match(text, /<form action="" method="post"/i);
|
|
41
|
+
// Check options if present
|
|
46
42
|
if (q.options) {
|
|
47
43
|
for (const option of q.options) {
|
|
48
44
|
if (q.type === COMPONENT_TYPES.SELECT) {
|
|
@@ -56,9 +52,8 @@ describe('question pages', () => {
|
|
|
56
52
|
}
|
|
57
53
|
}
|
|
58
54
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
});
|
|
55
|
+
|
|
56
|
+
assertSnapshot(ctx, text, snapshotName + '.html');
|
|
62
57
|
}
|
|
63
58
|
|
|
64
59
|
/**
|
|
@@ -70,22 +65,14 @@ describe('question pages', () => {
|
|
|
70
65
|
async function postQuestionCheck(ctx, url, q) {
|
|
71
66
|
const testServer = await createAppWithQuestions(ctx);
|
|
72
67
|
const payload = mockAnswerBody(q);
|
|
73
|
-
|
|
74
|
-
if (![302, 303].includes(response.status)) {
|
|
75
|
-
const text = await response.text();
|
|
76
|
-
console.log(`Response for ${q.url}:\n`, text);
|
|
77
|
-
}
|
|
78
|
-
// Should redirect (302 or 303)
|
|
79
|
-
assert.ok([302, 303].includes(response.status), `Expected redirect, got ${response.status}`);
|
|
80
|
-
// Should redirect to the next question (if there is one)
|
|
81
|
-
return response.headers.get('location');
|
|
68
|
+
return postAnswer(testServer, url, payload);
|
|
82
69
|
}
|
|
83
70
|
|
|
84
71
|
for (let i = 0; i < questionsInOrder.length; i++) {
|
|
85
72
|
const q = questionsInOrder[i];
|
|
86
73
|
|
|
87
74
|
it(`should render question: ${q.url}`, async (ctx) => {
|
|
88
|
-
await
|
|
75
|
+
await renderQuestionAndSnapshot(ctx, '/questions/' + q.url, q.url, q);
|
|
89
76
|
});
|
|
90
77
|
|
|
91
78
|
it(`should POST valid data for question: ${q.url} and redirect to next`, async (ctx) => {
|
|
@@ -105,7 +92,7 @@ describe('question pages', () => {
|
|
|
105
92
|
for (let i = 0; i < questions.length; i++) {
|
|
106
93
|
const q = questions[i];
|
|
107
94
|
it(`should render question: ${q.url}`, async (ctx) => {
|
|
108
|
-
await
|
|
95
|
+
await renderQuestionAndSnapshot(ctx, '/' + qUrl(q), manageListQuestion.url + '_' + q.url, q);
|
|
109
96
|
});
|
|
110
97
|
|
|
111
98
|
it(`should POST valid data for question: ${q.url} and redirect to next`, async (ctx) => {
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
</button>
|
|
44
44
|
|
|
45
45
|
<button type="button" class="govuk-button govuk-button--secondary" data-module="govuk-button" id="redact-undo-all">
|
|
46
|
-
|
|
46
|
+
Remove all redactions
|
|
47
47
|
</button>
|
|
48
48
|
</div>
|
|
49
49
|
|
|
@@ -107,16 +107,20 @@
|
|
|
107
107
|
showError('No changes to undo');
|
|
108
108
|
}
|
|
109
109
|
});
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
110
|
+
redactUndoAll.addEventListener('click', () => {
|
|
111
|
+
const unredated = unredactedText.dataset.original;
|
|
112
|
+
if (history.length === 0 && textArea.value === unredated) {
|
|
113
|
+
showError('No changes to undo');
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
hideError();
|
|
117
|
+
textArea.value = unredated || '';
|
|
118
|
+
history.length = 0;
|
|
119
|
+
|
|
120
|
+
for (const rejectLink of rejectLinks) {
|
|
121
|
+
rejectLink.text = 'Accept';
|
|
122
|
+
}
|
|
123
|
+
});
|
|
120
124
|
for (const rejectLink of rejectLinks) {
|
|
121
125
|
rejectLink.addEventListener('click', () => {
|
|
122
126
|
const index = parseInt(rejectLink.getAttribute('data-redact').replace('suggestion-reject-', ''));
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import assert from 'assert';
|
|
1
2
|
import { getQuestions } from '#test/questions.js';
|
|
2
3
|
import { COMPONENT_TYPES } from '#src/index.js';
|
|
3
4
|
import { createApp } from '#test/utils/app.js';
|
|
@@ -9,16 +10,28 @@ import validate from '#src/validator/validator.js';
|
|
|
9
10
|
import { validationErrorHandler } from '#src/validator/validation-error-handler.js';
|
|
10
11
|
import { TestServer } from '#test/utils/test-server.js';
|
|
11
12
|
import { BOOLEAN_OPTIONS } from '#src/components/boolean/question.js';
|
|
13
|
+
import { escapeForRegExp } from '#test/utils/utils.js';
|
|
14
|
+
import { mockRandomUUID } from '#test/mock/uuid.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {Object} CreateAppOptions
|
|
18
|
+
* @property {string} journeyId - The journey ID for session storage
|
|
19
|
+
* @property {(questions: Object, response: import('#src/journey/journey-response.js').JourneyResponse) => import('#src/journey/journey.js').Journey} createJourneyFn - Function to create the journey
|
|
20
|
+
* @property {Object} questions - The questions object
|
|
21
|
+
*/
|
|
12
22
|
|
|
13
23
|
/**
|
|
14
24
|
* @param {import('node:test').TestContext} ctx
|
|
25
|
+
* @param {CreateAppOptions} [options]
|
|
15
26
|
* @returns {Promise<TestServer>}
|
|
16
27
|
*/
|
|
17
|
-
export async function createAppWithQuestions(ctx) {
|
|
28
|
+
export async function createAppWithQuestions(ctx, options) {
|
|
18
29
|
const app = createApp();
|
|
19
|
-
const questions = getQuestions();
|
|
20
|
-
const
|
|
21
|
-
const
|
|
30
|
+
const questions = options?.questions ?? getQuestions();
|
|
31
|
+
const journeyId = options?.journeyId ?? JOURNEY_ID;
|
|
32
|
+
const createJourneyFn = options?.createJourneyFn ?? createJourney;
|
|
33
|
+
const getJourney = buildGetJourney((req, journeyResponse) => createJourneyFn(questions, journeyResponse));
|
|
34
|
+
const getJourneyResponse = buildGetJourneyResponseFromSession(journeyId);
|
|
22
35
|
|
|
23
36
|
// app.use((req, res, next) => {
|
|
24
37
|
// console.log(req.method, req.url, req.session);
|
|
@@ -198,3 +211,38 @@ export function mockAnswer(q) {
|
|
|
198
211
|
return 'test';
|
|
199
212
|
}
|
|
200
213
|
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Helper to render a question and check it's displayed correctly
|
|
217
|
+
* @param {import('node:test').TestContext} ctx
|
|
218
|
+
* @param {import('./test-server.js').TestServer} testServer
|
|
219
|
+
* @param {string} url
|
|
220
|
+
* @param {string} questionText
|
|
221
|
+
* @returns {Promise<string>}
|
|
222
|
+
*/
|
|
223
|
+
export async function renderQuestionCheck(ctx, testServer, url, questionText) {
|
|
224
|
+
mockRandomUUID(ctx);
|
|
225
|
+
const response = await testServer.get(url, { redirect: 'manual' });
|
|
226
|
+
assert.strictEqual(response.status, 200, `Expected 200 for ${url}, got ${response.status}`);
|
|
227
|
+
const text = await response.text();
|
|
228
|
+
assert.match(text, new RegExp(escapeForRegExp(questionText), 'i'));
|
|
229
|
+
assert.match(text, /<form action="" method="post"/i);
|
|
230
|
+
return text;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Helper to POST an answer and check the redirect
|
|
235
|
+
* @param {import('./test-server.js').TestServer} testServer
|
|
236
|
+
* @param {string} url
|
|
237
|
+
* @param {Record<string, unknown>} payload
|
|
238
|
+
* @returns {Promise<string|null>}
|
|
239
|
+
*/
|
|
240
|
+
export async function postAnswer(testServer, url, payload) {
|
|
241
|
+
const response = await testServer.post(url, payload, { redirect: 'manual' });
|
|
242
|
+
if (![302, 303].includes(response.status)) {
|
|
243
|
+
const text = await response.text();
|
|
244
|
+
console.log(`Response for ${url}:\n`, text);
|
|
245
|
+
}
|
|
246
|
+
assert.ok([302, 303].includes(response.status), `Expected redirect, got ${response.status}`);
|
|
247
|
+
return response.headers.get('location');
|
|
248
|
+
}
|
|
@@ -152,6 +152,7 @@ export class TestServer {
|
|
|
152
152
|
return response;
|
|
153
153
|
} catch (error) {
|
|
154
154
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
155
|
+
// eslint-disable-next-line preserve-caught-error
|
|
155
156
|
throw new Error(`Request to ${path} timed out after ${this.#timeoutMs}ms`);
|
|
156
157
|
}
|
|
157
158
|
throw error;
|
package/test/utils/utils.js
CHANGED
|
@@ -2,13 +2,28 @@ import path from 'path';
|
|
|
2
2
|
import { mock } from 'node:test';
|
|
3
3
|
|
|
4
4
|
export function testDir() {
|
|
5
|
-
return path.join(
|
|
5
|
+
return path.join(import.meta.dirname, '..');
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
export function snapshotsDir() {
|
|
9
9
|
return path.join(testDir(), 'snapshots');
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* @param {import('node:test').TestContext} ctx
|
|
14
|
+
* @param {string} content
|
|
15
|
+
* @param {string} name
|
|
16
|
+
*/
|
|
17
|
+
export function assertSnapshot(ctx, content, name) {
|
|
18
|
+
ctx.assert.fileSnapshot(
|
|
19
|
+
content.replaceAll('\r\n', '\n'), // OS agnostic line endings
|
|
20
|
+
path.join(snapshotsDir(), name),
|
|
21
|
+
{
|
|
22
|
+
serializers: [(v) => v]
|
|
23
|
+
}
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
12
27
|
/**
|
|
13
28
|
* @returns {import('pino').BaseLogger}
|
|
14
29
|
*/
|