@planning-inspectorate/dynamic-forms 2.3.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/components/manage-list/README.md +97 -0
- package/src/components/manage-list/answer-summary-list.njk +10 -0
- package/src/components/manage-list/index.njk +61 -0
- package/src/components/manage-list/manage-list-section.js +39 -0
- package/src/components/manage-list/manage-list-section.test.js +52 -0
- package/src/components/manage-list/question.js +147 -0
- package/src/components/manage-list/question.test.js +139 -0
- package/src/components/utils/nunjucks.js +20 -0
- package/src/controller.js +60 -20
- package/src/controller.test.js +110 -43
- package/src/index.js +3 -2
- package/src/journey/journey-types.d.ts +18 -0
- package/src/journey/journey.js +104 -40
- package/src/journey/journey.test.js +94 -23
- package/src/lib/uuid.js +10 -0
- package/src/lib/uuid.test.js +24 -0
- package/src/middleware/dynamic-req-files-to-req-body-files.js +1 -2
- package/src/middleware/dynamic-req-files-to-req-body-files.test.js +2 -2
- package/src/questions/question-props.d.ts +2 -1
- package/src/questions/question.js +67 -18
- package/src/questions/question.test.js +26 -3
- package/src/questions/questions.js +3 -1
- package/src/section-types.d.ts +18 -0
- package/src/section.js +67 -2
- package/src/section.test.js +176 -1
- package/src/validator/validator.js +1 -3
- package/src/validator/validator.test.js +7 -7
- package/test/journey.js +5 -0
- package/test/mock/journey.js +17 -0
- package/test/questions.js +30 -1
- package/test/questions.test.js +119 -48
- package/test/snapshots/check-your-answers-not-started.html +11 -0
- package/test/snapshots/check-your-answers-with-answers.html +11 -0
- package/test/snapshots/manage-list-answer-summary.html +14 -0
- package/test/snapshots/manage-list-render-questions.html +97 -0
- package/test/snapshots/manage-list-render.html +91 -0
- package/test/snapshots/travel-companions.html +19 -0
- package/test/snapshots/travel-companions_travel-companion-email.html +21 -0
- package/test/snapshots/travel-companions_travel-companion-name.html +21 -0
- package/test/utils/test-server.js +31 -9
- package/test/utils/utils.js +2 -1
- package/tsconfig.json +23 -0
package/src/section.test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, mock } from 'node:test';
|
|
2
2
|
import assert from 'node:assert';
|
|
3
|
-
import { Section, SECTION_STATUS } from './section.js';
|
|
3
|
+
import { END_OF_SECTION, Section, SECTION_STATUS } from './section.js';
|
|
4
4
|
import RequiredValidator from './validator/required-validator.js';
|
|
5
5
|
|
|
6
6
|
const mockQuestion = {
|
|
@@ -17,6 +17,13 @@ const mockQuestion2 = {
|
|
|
17
17
|
isAnswered: () => false
|
|
18
18
|
};
|
|
19
19
|
|
|
20
|
+
const mockQuestion3 = {
|
|
21
|
+
fieldName: 'mockQuestion3',
|
|
22
|
+
isRequired: () => true,
|
|
23
|
+
shouldDisplay: () => true,
|
|
24
|
+
isAnswered: () => false
|
|
25
|
+
};
|
|
26
|
+
|
|
20
27
|
describe('./src/dynamic-forms/section.js', () => {
|
|
21
28
|
describe('constructor', () => {
|
|
22
29
|
it('should create', () => {
|
|
@@ -46,6 +53,42 @@ describe('./src/dynamic-forms/section.js', () => {
|
|
|
46
53
|
assert.strictEqual(question.isRequired, mockQuestion.isRequired);
|
|
47
54
|
assert.strictEqual(question.shouldDisplay(), mockQuestion.shouldDisplay());
|
|
48
55
|
});
|
|
56
|
+
|
|
57
|
+
it('should support manage list questions', () => {
|
|
58
|
+
const section = new Section('s1', 'S');
|
|
59
|
+
assert.strictEqual(section.questions.length, 0);
|
|
60
|
+
const q = {
|
|
61
|
+
get isManageListQuestion() {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
const manageListSection = {
|
|
66
|
+
get isManageListSection() {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
section.addQuestion(q, manageListSection);
|
|
71
|
+
assert.strictEqual(section.questions.length, 1);
|
|
72
|
+
assert.strictEqual(q.section, manageListSection);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should throw if no manageListSection provided for a manage list question', () => {
|
|
76
|
+
const section = new Section('s1', 'S');
|
|
77
|
+
assert.strictEqual(section.questions.length, 0);
|
|
78
|
+
const q = {
|
|
79
|
+
get isManageListQuestion() {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
assert.throws(
|
|
84
|
+
() => section.addQuestion(q),
|
|
85
|
+
(thrown) => {
|
|
86
|
+
assert.strictEqual(thrown.message, 'manage list questions require a ManageListSection');
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
);
|
|
90
|
+
assert.strictEqual(section.questions.length, 0);
|
|
91
|
+
});
|
|
49
92
|
});
|
|
50
93
|
|
|
51
94
|
describe('getStatus', () => {
|
|
@@ -575,4 +618,136 @@ describe('./src/dynamic-forms/section.js', () => {
|
|
|
575
618
|
assert.throws(() => section.withRequiredCondition(false));
|
|
576
619
|
});
|
|
577
620
|
});
|
|
621
|
+
|
|
622
|
+
describe('getNextQuestion', () => {
|
|
623
|
+
const newSection = () => {
|
|
624
|
+
const q1 = { ...mockQuestion };
|
|
625
|
+
const q2 = { ...mockQuestion2 };
|
|
626
|
+
const q3 = { ...mockQuestion3 };
|
|
627
|
+
const section = new Section('s1', 'S').addQuestion(q1).addQuestion(q2).addQuestion(q3);
|
|
628
|
+
return { section, q1, q2, q3 };
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
it('should take the first question if takeNextQuestion is set', () => {
|
|
632
|
+
const { section, q1 } = newSection();
|
|
633
|
+
const next = section.getNextQuestion({
|
|
634
|
+
questionFieldName: '',
|
|
635
|
+
response: {},
|
|
636
|
+
takeNextQuestion: true,
|
|
637
|
+
reverse: false
|
|
638
|
+
});
|
|
639
|
+
assert.strictEqual(next, q1);
|
|
640
|
+
});
|
|
641
|
+
it('should take second question if first is matched', () => {
|
|
642
|
+
const { section, q1, q2 } = newSection();
|
|
643
|
+
const next = section.getNextQuestion({
|
|
644
|
+
questionFieldName: q1.fieldName,
|
|
645
|
+
response: {},
|
|
646
|
+
takeNextQuestion: false,
|
|
647
|
+
reverse: false
|
|
648
|
+
});
|
|
649
|
+
assert.strictEqual(next, q2);
|
|
650
|
+
});
|
|
651
|
+
it('should return END_OF_SECTION if last question matched', () => {
|
|
652
|
+
const { section, q3 } = newSection();
|
|
653
|
+
const next = section.getNextQuestion({
|
|
654
|
+
questionFieldName: q3.fieldName,
|
|
655
|
+
response: {},
|
|
656
|
+
takeNextQuestion: false,
|
|
657
|
+
reverse: false
|
|
658
|
+
});
|
|
659
|
+
assert.strictEqual(next, END_OF_SECTION);
|
|
660
|
+
});
|
|
661
|
+
it('should return null if no questions match', () => {
|
|
662
|
+
const { section } = newSection();
|
|
663
|
+
const next = section.getNextQuestion({
|
|
664
|
+
questionFieldName: 'random-field-name',
|
|
665
|
+
response: {},
|
|
666
|
+
takeNextQuestion: false,
|
|
667
|
+
reverse: false
|
|
668
|
+
});
|
|
669
|
+
assert.strictEqual(next, null);
|
|
670
|
+
});
|
|
671
|
+
it('should return question only if it "shouldDisplay"', () => {
|
|
672
|
+
const { section, q1, q2, q3 } = newSection();
|
|
673
|
+
// skip q2
|
|
674
|
+
q2.shouldDisplay = mock.fn(() => false);
|
|
675
|
+
const next = section.getNextQuestion({
|
|
676
|
+
questionFieldName: q1.fieldName,
|
|
677
|
+
response: {},
|
|
678
|
+
takeNextQuestion: false,
|
|
679
|
+
reverse: false
|
|
680
|
+
});
|
|
681
|
+
assert.strictEqual(q2.shouldDisplay.mock.callCount(), 1);
|
|
682
|
+
assert.strictEqual(next, q3);
|
|
683
|
+
});
|
|
684
|
+
it(`should return END_OF_SECTION if remaining questions shouldn't display`, () => {
|
|
685
|
+
const { section, q1, q2, q3 } = newSection();
|
|
686
|
+
// skip q2 & q3
|
|
687
|
+
q2.shouldDisplay = mock.fn(() => false);
|
|
688
|
+
q3.shouldDisplay = mock.fn(() => false);
|
|
689
|
+
const next = section.getNextQuestion({
|
|
690
|
+
questionFieldName: q1.fieldName,
|
|
691
|
+
response: {},
|
|
692
|
+
takeNextQuestion: false,
|
|
693
|
+
reverse: false
|
|
694
|
+
});
|
|
695
|
+
assert.strictEqual(q2.shouldDisplay.mock.callCount(), 1);
|
|
696
|
+
assert.strictEqual(q3.shouldDisplay.mock.callCount(), 1);
|
|
697
|
+
assert.strictEqual(next, END_OF_SECTION);
|
|
698
|
+
});
|
|
699
|
+
describe('manageListQuestions', () => {
|
|
700
|
+
const newSectionWithManageList = () => {
|
|
701
|
+
const q1 = { ...mockQuestion };
|
|
702
|
+
const manageListQ = {
|
|
703
|
+
get isManageListQuestion() {
|
|
704
|
+
return true;
|
|
705
|
+
}
|
|
706
|
+
};
|
|
707
|
+
const q3 = { ...mockQuestion2 };
|
|
708
|
+
const q4 = { ...mockQuestion3 };
|
|
709
|
+
const manageListSection = {
|
|
710
|
+
questions: [q3, q4],
|
|
711
|
+
get isManageListSection() {
|
|
712
|
+
return true;
|
|
713
|
+
}
|
|
714
|
+
};
|
|
715
|
+
const section = new Section('s1', 'S').addQuestion(q1).addQuestion(manageListQ, manageListSection);
|
|
716
|
+
return { section, q1, manageListQ, q3, q4 };
|
|
717
|
+
};
|
|
718
|
+
it(`should get first question within a manage list section`, () => {
|
|
719
|
+
const { section, manageListQ, q3 } = newSectionWithManageList();
|
|
720
|
+
const next = section.getNextQuestion({
|
|
721
|
+
questionFieldName: manageListQ.fieldName,
|
|
722
|
+
response: {},
|
|
723
|
+
manageListQuestion: manageListQ,
|
|
724
|
+
takeNextQuestion: true,
|
|
725
|
+
reverse: false
|
|
726
|
+
});
|
|
727
|
+
assert.strictEqual(next, q3);
|
|
728
|
+
});
|
|
729
|
+
it(`should get second question within a manage list section`, () => {
|
|
730
|
+
const { section, manageListQ, q3, q4 } = newSectionWithManageList();
|
|
731
|
+
const next = section.getNextQuestion({
|
|
732
|
+
questionFieldName: q3.fieldName,
|
|
733
|
+
response: {},
|
|
734
|
+
manageListQuestion: manageListQ,
|
|
735
|
+
takeNextQuestion: false,
|
|
736
|
+
reverse: false
|
|
737
|
+
});
|
|
738
|
+
assert.strictEqual(next, q4);
|
|
739
|
+
});
|
|
740
|
+
it(`should return to manage list question after manage list section is complete`, () => {
|
|
741
|
+
const { section, manageListQ, q4 } = newSectionWithManageList();
|
|
742
|
+
const next = section.getNextQuestion({
|
|
743
|
+
questionFieldName: q4.fieldName,
|
|
744
|
+
response: {},
|
|
745
|
+
manageListQuestion: manageListQ,
|
|
746
|
+
takeNextQuestion: false,
|
|
747
|
+
reverse: false
|
|
748
|
+
});
|
|
749
|
+
assert.strictEqual(next, manageListQ);
|
|
750
|
+
});
|
|
751
|
+
});
|
|
752
|
+
});
|
|
578
753
|
});
|
|
@@ -2,11 +2,9 @@ import { validationResult } from 'express-validator';
|
|
|
2
2
|
|
|
3
3
|
/** @type {import('express').Handler} */
|
|
4
4
|
const validate = async (req, res, next) => {
|
|
5
|
-
const { section, question } = req.params;
|
|
6
|
-
|
|
7
5
|
const { journey, journeyResponse } = res.locals;
|
|
8
6
|
|
|
9
|
-
const questionObj = journey.
|
|
7
|
+
const questionObj = journey.getQuestionByParams(req.params);
|
|
10
8
|
if (!questionObj) {
|
|
11
9
|
throw new Error('unknown question type');
|
|
12
10
|
}
|
|
@@ -12,7 +12,7 @@ describe('./src/dynamic-forms/validator/validator.js', () => {
|
|
|
12
12
|
locals: {
|
|
13
13
|
journeyResponse: {},
|
|
14
14
|
journey: {
|
|
15
|
-
|
|
15
|
+
getQuestionByParams: function () {
|
|
16
16
|
return null;
|
|
17
17
|
}
|
|
18
18
|
}
|
|
@@ -50,7 +50,7 @@ describe('./src/dynamic-forms/validator/validator.js', () => {
|
|
|
50
50
|
};
|
|
51
51
|
|
|
52
52
|
mockRes.locals.journey = {
|
|
53
|
-
|
|
53
|
+
getQuestionByParams: function () {
|
|
54
54
|
return {
|
|
55
55
|
validators: [new RequiredValidator()],
|
|
56
56
|
fieldName: 'field1'
|
|
@@ -78,7 +78,7 @@ describe('./src/dynamic-forms/validator/validator.js', () => {
|
|
|
78
78
|
};
|
|
79
79
|
|
|
80
80
|
mockRes.locals.journey = {
|
|
81
|
-
|
|
81
|
+
getQuestionByParams: function () {
|
|
82
82
|
return {
|
|
83
83
|
validators: [new RequiredValidator()],
|
|
84
84
|
fieldName: 'field1'
|
|
@@ -106,7 +106,7 @@ describe('./src/dynamic-forms/validator/validator.js', () => {
|
|
|
106
106
|
};
|
|
107
107
|
|
|
108
108
|
mockRes.locals.journey = {
|
|
109
|
-
|
|
109
|
+
getQuestionByParams: function () {
|
|
110
110
|
return {
|
|
111
111
|
validators: [new RequiredValidator()],
|
|
112
112
|
fieldName: 'field1'
|
|
@@ -133,7 +133,7 @@ describe('./src/dynamic-forms/validator/validator.js', () => {
|
|
|
133
133
|
};
|
|
134
134
|
|
|
135
135
|
mockRes.locals.journey = {
|
|
136
|
-
|
|
136
|
+
getQuestionByParams: function () {
|
|
137
137
|
return {
|
|
138
138
|
validators: [new RequiredValidator(), new ValidOptionValidator()],
|
|
139
139
|
fieldName: 'field1',
|
|
@@ -174,7 +174,7 @@ describe('./src/dynamic-forms/validator/validator.js', () => {
|
|
|
174
174
|
};
|
|
175
175
|
|
|
176
176
|
mockRes.locals.journey = {
|
|
177
|
-
|
|
177
|
+
getQuestionByParams: function () {
|
|
178
178
|
return {
|
|
179
179
|
validators: [new RequiredValidator(), new ValidOptionValidator()],
|
|
180
180
|
fieldName: 'field1',
|
|
@@ -217,7 +217,7 @@ describe('./src/dynamic-forms/validator/validator.js', () => {
|
|
|
217
217
|
};
|
|
218
218
|
|
|
219
219
|
mockRes.locals.journey = {
|
|
220
|
-
|
|
220
|
+
getQuestionByParams: function () {
|
|
221
221
|
return {
|
|
222
222
|
validators: [new AddressValidator()],
|
|
223
223
|
fieldName: 'field1'
|
package/test/journey.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Section } from '../src/section.js';
|
|
2
2
|
import { Journey } from '../src/journey/journey.js';
|
|
3
|
+
import { ManageListSection } from '#src/components/manage-list/manage-list-section.js';
|
|
3
4
|
|
|
4
5
|
export const JOURNEY_ID = 'holiday-journey';
|
|
5
6
|
|
|
@@ -29,6 +30,10 @@ export function createJourney(questions, response) {
|
|
|
29
30
|
.addQuestion(questions.hotelAddress)
|
|
30
31
|
.addQuestion(questions.luggageWeight)
|
|
31
32
|
.addQuestion(questions.contactEmail)
|
|
33
|
+
.addQuestion(
|
|
34
|
+
questions.travelCompanions,
|
|
35
|
+
new ManageListSection().addQuestion(questions.travelCompanionName).addQuestion(questions.travelCompanionEmail)
|
|
36
|
+
)
|
|
32
37
|
],
|
|
33
38
|
taskListUrl: 'check-your-answers',
|
|
34
39
|
journeyTemplate: 'views/layout-journey.njk',
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Journey } from '#src/journey/journey.js';
|
|
2
|
+
|
|
3
|
+
export function mockJourney() {
|
|
4
|
+
return new Journey({
|
|
5
|
+
journeyId: 'mock-journey-id',
|
|
6
|
+
makeBaseUrl: () => '/journey',
|
|
7
|
+
taskListUrl: 'check-your-answers',
|
|
8
|
+
response: { answers: {} },
|
|
9
|
+
journeyTemplate: 'views/layout-journey.njk',
|
|
10
|
+
taskListTemplate: 'views/layout-check-your-answers.njk',
|
|
11
|
+
informationPageViewPath: '',
|
|
12
|
+
journeyTitle: 'Mock Journey',
|
|
13
|
+
returnToListing: false,
|
|
14
|
+
sections: [],
|
|
15
|
+
initialBackLink: '/journey'
|
|
16
|
+
});
|
|
17
|
+
}
|
package/test/questions.js
CHANGED
|
@@ -174,6 +174,27 @@ export const questionProps = {
|
|
|
174
174
|
url: 'contact-email',
|
|
175
175
|
label: 'Email address',
|
|
176
176
|
validators: [new EmailValidator()]
|
|
177
|
+
},
|
|
178
|
+
travelCompanions: {
|
|
179
|
+
type: COMPONENT_TYPES.MANAGE_LIST,
|
|
180
|
+
title: 'Travel Companions',
|
|
181
|
+
question: 'Travel Companions',
|
|
182
|
+
fieldName: 'travelCompanions',
|
|
183
|
+
url: 'travel-companions'
|
|
184
|
+
},
|
|
185
|
+
travelCompanionName: {
|
|
186
|
+
type: COMPONENT_TYPES.SINGLE_LINE_INPUT,
|
|
187
|
+
title: 'Travel Companion Name',
|
|
188
|
+
question: 'Travel Companion Name',
|
|
189
|
+
fieldName: 'travelCompanionName',
|
|
190
|
+
url: 'travel-companion-name'
|
|
191
|
+
},
|
|
192
|
+
travelCompanionEmail: {
|
|
193
|
+
type: COMPONENT_TYPES.EMAIL,
|
|
194
|
+
title: 'Travel Companion Email',
|
|
195
|
+
question: 'Travel Companion Email',
|
|
196
|
+
fieldName: 'travelCompanionEmail',
|
|
197
|
+
url: 'travel-companion-email'
|
|
177
198
|
}
|
|
178
199
|
};
|
|
179
200
|
|
|
@@ -194,7 +215,15 @@ export const questionsInOrder = [
|
|
|
194
215
|
questionProps.nights,
|
|
195
216
|
questionProps.hotelAddress,
|
|
196
217
|
questionProps.luggageWeight,
|
|
197
|
-
questionProps.contactEmail
|
|
218
|
+
questionProps.contactEmail,
|
|
219
|
+
questionProps.travelCompanions
|
|
220
|
+
];
|
|
221
|
+
|
|
222
|
+
export const manageListQuestions = [
|
|
223
|
+
{
|
|
224
|
+
manageListQuestion: questionProps.travelCompanions,
|
|
225
|
+
questions: [questionProps.travelCompanionName, questionProps.travelCompanionEmail]
|
|
226
|
+
}
|
|
198
227
|
];
|
|
199
228
|
|
|
200
229
|
export const getQuestions = () => createQuestions(questionProps, questionClasses, {});
|
package/test/questions.test.js
CHANGED
|
@@ -2,7 +2,7 @@ import { describe, it } from 'node:test';
|
|
|
2
2
|
import assert from 'assert';
|
|
3
3
|
import { createApp } from './utils/app.js';
|
|
4
4
|
import { TestServer } from './utils/test-server.js';
|
|
5
|
-
import { getQuestions, questionsInOrder } from './questions.js';
|
|
5
|
+
import { getQuestions, manageListQuestions, questionsInOrder } from './questions.js';
|
|
6
6
|
import { buildGetJourney } from '../src/middleware/build-get-journey.js';
|
|
7
7
|
import { createJourney, JOURNEY_ID } from './journey.js';
|
|
8
8
|
import { buildGetJourneyResponseFromSession, saveDataToSession } from '../src/lib/session-answer-store.js';
|
|
@@ -13,6 +13,7 @@ import path from 'path';
|
|
|
13
13
|
import { COMPONENT_TYPES } from '../src/index.js';
|
|
14
14
|
import { escapeForRegExp, snapshotsDir } from './utils/utils.js';
|
|
15
15
|
import { BOOLEAN_OPTIONS } from '../src/components/boolean/question.js';
|
|
16
|
+
import { mockRandomUUID } from '#src/lib/uuid.test.js';
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* @param {import('node:test').TestContext} ctx
|
|
@@ -29,10 +30,15 @@ export async function createAppWithQuestions(ctx) {
|
|
|
29
30
|
// next();
|
|
30
31
|
// });
|
|
31
32
|
|
|
32
|
-
app.get(
|
|
33
|
+
app.get(
|
|
34
|
+
'/:section/:question{/:manageListAction/:manageListItemId/:manageListQuestion}',
|
|
35
|
+
getJourneyResponse,
|
|
36
|
+
getJourney,
|
|
37
|
+
question
|
|
38
|
+
);
|
|
33
39
|
|
|
34
40
|
app.post(
|
|
35
|
-
'/:section/:question',
|
|
41
|
+
'/:section/:question{/:manageListAction/:manageListItemId/:manageListQuestion}',
|
|
36
42
|
getJourneyResponse,
|
|
37
43
|
getJourney,
|
|
38
44
|
validate,
|
|
@@ -42,18 +48,25 @@ export async function createAppWithQuestions(ctx) {
|
|
|
42
48
|
|
|
43
49
|
app.get('/check-your-answers', getJourneyResponse, getJourney, (req, res) => list(req, res, '', {}));
|
|
44
50
|
|
|
45
|
-
app.use((error, req, res, next) => {
|
|
46
|
-
if (error) {
|
|
47
|
-
console.error('Internal Server Error:', error);
|
|
48
|
-
}
|
|
49
|
-
next();
|
|
50
|
-
});
|
|
51
|
-
|
|
52
51
|
app.use((req, res) => {
|
|
53
52
|
res.status(404);
|
|
54
53
|
});
|
|
55
54
|
|
|
56
|
-
|
|
55
|
+
/**
|
|
56
|
+
* @type {import('express').ErrorRequestHandler}
|
|
57
|
+
*/
|
|
58
|
+
function errorHandler(error, req, res, next) {
|
|
59
|
+
console.error('Internal Server Error:', error);
|
|
60
|
+
if (res.headersSent) {
|
|
61
|
+
return next(error);
|
|
62
|
+
}
|
|
63
|
+
res.status(500);
|
|
64
|
+
res.render('error', { error });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
app.use(errorHandler);
|
|
68
|
+
|
|
69
|
+
const server = new TestServer(app, { rememberCookies: true, timeoutMs: 2000 });
|
|
57
70
|
await server.start();
|
|
58
71
|
ctx.after(async () => await server.stop());
|
|
59
72
|
return server;
|
|
@@ -76,50 +89,72 @@ describe('question pages', () => {
|
|
|
76
89
|
}
|
|
77
90
|
});
|
|
78
91
|
|
|
92
|
+
/**
|
|
93
|
+
* @param {import('node:test').TestContext} ctx
|
|
94
|
+
* @param {string} url
|
|
95
|
+
* @param {string} snapshotName
|
|
96
|
+
* @param {import('#src/questions/question.js')} q
|
|
97
|
+
* @returns {Promise<void>}
|
|
98
|
+
*/
|
|
99
|
+
async function renderQuestionCheck(ctx, url, snapshotName, q) {
|
|
100
|
+
mockRandomUUID(ctx);
|
|
101
|
+
const testServer = await createAppWithQuestions(ctx);
|
|
102
|
+
|
|
103
|
+
const response = await testServer.get(url, {
|
|
104
|
+
redirect: 'manual'
|
|
105
|
+
});
|
|
106
|
+
assert.strictEqual(response.status, 200);
|
|
107
|
+
const text = await response.text();
|
|
108
|
+
// be sure to escape parentheses in the question text for the regex
|
|
109
|
+
assert.match(text, new RegExp(escapeForRegExp(q.question), 'i'));
|
|
110
|
+
assert.match(text, /<form action="" method="post"/i);
|
|
111
|
+
if (q.options) {
|
|
112
|
+
for (const option of q.options) {
|
|
113
|
+
if (q.type === COMPONENT_TYPES.SELECT) {
|
|
114
|
+
assert.match(
|
|
115
|
+
text,
|
|
116
|
+
new RegExp(`<option[^>]*value="${option.value}"[^>]*>\\s*${option.text}\\s*</option>`, 'i')
|
|
117
|
+
);
|
|
118
|
+
} else {
|
|
119
|
+
assert.match(text, new RegExp(`<input[^>]*value="${option.value}"[^>]*>`));
|
|
120
|
+
assert.match(text, new RegExp(`<label[^>]*>\\s*${option.text}\\s*</label>`, 'i'));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
ctx.assert.fileSnapshot(text, path.join(snapshotsDir(), snapshotName + '.html'), {
|
|
125
|
+
serializers: [(v) => v]
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* @param {import('node:test').TestContext} ctx
|
|
131
|
+
* @param {string} url
|
|
132
|
+
* @param {import('#src/questions/question.js')} q
|
|
133
|
+
* @returns {Promise<string>}
|
|
134
|
+
*/
|
|
135
|
+
async function postQuestionCheck(ctx, url, q) {
|
|
136
|
+
const testServer = await createAppWithQuestions(ctx);
|
|
137
|
+
const payload = mockAnswerBody(q);
|
|
138
|
+
const response = await testServer.post(url, payload, { redirect: 'manual' });
|
|
139
|
+
if (![302, 303].includes(response.status)) {
|
|
140
|
+
const text = await response.text();
|
|
141
|
+
console.log(`Response for ${q.url}:\n`, text);
|
|
142
|
+
}
|
|
143
|
+
// Should redirect (302 or 303)
|
|
144
|
+
assert.ok([302, 303].includes(response.status), `Expected redirect, got ${response.status}`);
|
|
145
|
+
// Should redirect to the next question (if there is one)
|
|
146
|
+
return response.headers.get('location');
|
|
147
|
+
}
|
|
148
|
+
|
|
79
149
|
for (let i = 0; i < questionsInOrder.length; i++) {
|
|
80
150
|
const q = questionsInOrder[i];
|
|
81
151
|
|
|
82
152
|
it(`should render question: ${q.url}`, async (ctx) => {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const response = await testServer.get('/questions/' + q.url, {
|
|
86
|
-
redirect: 'manual'
|
|
87
|
-
});
|
|
88
|
-
assert.strictEqual(response.status, 200);
|
|
89
|
-
const text = await response.text();
|
|
90
|
-
// be sure to escape parentheses in the question text for the regex
|
|
91
|
-
assert.match(text, new RegExp(escapeForRegExp(q.question), 'i'));
|
|
92
|
-
assert.match(text, /<form action="" method="post"/i);
|
|
93
|
-
if (q.options) {
|
|
94
|
-
for (const option of q.options) {
|
|
95
|
-
if (q.type === COMPONENT_TYPES.SELECT) {
|
|
96
|
-
assert.match(
|
|
97
|
-
text,
|
|
98
|
-
new RegExp(`<option[^>]*value="${option.value}"[^>]*>\\s*${option.text}\\s*</option>`, 'i')
|
|
99
|
-
);
|
|
100
|
-
} else {
|
|
101
|
-
assert.match(text, new RegExp(`<input[^>]*value="${option.value}"[^>]*>`));
|
|
102
|
-
assert.match(text, new RegExp(`<label[^>]*>\\s*${option.text}\\s*</label>`, 'i'));
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
ctx.assert.fileSnapshot(text, path.join(snapshotsDir(), q.url + '.html'), {
|
|
107
|
-
serializers: [(v) => v]
|
|
108
|
-
});
|
|
153
|
+
await renderQuestionCheck(ctx, '/questions/' + q.url, q.url, q);
|
|
109
154
|
});
|
|
110
155
|
|
|
111
156
|
it(`should POST valid data for question: ${q.url} and redirect to next`, async (ctx) => {
|
|
112
|
-
const
|
|
113
|
-
const payload = mockAnswerBody(q);
|
|
114
|
-
const response = await testServer.post(`/questions/${q.url}`, payload, { redirect: 'manual' });
|
|
115
|
-
if (![302, 303].includes(response.status)) {
|
|
116
|
-
const text = await response.text();
|
|
117
|
-
console.log(`Response for ${q.url}:\n`, text);
|
|
118
|
-
}
|
|
119
|
-
// Should redirect (302 or 303)
|
|
120
|
-
assert.ok([302, 303].includes(response.status), `Expected redirect, got ${response.status}`);
|
|
121
|
-
// Should redirect to the next question (if there is one)
|
|
122
|
-
const location = response.headers.get('location');
|
|
157
|
+
const location = await postQuestionCheck(ctx, `/questions/${q.url}`, q);
|
|
123
158
|
const nextQ = questionsInOrder[i + 1];
|
|
124
159
|
if (nextQ) {
|
|
125
160
|
assert.strictEqual(location, `questions/${nextQ.url}`);
|
|
@@ -128,6 +163,29 @@ describe('question pages', () => {
|
|
|
128
163
|
}
|
|
129
164
|
});
|
|
130
165
|
}
|
|
166
|
+
|
|
167
|
+
describe('manage list questions', () => {
|
|
168
|
+
for (const { manageListQuestion, questions } of manageListQuestions) {
|
|
169
|
+
const qUrl = (q) => ['questions', manageListQuestion.url, 'add', 'id', q.url].join('/');
|
|
170
|
+
for (let i = 0; i < questions.length; i++) {
|
|
171
|
+
const q = questions[i];
|
|
172
|
+
it(`should render question: ${q.url}`, async (ctx) => {
|
|
173
|
+
await renderQuestionCheck(ctx, '/' + qUrl(q), manageListQuestion.url + '_' + q.url, q);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it(`should POST valid data for question: ${q.url} and redirect to next`, async (ctx) => {
|
|
177
|
+
const location = await postQuestionCheck(ctx, '/' + qUrl(q), q);
|
|
178
|
+
const nextQ = questions[i + 1];
|
|
179
|
+
if (nextQ) {
|
|
180
|
+
assert.strictEqual(location, qUrl(nextQ));
|
|
181
|
+
} else {
|
|
182
|
+
// at the end of the manage list section, return to the manage list question
|
|
183
|
+
assert.strictEqual(location, 'questions/' + manageListQuestion.url);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
});
|
|
131
189
|
});
|
|
132
190
|
|
|
133
191
|
/**
|
|
@@ -195,6 +253,16 @@ export function mockAnswerBody(q) {
|
|
|
195
253
|
};
|
|
196
254
|
case COMPONENT_TYPES.EMAIL:
|
|
197
255
|
return { [q.fieldName]: 'test@example.com' };
|
|
256
|
+
case COMPONENT_TYPES.MANAGE_LIST: {
|
|
257
|
+
// manage list questions not available here so just hard coded
|
|
258
|
+
return {
|
|
259
|
+
[q.fieldName]: [
|
|
260
|
+
{ travelCompanionName: 'Companion 1', travelCompanionEmail: 'compaion-1@example.com' },
|
|
261
|
+
{ travelCompanionName: 'Companion 2', travelCompanionEmail: 'compaion-2@example.com' },
|
|
262
|
+
{ travelCompanionName: 'Companion 3', travelCompanionEmail: 'compaion-3@example.com' }
|
|
263
|
+
]
|
|
264
|
+
};
|
|
265
|
+
}
|
|
198
266
|
default:
|
|
199
267
|
return { [q.fieldName]: 'test' };
|
|
200
268
|
}
|
|
@@ -244,6 +312,9 @@ export function mockAnswer(q) {
|
|
|
244
312
|
return '10 kg';
|
|
245
313
|
case COMPONENT_TYPES.EMAIL:
|
|
246
314
|
return 'test@example.com';
|
|
315
|
+
case COMPONENT_TYPES.MANAGE_LIST: {
|
|
316
|
+
return `3 ${q.title}`;
|
|
317
|
+
}
|
|
247
318
|
default:
|
|
248
319
|
return 'test';
|
|
249
320
|
}
|
|
@@ -196,6 +196,17 @@
|
|
|
196
196
|
<a class="govuk-link" href="questions/contact-email">Answer<span class="govuk-visually-hidden"> What is your email address for booking confirmations?</span></a>
|
|
197
197
|
</dd>
|
|
198
198
|
</div>
|
|
199
|
+
<div class="govuk-summary-list__row">
|
|
200
|
+
<dt class="govuk-summary-list__key">
|
|
201
|
+
Travel Companions
|
|
202
|
+
</dt>
|
|
203
|
+
<dd class="govuk-summary-list__value">
|
|
204
|
+
Not started
|
|
205
|
+
</dd>
|
|
206
|
+
<dd class="govuk-summary-list__actions">
|
|
207
|
+
<a class="govuk-link" href="questions/travel-companions">Answer<span class="govuk-visually-hidden"> Travel Companions</span></a>
|
|
208
|
+
</dd>
|
|
209
|
+
</div>
|
|
199
210
|
</dl>
|
|
200
211
|
|
|
201
212
|
<form action method="post">
|
|
@@ -196,6 +196,17 @@
|
|
|
196
196
|
<a class="govuk-link" href="questions/contact-email">Change<span class="govuk-visually-hidden"> What is your email address for booking confirmations?</span></a>
|
|
197
197
|
</dd>
|
|
198
198
|
</div>
|
|
199
|
+
<div class="govuk-summary-list__row">
|
|
200
|
+
<dt class="govuk-summary-list__key">
|
|
201
|
+
Travel Companions
|
|
202
|
+
</dt>
|
|
203
|
+
<dd class="govuk-summary-list__value">
|
|
204
|
+
3 Travel Companions
|
|
205
|
+
</dd>
|
|
206
|
+
<dd class="govuk-summary-list__actions">
|
|
207
|
+
<a class="govuk-link" href="questions/travel-companions">Change<span class="govuk-visually-hidden"> Travel Companions</span></a>
|
|
208
|
+
</dd>
|
|
209
|
+
</div>
|
|
199
210
|
</dl>
|
|
200
211
|
|
|
201
212
|
<form action method="post">
|