@planning-inspectorate/dynamic-forms 2.1.0 → 2.2.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 +81 -0
- package/package.json +1 -1
- package/src/components/email/question.js +55 -0
- package/src/components/email/question.test.js +146 -0
- package/src/components/single-line-input/index.njk +2 -2
- package/src/components/single-line-input/question.js +6 -1
- package/src/index.js +3 -2
- package/src/questions/questions.js +3 -1
- package/src/validator/email-validator.js +41 -0
- package/src/validator/email-validator.test.js +166 -0
- package/test/journey.js +1 -0
- package/test/questions.js +12 -1
- package/test/questions.test.js +4 -0
- 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/contact-email.html +23 -0
package/README.md
CHANGED
|
@@ -199,7 +199,88 @@ These conditions combine with individual conditions and section conditions. In t
|
|
|
199
199
|
| q6 | q1 = 'yes' | q5 = 90 | N/A |
|
|
200
200
|
| q7 | q1 = 'yes' | q5 = 90 | N/A |
|
|
201
201
|
|
|
202
|
+
### Email Validation
|
|
202
203
|
|
|
204
|
+
Email validation is provided through the `EmailValidator` class and `EmailQuestion` component. The validator uses express-validator's robust email validation with configurable options.
|
|
205
|
+
|
|
206
|
+
#### Basic Usage
|
|
207
|
+
|
|
208
|
+
```javascript
|
|
209
|
+
import { COMPONENT_TYPES } from '@planning-inspectorate/dynamic-forms/src/index.js';
|
|
210
|
+
import { createQuestions } from '@planning-inspectorate/dynamic-forms/src/questions/create-questions.js';
|
|
211
|
+
import { questionClasses } from '@planning-inspectorate/dynamic-forms/src/questions/questions.js';
|
|
212
|
+
import EmailValidator from '@planning-inspectorate/dynamic-forms/src/validator/email-validator.js';
|
|
213
|
+
|
|
214
|
+
// Define question configuration
|
|
215
|
+
const questionProps = {
|
|
216
|
+
contactEmail: {
|
|
217
|
+
type: COMPONENT_TYPES.EMAIL,
|
|
218
|
+
title: 'Contact Information',
|
|
219
|
+
question: 'What is your email address?',
|
|
220
|
+
fieldName: 'contactEmail',
|
|
221
|
+
url: 'contact-email',
|
|
222
|
+
label: 'Email address',
|
|
223
|
+
validators: [
|
|
224
|
+
new EmailValidator({
|
|
225
|
+
errorMessage: 'Enter an email address in the correct format, like name@example.com'
|
|
226
|
+
})
|
|
227
|
+
]
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
// Create questions using the factory function
|
|
232
|
+
const questions = createQuestions(questionProps, questionClasses, {});
|
|
233
|
+
const emailQuestion = questions.contactEmail;
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
The EmailQuestion uses the following default input attributes:
|
|
237
|
+
|
|
238
|
+
- `type="email"`
|
|
239
|
+
- `spellcheck="false"`
|
|
240
|
+
- `autocomplete="email"`
|
|
241
|
+
|
|
242
|
+
#### Advanced Validation Options
|
|
243
|
+
|
|
244
|
+
For stricter email validation requirements:
|
|
245
|
+
|
|
246
|
+
```javascript
|
|
247
|
+
const businessEmailValidator = new EmailValidator({
|
|
248
|
+
options: {
|
|
249
|
+
allowDisplayName: false, // Reject "Name <email@domain.com>" format
|
|
250
|
+
requireTld: true, // Require top-level domain (default: true)
|
|
251
|
+
allowUtf8LocalPart: false, // Only ASCII characters in local part
|
|
252
|
+
allowIpDomain: false // Don't allow IP addresses as domain
|
|
253
|
+
},
|
|
254
|
+
errorMessage: 'Enter a valid business email address'
|
|
255
|
+
});
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
#### Alternative: Single Line Input with Email Attributes
|
|
259
|
+
|
|
260
|
+
You can also configure a single line input with email-specific attributes:
|
|
261
|
+
|
|
262
|
+
```javascript
|
|
263
|
+
import { COMPONENT_TYPES } from '@planning-inspectorate/dynamic-forms/src/index.js';
|
|
264
|
+
import { createQuestions } from '@planning-inspectorate/dynamic-forms/src/questions/create-questions.js';
|
|
265
|
+
import { questionClasses } from '@planning-inspectorate/dynamic-forms/src/questions/questions.js';
|
|
266
|
+
import EmailValidator from '@planning-inspectorate/dynamic-forms/src/validator/email-validator.js';
|
|
267
|
+
|
|
268
|
+
const questionProps = {
|
|
269
|
+
contactEmail: {
|
|
270
|
+
type: COMPONENT_TYPES.SINGLE_LINE_INPUT,
|
|
271
|
+
title: 'Contact Information',
|
|
272
|
+
question: 'What is your email address?',
|
|
273
|
+
fieldName: 'contactEmail',
|
|
274
|
+
url: 'contact-email',
|
|
275
|
+
label: 'Email address',
|
|
276
|
+
inputAttributes: { type: 'email', spellcheck: 'false' },
|
|
277
|
+
autocomplete: 'email',
|
|
278
|
+
validators: [new EmailValidator()]
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const questions = createQuestions(questionProps, questionClasses, {});
|
|
283
|
+
```
|
|
203
284
|
|
|
204
285
|
## Contributing
|
|
205
286
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import SingleLineInputQuestion from '../single-line-input/question.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {import('../../questions/question.js').QuestionViewModel} QuestionViewModel
|
|
5
|
+
* @typedef {import('../../journey/journey.js').Journey} Journey
|
|
6
|
+
* @typedef {import('../../journey/journey-response.js').JourneyResponse} JourneyResponse
|
|
7
|
+
* @typedef {import('../../section').Section} Section
|
|
8
|
+
* @typedef {import('../../validator/base-validator')} BaseValidator
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Email input question that extends SingleLineInputQuestion
|
|
13
|
+
* Automatically sets the input type to "email" and adds appropriate attributes
|
|
14
|
+
* @class
|
|
15
|
+
*/
|
|
16
|
+
export default class EmailQuestion extends SingleLineInputQuestion {
|
|
17
|
+
/**
|
|
18
|
+
* @param {import('#question-types').QuestionParameters} params
|
|
19
|
+
* @param {string|undefined} [params.label] if defined this show as a label for the input and the question will just be a standard h1
|
|
20
|
+
* @param {Record<string, string>} [params.inputAttributes] html attributes to add to the input (will be merged with email defaults)
|
|
21
|
+
* @param {string} [params.classes] html classes to add to the input
|
|
22
|
+
* @param {string} [params.autocomplete] autocomplete attribute value (defaults to 'email')
|
|
23
|
+
*/
|
|
24
|
+
constructor(params) {
|
|
25
|
+
// Set default input attributes for email
|
|
26
|
+
const emailInputAttributes = {
|
|
27
|
+
type: 'email',
|
|
28
|
+
spellcheck: 'false',
|
|
29
|
+
...params.inputAttributes
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Set default autocomplete for email if not provided
|
|
33
|
+
const autocomplete = params.autocomplete || 'email';
|
|
34
|
+
|
|
35
|
+
super({
|
|
36
|
+
...params,
|
|
37
|
+
viewFolder: 'single-line-input', // Reuse single-line-input template
|
|
38
|
+
inputAttributes: emailInputAttributes,
|
|
39
|
+
autocomplete: autocomplete
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Override formatAnswerForSummary to prevent capitalization of email addresses
|
|
45
|
+
* Email addresses should remain in their original case (typically lowercase)
|
|
46
|
+
* @param {string} sectionSegment
|
|
47
|
+
* @param {import('../../journey/journey.js').Journey} journey
|
|
48
|
+
* @param {string} answer
|
|
49
|
+
* @returns {Array}
|
|
50
|
+
*/
|
|
51
|
+
formatAnswerForSummary(sectionSegment, journey, answer) {
|
|
52
|
+
// Call parent method with capitals=false to prevent email capitalization
|
|
53
|
+
return super.formatAnswerForSummary(sectionSegment, journey, answer, false);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import EmailQuestion from './question.js';
|
|
4
|
+
|
|
5
|
+
describe('src/dynamic-forms/components/email/question.js', () => {
|
|
6
|
+
it('should create an email question with default attributes', () => {
|
|
7
|
+
const emailQuestion = new EmailQuestion({
|
|
8
|
+
title: 'Email Address',
|
|
9
|
+
question: 'What is your email address?',
|
|
10
|
+
fieldName: 'email'
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
assert.strictEqual(emailQuestion.viewFolder, 'single-line-input');
|
|
14
|
+
assert.strictEqual(emailQuestion.inputAttributes.type, 'email');
|
|
15
|
+
assert.strictEqual(emailQuestion.inputAttributes.spellcheck, 'false');
|
|
16
|
+
assert.strictEqual(emailQuestion.autocomplete, 'email');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should allow custom input attributes while preserving email defaults', () => {
|
|
20
|
+
const customAttributes = {
|
|
21
|
+
placeholder: 'Enter your email',
|
|
22
|
+
'data-testid': 'email-input'
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const emailQuestion = new EmailQuestion({
|
|
26
|
+
title: 'Email Address',
|
|
27
|
+
question: 'What is your email address?',
|
|
28
|
+
fieldName: 'email',
|
|
29
|
+
inputAttributes: customAttributes
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Should preserve email-specific attributes
|
|
33
|
+
assert.strictEqual(emailQuestion.inputAttributes.type, 'email');
|
|
34
|
+
assert.strictEqual(emailQuestion.inputAttributes.spellcheck, 'false');
|
|
35
|
+
|
|
36
|
+
// Should include custom attributes
|
|
37
|
+
assert.strictEqual(emailQuestion.inputAttributes.placeholder, 'Enter your email');
|
|
38
|
+
assert.strictEqual(emailQuestion.inputAttributes['data-testid'], 'email-input');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should allow overriding default attributes', () => {
|
|
42
|
+
const overrideAttributes = {
|
|
43
|
+
type: 'text', // Override the default email type
|
|
44
|
+
spellcheck: 'true' // Override the default spellcheck
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const emailQuestion = new EmailQuestion({
|
|
48
|
+
title: 'Email Address',
|
|
49
|
+
question: 'What is your email address?',
|
|
50
|
+
fieldName: 'email',
|
|
51
|
+
inputAttributes: overrideAttributes
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Should use overridden values
|
|
55
|
+
assert.strictEqual(emailQuestion.inputAttributes.type, 'text');
|
|
56
|
+
assert.strictEqual(emailQuestion.inputAttributes.spellcheck, 'true');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should set custom autocomplete attribute', () => {
|
|
60
|
+
const emailQuestion = new EmailQuestion({
|
|
61
|
+
title: 'Work Email',
|
|
62
|
+
question: 'What is your work email address?',
|
|
63
|
+
fieldName: 'workEmail',
|
|
64
|
+
autocomplete: 'work email'
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
assert.strictEqual(emailQuestion.autocomplete, 'work email');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should include label when provided', () => {
|
|
71
|
+
const emailQuestion = new EmailQuestion({
|
|
72
|
+
title: 'Contact Email',
|
|
73
|
+
question: 'Provide your contact information',
|
|
74
|
+
fieldName: 'contactEmail',
|
|
75
|
+
label: 'Email Address'
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
assert.strictEqual(emailQuestion.label, 'Email Address');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should include custom classes when provided', () => {
|
|
82
|
+
const emailQuestion = new EmailQuestion({
|
|
83
|
+
title: 'Email Address',
|
|
84
|
+
question: 'What is your email address?',
|
|
85
|
+
fieldName: 'email',
|
|
86
|
+
classes: 'govuk-input--width-20'
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
assert.strictEqual(emailQuestion.classes, 'govuk-input--width-20');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should prepare question for rendering correctly', () => {
|
|
93
|
+
const emailQuestion = new EmailQuestion({
|
|
94
|
+
title: 'Email Address',
|
|
95
|
+
question: 'What is your email address?',
|
|
96
|
+
fieldName: 'email',
|
|
97
|
+
label: 'Your Email',
|
|
98
|
+
hint: 'We will use this to contact you'
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Mock section and journey objects with proper structure
|
|
102
|
+
const mockSection = {
|
|
103
|
+
name: 'contact',
|
|
104
|
+
segment: 'contact-section'
|
|
105
|
+
};
|
|
106
|
+
const mockJourney = {
|
|
107
|
+
response: {
|
|
108
|
+
answers: {
|
|
109
|
+
email: 'existing@example.com'
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
getBackLink: () => '/back'
|
|
113
|
+
};
|
|
114
|
+
const mockCustomViewData = {};
|
|
115
|
+
const mockPayload = { email: 'test@example.com' };
|
|
116
|
+
|
|
117
|
+
const viewModel = emailQuestion.prepQuestionForRendering(mockSection, mockJourney, mockCustomViewData, mockPayload);
|
|
118
|
+
|
|
119
|
+
assert.strictEqual(viewModel.question.fieldName, 'email');
|
|
120
|
+
assert.strictEqual(viewModel.question.label, 'Your Email');
|
|
121
|
+
assert.strictEqual(viewModel.question.value, 'test@example.com');
|
|
122
|
+
assert.strictEqual(viewModel.question.type, 'email');
|
|
123
|
+
assert.strictEqual(viewModel.question.attributes.spellcheck, 'false');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should not capitalize email addresses in summary', () => {
|
|
127
|
+
const emailQuestion = new EmailQuestion({
|
|
128
|
+
title: 'Email Address',
|
|
129
|
+
question: 'What is your email address?',
|
|
130
|
+
fieldName: 'email'
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const mockSection = { segment: 'contact' };
|
|
134
|
+
const mockJourney = {
|
|
135
|
+
getCurrentQuestionUrl: () => '/questions/email'
|
|
136
|
+
};
|
|
137
|
+
const emailAddress = 'test@example.com';
|
|
138
|
+
|
|
139
|
+
const summary = emailQuestion.formatAnswerForSummary(mockSection.segment, mockJourney, emailAddress);
|
|
140
|
+
|
|
141
|
+
// Should preserve the original case of the email address
|
|
142
|
+
assert.strictEqual(summary.length, 1);
|
|
143
|
+
assert.match(summary[0].value, /test@example\.com/);
|
|
144
|
+
assert.doesNotMatch(summary[0].value, /Test@example\.com/);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
},
|
|
41
41
|
classes: question.classes,
|
|
42
42
|
attributes: question.attributes,
|
|
43
|
-
type: question.
|
|
43
|
+
type: question.type,
|
|
44
44
|
autocomplete: question.autocomplete
|
|
45
45
|
}) }}
|
|
46
46
|
{% else %}
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
},
|
|
65
65
|
classes: question.classes,
|
|
66
66
|
attributes: question.attributes,
|
|
67
|
-
type: question.
|
|
67
|
+
type: question.type,
|
|
68
68
|
autocomplete: question.autocomplete
|
|
69
69
|
}) }}
|
|
70
70
|
|
|
@@ -36,7 +36,12 @@ export default class SingleLineInputQuestion extends Question {
|
|
|
36
36
|
let viewModel = super.prepQuestionForRendering(section, journey, customViewData);
|
|
37
37
|
viewModel.question.label = this.label;
|
|
38
38
|
viewModel.question.value = payload ? payload[viewModel.question.fieldName] : viewModel.question.value;
|
|
39
|
-
|
|
39
|
+
|
|
40
|
+
// Extract type from attributes to pass separately to avoid duplication
|
|
41
|
+
const { type, ...otherAttributes } = this.inputAttributes;
|
|
42
|
+
viewModel.question.attributes = otherAttributes;
|
|
43
|
+
viewModel.question.type = type;
|
|
44
|
+
|
|
40
45
|
viewModel.question.classes = this.classes;
|
|
41
46
|
return viewModel;
|
|
42
47
|
}
|
package/src/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* @type {Readonly<{CHECKBOX: QuestionTypes, BOOLEAN: QuestionTypes, RADIO: QuestionTypes, DATE: QuestionTypes, DATE_PERIOD: QuestionTypes, TEXT_ENTRY: QuestionTypes, TEXT_ENTRY_REDACT: QuestionTypes, SELECT: QuestionTypes, SINGLE_LINE_INPUT: QuestionTypes, MULTI_FIELD_INPUT: QuestionTypes, NUMBER: QuestionTypes, ADDRESS: QuestionTypes, UNIT_OPTION: QuestionTypes}>}
|
|
6
|
+
* @type {Readonly<{CHECKBOX: QuestionTypes, BOOLEAN: QuestionTypes, RADIO: QuestionTypes, DATE: QuestionTypes, DATE_PERIOD: QuestionTypes, TEXT_ENTRY: QuestionTypes, TEXT_ENTRY_REDACT: QuestionTypes, SELECT: QuestionTypes, SINGLE_LINE_INPUT: QuestionTypes, MULTI_FIELD_INPUT: QuestionTypes, NUMBER: QuestionTypes, ADDRESS: QuestionTypes, UNIT_OPTION: QuestionTypes, EMAIL: QuestionTypes}>}
|
|
7
7
|
*/
|
|
8
8
|
export const COMPONENT_TYPES = Object.freeze({
|
|
9
9
|
CHECKBOX: 'checkbox',
|
|
@@ -19,5 +19,6 @@ export const COMPONENT_TYPES = Object.freeze({
|
|
|
19
19
|
MULTI_FIELD_INPUT: 'multi-field-input',
|
|
20
20
|
NUMBER: 'number',
|
|
21
21
|
ADDRESS: 'site-address',
|
|
22
|
-
UNIT_OPTION: 'unit-option'
|
|
22
|
+
UNIT_OPTION: 'unit-option',
|
|
23
|
+
EMAIL: 'email'
|
|
23
24
|
});
|
|
@@ -10,6 +10,7 @@ import SingleLineInputQuestion from '../components/single-line-input/question.js
|
|
|
10
10
|
import MultiFieldInputQuestion from '../components/multi-field-input/question.js';
|
|
11
11
|
import NumberEntryQuestion from '../components/number-entry/question.js';
|
|
12
12
|
import UnitOptionEntryQuestion from '../components/unit-option-entry/question.js';
|
|
13
|
+
import EmailQuestion from '../components/email/question.js';
|
|
13
14
|
|
|
14
15
|
import { COMPONENT_TYPES } from '../index.js';
|
|
15
16
|
import TextEntryRedactQuestion from '../components/text-entry-redact/question.js';
|
|
@@ -33,5 +34,6 @@ export const questionClasses = Object.freeze({
|
|
|
33
34
|
[COMPONENT_TYPES.SINGLE_LINE_INPUT]: SingleLineInputQuestion,
|
|
34
35
|
[COMPONENT_TYPES.MULTI_FIELD_INPUT]: MultiFieldInputQuestion,
|
|
35
36
|
[COMPONENT_TYPES.NUMBER]: NumberEntryQuestion,
|
|
36
|
-
[COMPONENT_TYPES.UNIT_OPTION]: UnitOptionEntryQuestion
|
|
37
|
+
[COMPONENT_TYPES.UNIT_OPTION]: UnitOptionEntryQuestion,
|
|
38
|
+
[COMPONENT_TYPES.EMAIL]: EmailQuestion
|
|
37
39
|
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { body } from 'express-validator';
|
|
2
|
+
|
|
3
|
+
import BaseValidator from './base-validator.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {Object} EmailValidationOptions
|
|
7
|
+
* @property {boolean} [allowDisplayName] - Allow display names (e.g., "John Doe <john@example.com>")
|
|
8
|
+
* @property {boolean} [requireTld] - Require top-level domain
|
|
9
|
+
* @property {boolean} [allowUtf8LocalPart] - Allow UTF8 characters in local part
|
|
10
|
+
* @property {boolean} [allowIpDomain] - Allow IP addresses as domain
|
|
11
|
+
* @property {string} [errorMessage] - Custom error message
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export default class EmailValidator extends BaseValidator {
|
|
15
|
+
/**
|
|
16
|
+
* @param {Object} params
|
|
17
|
+
* @param {EmailValidationOptions} [params.options] - Email validation options
|
|
18
|
+
* @param {string} [params.errorMessage] - Custom error message
|
|
19
|
+
* @param {string} [params.fieldName] - Field name to validate
|
|
20
|
+
*/
|
|
21
|
+
constructor({ options = {}, errorMessage, fieldName } = {}) {
|
|
22
|
+
super();
|
|
23
|
+
|
|
24
|
+
this.options = {
|
|
25
|
+
allow_display_name: options.allowDisplayName || false,
|
|
26
|
+
require_tld: options.requireTld !== false, // Default to true
|
|
27
|
+
allow_utf8_local_part: options.allowUtf8LocalPart !== undefined ? options.allowUtf8LocalPart : true,
|
|
28
|
+
allow_ip_domain: options.allowIpDomain || false,
|
|
29
|
+
...options
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
this.errorMessage = errorMessage || 'Enter an email address in the correct format, like name@example.com';
|
|
33
|
+
this.fieldName = fieldName;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
validate(questionObj) {
|
|
37
|
+
const fieldName = this.fieldName || questionObj.fieldName;
|
|
38
|
+
|
|
39
|
+
return body(fieldName).isEmail(this.options).withMessage(this.errorMessage);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import EmailValidator from './email-validator.js';
|
|
4
|
+
|
|
5
|
+
describe('src/dynamic-forms/validator/email-validator.js', () => {
|
|
6
|
+
// Helper function to create validation test
|
|
7
|
+
const createValidationTest = async (email, fieldName = 'email') => {
|
|
8
|
+
const req = {
|
|
9
|
+
body: {
|
|
10
|
+
[fieldName]: email
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
const question = {
|
|
14
|
+
fieldName
|
|
15
|
+
};
|
|
16
|
+
return await new EmailValidator().validate(question).run(req);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Valid email addresses
|
|
20
|
+
const validEmails = [
|
|
21
|
+
'test@example.com',
|
|
22
|
+
'test+tag@example.com',
|
|
23
|
+
'first.last@example.com',
|
|
24
|
+
'user@mail.example.com',
|
|
25
|
+
'test@example.org',
|
|
26
|
+
'verylongusernamethatisusuallynotrecommended@verylongdomainnamethatisalsounusual.com'
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
validEmails.forEach((email) => {
|
|
30
|
+
it(`should validate "${email}" as a correct email address`, async () => {
|
|
31
|
+
const validationResult = await createValidationTest(email);
|
|
32
|
+
assert.strictEqual(validationResult.errors.length, 0);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Invalid email addresses
|
|
37
|
+
const invalidEmails = ['invalid-email', 'test@', 'testexample.com', '', 'test @example.com'];
|
|
38
|
+
|
|
39
|
+
invalidEmails.forEach((email) => {
|
|
40
|
+
it(`should invalidate "${email}" as an incorrect email format`, async () => {
|
|
41
|
+
const validationResult = await createValidationTest(email);
|
|
42
|
+
assert.strictEqual(validationResult.errors.length, 1);
|
|
43
|
+
assert.strictEqual(
|
|
44
|
+
validationResult.errors[0].msg,
|
|
45
|
+
'Enter an email address in the correct format, like name@example.com'
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should handle undefined email field as invalid', async () => {
|
|
51
|
+
const req = {
|
|
52
|
+
body: {}
|
|
53
|
+
};
|
|
54
|
+
const question = {
|
|
55
|
+
fieldName: 'email'
|
|
56
|
+
};
|
|
57
|
+
const validationResult = await new EmailValidator().validate(question).run(req);
|
|
58
|
+
assert.strictEqual(validationResult.errors.length, 1);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should use custom error message when provided', async () => {
|
|
62
|
+
const req = {
|
|
63
|
+
body: {
|
|
64
|
+
email: 'invalid-email'
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
const question = {
|
|
68
|
+
fieldName: 'email'
|
|
69
|
+
};
|
|
70
|
+
const customMessage = 'Please provide a valid email address';
|
|
71
|
+
const validationResult = await new EmailValidator({ errorMessage: customMessage }).validate(question).run(req);
|
|
72
|
+
assert.strictEqual(validationResult.errors.length, 1);
|
|
73
|
+
assert.strictEqual(validationResult.errors[0].msg, customMessage);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should handle display names when option is enabled', async () => {
|
|
77
|
+
const req = {
|
|
78
|
+
body: {
|
|
79
|
+
email: 'John Doe <john@example.com>'
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
const question = {
|
|
83
|
+
fieldName: 'email'
|
|
84
|
+
};
|
|
85
|
+
const validationResult = await new EmailValidator({
|
|
86
|
+
options: { allowDisplayName: true }
|
|
87
|
+
})
|
|
88
|
+
.validate(question)
|
|
89
|
+
.run(req);
|
|
90
|
+
assert.strictEqual(validationResult.errors.length, 0);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should reject display names when option is disabled', async () => {
|
|
94
|
+
const req = {
|
|
95
|
+
body: {
|
|
96
|
+
email: 'John Doe <john@example.com>'
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
const question = {
|
|
100
|
+
fieldName: 'email'
|
|
101
|
+
};
|
|
102
|
+
const validationResult = await new EmailValidator({
|
|
103
|
+
options: { allowDisplayName: false }
|
|
104
|
+
})
|
|
105
|
+
.validate(question)
|
|
106
|
+
.run(req);
|
|
107
|
+
assert.strictEqual(validationResult.errors.length, 1);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should validate email with custom fieldName', async () => {
|
|
111
|
+
const req = {
|
|
112
|
+
body: {
|
|
113
|
+
contactEmail: 'test@example.com'
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
const question = {
|
|
117
|
+
fieldName: 'someOtherField'
|
|
118
|
+
};
|
|
119
|
+
const validationResult = await new EmailValidator({
|
|
120
|
+
fieldName: 'contactEmail'
|
|
121
|
+
})
|
|
122
|
+
.validate(question)
|
|
123
|
+
.run(req);
|
|
124
|
+
assert.strictEqual(validationResult.errors.length, 0);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('should reject UTF8 characters when allowUtf8LocalPart is false', async () => {
|
|
128
|
+
const req = {
|
|
129
|
+
body: {
|
|
130
|
+
email: 'tëst@example.com'
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
const question = {
|
|
134
|
+
fieldName: 'email'
|
|
135
|
+
};
|
|
136
|
+
const validationResult = await new EmailValidator({
|
|
137
|
+
options: { allowUtf8LocalPart: false }
|
|
138
|
+
})
|
|
139
|
+
.validate(question)
|
|
140
|
+
.run(req);
|
|
141
|
+
|
|
142
|
+
assert.strictEqual(validationResult.errors.length, 1);
|
|
143
|
+
assert.strictEqual(
|
|
144
|
+
validationResult.errors[0].msg,
|
|
145
|
+
'Enter an email address in the correct format, like name@example.com'
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('should accept UTF8 characters when allowUtf8LocalPart is true (default)', async () => {
|
|
150
|
+
const req = {
|
|
151
|
+
body: {
|
|
152
|
+
email: 'tëst@example.com'
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
const question = {
|
|
156
|
+
fieldName: 'email'
|
|
157
|
+
};
|
|
158
|
+
const validationResult = await new EmailValidator({
|
|
159
|
+
options: { allowUtf8LocalPart: true }
|
|
160
|
+
})
|
|
161
|
+
.validate(question)
|
|
162
|
+
.run(req);
|
|
163
|
+
|
|
164
|
+
assert.strictEqual(validationResult.errors.length, 0);
|
|
165
|
+
});
|
|
166
|
+
});
|
package/test/journey.js
CHANGED
|
@@ -28,6 +28,7 @@ export function createJourney(questions, response) {
|
|
|
28
28
|
.addQuestion(questions.nights)
|
|
29
29
|
.addQuestion(questions.hotelAddress)
|
|
30
30
|
.addQuestion(questions.luggageWeight)
|
|
31
|
+
.addQuestion(questions.contactEmail)
|
|
31
32
|
],
|
|
32
33
|
taskListUrl: 'check-your-answers',
|
|
33
34
|
journeyTemplate: 'views/layout-journey.njk',
|
package/test/questions.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { COMPONENT_TYPES } from '../src/index.js';
|
|
2
2
|
import { createQuestions } from '../src/questions/create-questions.js';
|
|
3
3
|
import { questionClasses } from '../src/questions/questions.js';
|
|
4
|
+
import EmailValidator from '../src/validator/email-validator.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* @typedef {import('../src/questions/question-props.js').QuestionProps} Props
|
|
@@ -164,6 +165,15 @@ export const questionProps = {
|
|
|
164
165
|
conditional: { label: 'Weight in pounds', fieldName: 'luggageWeightValue_lbs', suffix: 'lbs' }
|
|
165
166
|
}
|
|
166
167
|
]
|
|
168
|
+
},
|
|
169
|
+
contactEmail: {
|
|
170
|
+
type: COMPONENT_TYPES.EMAIL,
|
|
171
|
+
title: 'Contact Email',
|
|
172
|
+
question: 'What is your email address for booking confirmations?',
|
|
173
|
+
fieldName: 'contactEmail',
|
|
174
|
+
url: 'contact-email',
|
|
175
|
+
label: 'Email address',
|
|
176
|
+
validators: [new EmailValidator()]
|
|
167
177
|
}
|
|
168
178
|
};
|
|
169
179
|
|
|
@@ -183,7 +193,8 @@ export const questionsInOrder = [
|
|
|
183
193
|
questionProps.companions,
|
|
184
194
|
questionProps.nights,
|
|
185
195
|
questionProps.hotelAddress,
|
|
186
|
-
questionProps.luggageWeight
|
|
196
|
+
questionProps.luggageWeight,
|
|
197
|
+
questionProps.contactEmail
|
|
187
198
|
];
|
|
188
199
|
|
|
189
200
|
export const getQuestions = () => createQuestions(questionProps, questionClasses, {});
|
package/test/questions.test.js
CHANGED
|
@@ -193,6 +193,8 @@ export function mockAnswerBody(q) {
|
|
|
193
193
|
[q.fieldName]: 'kg',
|
|
194
194
|
[q.options[0].conditional.fieldName]: 10
|
|
195
195
|
};
|
|
196
|
+
case COMPONENT_TYPES.EMAIL:
|
|
197
|
+
return { [q.fieldName]: 'test@example.com' };
|
|
196
198
|
default:
|
|
197
199
|
return { [q.fieldName]: 'test' };
|
|
198
200
|
}
|
|
@@ -240,6 +242,8 @@ export function mockAnswer(q) {
|
|
|
240
242
|
}
|
|
241
243
|
case COMPONENT_TYPES.UNIT_OPTION:
|
|
242
244
|
return '10 kg';
|
|
245
|
+
case COMPONENT_TYPES.EMAIL:
|
|
246
|
+
return 'test@example.com';
|
|
243
247
|
default:
|
|
244
248
|
return 'test';
|
|
245
249
|
}
|
|
@@ -185,6 +185,17 @@
|
|
|
185
185
|
<a class="govuk-link" href="questions/luggage-weight-units">Answer<span class="govuk-visually-hidden"> Select your luggage weight and unit.</span></a>
|
|
186
186
|
</dd>
|
|
187
187
|
</div>
|
|
188
|
+
<div class="govuk-summary-list__row">
|
|
189
|
+
<dt class="govuk-summary-list__key">
|
|
190
|
+
Contact Email
|
|
191
|
+
</dt>
|
|
192
|
+
<dd class="govuk-summary-list__value">
|
|
193
|
+
Not started
|
|
194
|
+
</dd>
|
|
195
|
+
<dd class="govuk-summary-list__actions">
|
|
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
|
+
</dd>
|
|
198
|
+
</div>
|
|
188
199
|
</dl>
|
|
189
200
|
|
|
190
201
|
<form action method="post">
|
|
@@ -185,6 +185,17 @@
|
|
|
185
185
|
<a class="govuk-link" href="questions/luggage-weight-units">Change<span class="govuk-visually-hidden"> Select your luggage weight and unit.</span></a>
|
|
186
186
|
</dd>
|
|
187
187
|
</div>
|
|
188
|
+
<div class="govuk-summary-list__row">
|
|
189
|
+
<dt class="govuk-summary-list__key">
|
|
190
|
+
Contact Email
|
|
191
|
+
</dt>
|
|
192
|
+
<dd class="govuk-summary-list__value">
|
|
193
|
+
test@example.com
|
|
194
|
+
</dd>
|
|
195
|
+
<dd class="govuk-summary-list__actions">
|
|
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
|
+
</dd>
|
|
198
|
+
</div>
|
|
188
199
|
</dl>
|
|
189
200
|
|
|
190
201
|
<form action method="post">
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<div class="govuk-grid-row">
|
|
2
|
+
<div class="govuk-grid-column-two-thirds">
|
|
3
|
+
<form action="" method="post" novalidate>
|
|
4
|
+
<h1 class="govuk-heading-l">
|
|
5
|
+
What is your email address for booking confirmations?
|
|
6
|
+
</h1>
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
<div class="govuk-form-group">
|
|
11
|
+
<label class="govuk-label govuk-label" for="contactEmail">
|
|
12
|
+
Email address
|
|
13
|
+
</label>
|
|
14
|
+
<input class="govuk-input" id="contactEmail" name="contactEmail" type="email" value="" autocomplete="email" spellcheck="false">
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
<button type="submit" class="govuk-button" data-module="govuk-button" data-cy="button-save-and-continue">
|
|
19
|
+
Continue
|
|
20
|
+
</button>
|
|
21
|
+
</form>
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|