@planning-inspectorate/dynamic-forms 3.14.0 → 3.15.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 +1 -1
- package/src/components/address/question.js +14 -0
- package/src/components/address/question.test.js +13 -0
- package/src/components/date/question.js +8 -0
- package/src/components/date/question.test.js +17 -0
- package/src/components/date-period/question.js +15 -0
- package/src/components/date-period/question.test.js +20 -0
- package/src/components/date-time/question.js +15 -0
- package/src/components/date-time/question.test.js +11 -0
- package/src/components/multi-field-input/question.js +8 -0
- package/src/components/multi-field-input/question.test.js +7 -0
- package/src/questions/question.js +11 -0
- package/src/questions/question.test.js +7 -0
- package/src/validator/address-validator.js +1 -1
- package/src/validator/base-validator.js +1 -1
- package/src/validator/conditional-required-validator.js +1 -1
- package/src/validator/cross-question-validator.README.md +131 -0
- package/src/validator/cross-question-validator.js +48 -5
- package/src/validator/cross-question-validator.test.js +448 -11
- package/src/validator/date-period-validator.js +1 -5
- package/src/validator/date-time-validator.js +1 -5
- package/src/validator/date-validator.js +1 -5
- package/src/validator/required-validator.js +1 -5
- package/src/validator/unit-option-entry-validator.js +1 -5
- package/types/src/components/address/question.d.ts.map +1 -1
- package/types/src/components/date/question.d.ts.map +1 -1
- package/types/src/components/date-period/question.d.ts.map +1 -1
- package/types/src/components/date-time/question.d.ts.map +1 -1
- package/types/src/components/multi-field-input/question.d.ts.map +1 -1
- package/types/src/questions/question.d.ts +8 -0
- package/types/src/questions/question.d.ts.map +1 -1
- package/types/src/validator/address-validator.d.ts +2 -2
- package/types/src/validator/address-validator.d.ts.map +1 -1
- package/types/src/validator/base-validator.d.ts +4 -2
- package/types/src/validator/base-validator.d.ts.map +1 -1
- package/types/src/validator/conditional-required-validator.d.ts +2 -2
- package/types/src/validator/conditional-required-validator.d.ts.map +1 -1
- package/types/src/validator/cross-question-validator.d.ts +11 -4
- package/types/src/validator/cross-question-validator.d.ts.map +1 -1
- package/types/src/validator/date-period-validator.d.ts +2 -6
- package/types/src/validator/date-period-validator.d.ts.map +1 -1
- package/types/src/validator/date-time-validator.d.ts +2 -6
- package/types/src/validator/date-time-validator.d.ts.map +1 -1
- package/types/src/validator/date-validator.d.ts +2 -6
- package/types/src/validator/date-validator.d.ts.map +1 -1
- package/types/src/validator/required-validator.d.ts +2 -6
- package/types/src/validator/required-validator.d.ts.map +1 -1
- package/types/src/validator/unit-option-entry-validator.d.ts +2 -6
- package/types/src/validator/unit-option-entry-validator.d.ts.map +1 -1
package/package.json
CHANGED
|
@@ -30,6 +30,20 @@ export class AddressQuestion extends Question {
|
|
|
30
30
|
};
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Gets the body field names used by this question in form submissions.
|
|
35
|
+
* @returns {string[]}
|
|
36
|
+
*/
|
|
37
|
+
get bodyFieldNames() {
|
|
38
|
+
return [
|
|
39
|
+
`${this.fieldName}_addressLine1`,
|
|
40
|
+
`${this.fieldName}_addressLine2`,
|
|
41
|
+
`${this.fieldName}_townCity`,
|
|
42
|
+
`${this.fieldName}_county`,
|
|
43
|
+
`${this.fieldName}_postcode`
|
|
44
|
+
];
|
|
45
|
+
}
|
|
46
|
+
|
|
33
47
|
/**
|
|
34
48
|
* @param {Record<string, any>} answers
|
|
35
49
|
* @returns {*|string}
|
|
@@ -56,6 +56,19 @@ describe('AddressQuestion', () => {
|
|
|
56
56
|
return { question, mockApi, req, journeyResponse };
|
|
57
57
|
};
|
|
58
58
|
|
|
59
|
+
describe('bodyFieldNames', () => {
|
|
60
|
+
it('should return all address sub-field names', () => {
|
|
61
|
+
const { question } = setup();
|
|
62
|
+
assert.deepStrictEqual(question.bodyFieldNames, [
|
|
63
|
+
`${FIELDNAME}_addressLine1`,
|
|
64
|
+
`${FIELDNAME}_addressLine2`,
|
|
65
|
+
`${FIELDNAME}_townCity`,
|
|
66
|
+
`${FIELDNAME}_county`,
|
|
67
|
+
`${FIELDNAME}_postcode`
|
|
68
|
+
]);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
59
72
|
describe('getDataToSave', () => {
|
|
60
73
|
it('should format the data correctly to be saved', async () => {
|
|
61
74
|
const { question, req } = setup();
|
|
@@ -19,6 +19,14 @@ export class DateQuestion extends Question {
|
|
|
19
19
|
this.dateFormat = dateFormat;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Gets the body field names used by this question in form submissions.
|
|
24
|
+
* @returns {string[]}
|
|
25
|
+
*/
|
|
26
|
+
get bodyFieldNames() {
|
|
27
|
+
return [`${this.fieldName}_day`, `${this.fieldName}_month`, `${this.fieldName}_year`];
|
|
28
|
+
}
|
|
29
|
+
|
|
22
30
|
/**
|
|
23
31
|
* Get the data to save from the request, returns an object of answers
|
|
24
32
|
* @param {import('express').Request} req
|
|
@@ -26,6 +26,23 @@ describe('DateQuestion', () => {
|
|
|
26
26
|
});
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
+
describe('bodyFieldNames', () => {
|
|
30
|
+
it('should return day, month, and year field names', () => {
|
|
31
|
+
const dateQuestion = new DateQuestion({
|
|
32
|
+
title: TITLE,
|
|
33
|
+
question: QUESTION,
|
|
34
|
+
fieldName: FIELDNAME,
|
|
35
|
+
hint: HINT,
|
|
36
|
+
validators: VALIDATORS
|
|
37
|
+
});
|
|
38
|
+
assert.deepStrictEqual(dateQuestion.bodyFieldNames, [
|
|
39
|
+
`${FIELDNAME}_day`,
|
|
40
|
+
`${FIELDNAME}_month`,
|
|
41
|
+
`${FIELDNAME}_year`
|
|
42
|
+
]);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
29
46
|
describe('getDataToSave', () => {
|
|
30
47
|
it('should return data correctly', async () => {
|
|
31
48
|
const dateQuestion = new DateQuestion({
|
|
@@ -34,6 +34,21 @@ export class DatePeriodQuestion extends Question {
|
|
|
34
34
|
this.endTime = endTime || { hour: 0, minute: 0, second: 0 };
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Gets the body field names used by this question in form submissions.
|
|
39
|
+
* @returns {string[]}
|
|
40
|
+
*/
|
|
41
|
+
get bodyFieldNames() {
|
|
42
|
+
return [
|
|
43
|
+
`${this.fieldName}_start_day`,
|
|
44
|
+
`${this.fieldName}_start_month`,
|
|
45
|
+
`${this.fieldName}_start_year`,
|
|
46
|
+
`${this.fieldName}_end_day`,
|
|
47
|
+
`${this.fieldName}_end_month`,
|
|
48
|
+
`${this.fieldName}_end_year`
|
|
49
|
+
];
|
|
50
|
+
}
|
|
51
|
+
|
|
37
52
|
/**
|
|
38
53
|
* Get the data to save from the request, returns an object of answers
|
|
39
54
|
* @param {import('express').Request} req
|
|
@@ -30,6 +30,26 @@ describe('DatePeriodQuestion', () => {
|
|
|
30
30
|
});
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
+
describe('bodyFieldNames', () => {
|
|
34
|
+
it('should return all start and end date field names', () => {
|
|
35
|
+
const dateQuestion = new DatePeriodQuestion({
|
|
36
|
+
title: TITLE,
|
|
37
|
+
question: QUESTION,
|
|
38
|
+
fieldName: FIELDNAME,
|
|
39
|
+
hint: HINT,
|
|
40
|
+
validators: VALIDATORS
|
|
41
|
+
});
|
|
42
|
+
assert.deepStrictEqual(dateQuestion.bodyFieldNames, [
|
|
43
|
+
`${FIELDNAME}_start_day`,
|
|
44
|
+
`${FIELDNAME}_start_month`,
|
|
45
|
+
`${FIELDNAME}_start_year`,
|
|
46
|
+
`${FIELDNAME}_end_day`,
|
|
47
|
+
`${FIELDNAME}_end_month`,
|
|
48
|
+
`${FIELDNAME}_end_year`
|
|
49
|
+
]);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
33
53
|
describe('getDataToSave', () => {
|
|
34
54
|
it('should return data correctly', async () => {
|
|
35
55
|
const dateQuestion = new DatePeriodQuestion({
|
|
@@ -22,6 +22,21 @@ export class DateTimeQuestion extends Question {
|
|
|
22
22
|
this.timeFormat = timeFormat;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Gets the body field names used by this question in form submissions.
|
|
27
|
+
* @returns {string[]}
|
|
28
|
+
*/
|
|
29
|
+
get bodyFieldNames() {
|
|
30
|
+
return [
|
|
31
|
+
`${this.fieldName}_day`,
|
|
32
|
+
`${this.fieldName}_month`,
|
|
33
|
+
`${this.fieldName}_year`,
|
|
34
|
+
`${this.fieldName}_hour`,
|
|
35
|
+
`${this.fieldName}_minutes`,
|
|
36
|
+
`${this.fieldName}_period`
|
|
37
|
+
];
|
|
38
|
+
}
|
|
39
|
+
|
|
25
40
|
/**
|
|
26
41
|
* Get the data to save from the request, returns an object of answers
|
|
27
42
|
* @param {import('express').Request} req
|
|
@@ -15,6 +15,17 @@ describe('./lib/forms/custom-components/date-time/question.js', () => {
|
|
|
15
15
|
it('should create', () => {
|
|
16
16
|
assert.strictEqual(question.viewFolder, 'date-time');
|
|
17
17
|
});
|
|
18
|
+
|
|
19
|
+
it('should return all date and time field names from bodyFieldNames', () => {
|
|
20
|
+
assert.deepStrictEqual(question.bodyFieldNames, [
|
|
21
|
+
'siteVisitDate_day',
|
|
22
|
+
'siteVisitDate_month',
|
|
23
|
+
'siteVisitDate_year',
|
|
24
|
+
'siteVisitDate_hour',
|
|
25
|
+
'siteVisitDate_minutes',
|
|
26
|
+
'siteVisitDate_period'
|
|
27
|
+
]);
|
|
28
|
+
});
|
|
18
29
|
});
|
|
19
30
|
describe('prepQuestionForRendering', () => {
|
|
20
31
|
it('should prep question for rendering when value not in payload', () => {
|
|
@@ -30,6 +30,14 @@ export class MultiFieldInputQuestion extends Question {
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Gets the body field names used by this question in form submissions.
|
|
35
|
+
* @returns {string[]}
|
|
36
|
+
*/
|
|
37
|
+
get bodyFieldNames() {
|
|
38
|
+
return this.inputFields.map((inputField) => inputField.fieldName);
|
|
39
|
+
}
|
|
40
|
+
|
|
33
41
|
answerForViewModel(answers) {
|
|
34
42
|
return this.inputFields.map((inputField) => {
|
|
35
43
|
return {
|
|
@@ -51,6 +51,13 @@ describe('./src/dynamic-forms/components/multi-field-input/question.js', () => {
|
|
|
51
51
|
}, new Error('inputFields are mandatory'));
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
+
describe('bodyFieldNames', () => {
|
|
55
|
+
it('should return field names from all inputFields', () => {
|
|
56
|
+
const testQuestion = createMultiFieldInputQuestion();
|
|
57
|
+
assert.deepStrictEqual(testQuestion.bodyFieldNames, ['testField1', 'testField2']);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
54
61
|
describe('prepQuestionForRendering', () => {
|
|
55
62
|
it('should call super and set inputFields', () => {
|
|
56
63
|
const question = createMultiFieldInputQuestion();
|
|
@@ -181,6 +181,17 @@ export class Question {
|
|
|
181
181
|
this._isInManageListSection = value;
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
/**
|
|
185
|
+
* Gets the body field names used by this question in form submissions.
|
|
186
|
+
* Returns the field names that should be present in req.body when this question is submitted.
|
|
187
|
+
* Subclasses should override this for questions with multiple or differently-named body fields.
|
|
188
|
+
*
|
|
189
|
+
* @returns {string[]}
|
|
190
|
+
*/
|
|
191
|
+
get bodyFieldNames() {
|
|
192
|
+
return [this.fieldName];
|
|
193
|
+
}
|
|
194
|
+
|
|
184
195
|
/**
|
|
185
196
|
* gets the view model for this question
|
|
186
197
|
*
|
|
@@ -135,6 +135,13 @@ describe('./src/dynamic-forms/question.js', () => {
|
|
|
135
135
|
});
|
|
136
136
|
});
|
|
137
137
|
|
|
138
|
+
describe('bodyFieldNames', () => {
|
|
139
|
+
it('should return an array containing the fieldName by default', () => {
|
|
140
|
+
const question = getTestQuestion();
|
|
141
|
+
assert.deepStrictEqual(question.bodyFieldNames, [FIELDNAME]);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
138
145
|
describe('prepQuestionForRendering', () => {
|
|
139
146
|
it('should prepQuestionForRendering', () => {
|
|
140
147
|
const question = getTestQuestion();
|
|
@@ -41,7 +41,7 @@ export class AddressValidator extends BaseValidator {
|
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
43
|
* validates response body using questionObj fieldname
|
|
44
|
-
* @param {Question} questionObj
|
|
44
|
+
* @param {import('../questions/question.js').Question} questionObj
|
|
45
45
|
*/
|
|
46
46
|
validate(questionObj) {
|
|
47
47
|
const fieldName = questionObj.fieldName;
|
|
@@ -18,7 +18,7 @@ export class BaseValidator {
|
|
|
18
18
|
* Validates response body against field validators.
|
|
19
19
|
* Subclasses must override this method.
|
|
20
20
|
* @abstract
|
|
21
|
-
* @param {
|
|
21
|
+
* @param {{fieldName: string}} questionObj - The question object containing the fieldName to validate.
|
|
22
22
|
* @param {import('../journey/journey-response.js').JourneyResponse} [journeyResponse] - The current journey response (optional).
|
|
23
23
|
* @returns {import('express-validator').ValidationChain | import('express-validator').ValidationChain[]}
|
|
24
24
|
*/
|
|
@@ -31,7 +31,7 @@ export class ConditionalRequiredValidator extends BaseValidator {
|
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
33
|
* validates the response body, checking the questionObj's fieldname
|
|
34
|
-
* @param {
|
|
34
|
+
* @param {import('../questions/options-question.js').OptionsQuestion} questionObj
|
|
35
35
|
*/
|
|
36
36
|
validate(questionObj) {
|
|
37
37
|
return questionObj.options.reduce((schema, option) => {
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Cross-question validator
|
|
2
|
+
|
|
3
|
+
The `CrossQuestionValidator` enables validation of a question's answer against another question's answer using a custom validation function. This is useful for scenarios where the validity of one answer depends on another answer.
|
|
4
|
+
|
|
5
|
+
## Constructor options
|
|
6
|
+
|
|
7
|
+
| Parameter | Type | Default | Description |
|
|
8
|
+
| ------------------------- | ---------------------------------------------- | ------------ | ---------------------------------------------------------------------------------------------- |
|
|
9
|
+
| `dependencyFieldName` | `string` | **required** | The field name of the other question to compare against |
|
|
10
|
+
| `validationFunction` | `(currentAnswer, dependencyAnswer) => boolean` | **required** | Function that returns `true` if valid, or throws an `Error` with a specific message if invalid |
|
|
11
|
+
| `useBodyValues` | `boolean` | `false` | Use `req.body` values for **both** current and dependency answers |
|
|
12
|
+
| `useBodyValuesForCurrent` | `boolean` | `false` | Use `req.body` value for current answer only (dependency still uses JourneyResponse) |
|
|
13
|
+
|
|
14
|
+
## Use cases
|
|
15
|
+
|
|
16
|
+
### 1. Validation between separate manage list questions (JourneyResponse data)
|
|
17
|
+
|
|
18
|
+
When validating between questions where both answers are already saved to the JourneyResponse (e.g., in a manage list journey), use the default settings.
|
|
19
|
+
|
|
20
|
+
```js
|
|
21
|
+
import { CrossQuestionValidator } from '@planning-inspectorate/dynamic-forms';
|
|
22
|
+
|
|
23
|
+
const startDateQuestion = {
|
|
24
|
+
type: COMPONENT_TYPES.DATE,
|
|
25
|
+
fieldName: 'startDate',
|
|
26
|
+
question: 'What is the start date?'
|
|
27
|
+
// ... other config
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const endDateQuestion = {
|
|
31
|
+
type: COMPONENT_TYPES.DATE,
|
|
32
|
+
fieldName: 'endDate',
|
|
33
|
+
question: 'What is the end date?',
|
|
34
|
+
validators: [
|
|
35
|
+
new CrossQuestionValidator({
|
|
36
|
+
dependencyFieldName: 'startDate',
|
|
37
|
+
validationFunction: (endDate, startDate) => {
|
|
38
|
+
if (!endDate || !startDate) return true;
|
|
39
|
+
if (new Date(endDate) < new Date(startDate)) {
|
|
40
|
+
throw new Error('End date must be on or after the start date');
|
|
41
|
+
}
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
]
|
|
46
|
+
};
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**When to use:** Both questions are on separate pages and their answers are saved to the JourneyResponse before validation runs.
|
|
50
|
+
|
|
51
|
+
### 2. Validation between regular questions (body data with formatting)
|
|
52
|
+
|
|
53
|
+
When the current question's data needs to be extracted from `req.body` and formatted (e.g., date fields that come as separate day/month/year inputs), use `useBodyValuesForCurrent: true`.
|
|
54
|
+
|
|
55
|
+
The validator will automatically use the question's `getDataToSave()` method to format the data correctly.
|
|
56
|
+
|
|
57
|
+
```js
|
|
58
|
+
const projectEndDateQuestion = {
|
|
59
|
+
type: COMPONENT_TYPES.DATE,
|
|
60
|
+
fieldName: 'projectEndDate',
|
|
61
|
+
question: 'When does the project end?',
|
|
62
|
+
validators: [
|
|
63
|
+
new CrossQuestionValidator({
|
|
64
|
+
dependencyFieldName: 'projectStartDate', // Already saved in JourneyResponse
|
|
65
|
+
useBodyValuesForCurrent: true, // Current answer from body, formatted via getDataToSave()
|
|
66
|
+
validationFunction: (endDate, startDate) => {
|
|
67
|
+
if (!endDate || !startDate) return true;
|
|
68
|
+
if (endDate < new Date(startDate)) {
|
|
69
|
+
throw new Error('Project end date must be on or after the project start date');
|
|
70
|
+
}
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
]
|
|
75
|
+
};
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**When to use:** The current question's answer is in `req.body` (not yet saved), but the dependency answer is already saved to the JourneyResponse. Works automatically with date questions, date-period questions, and other question types that have a `getDataToSave()` method.
|
|
79
|
+
|
|
80
|
+
### 3. Validation between fields in a multi-field question (both from body)
|
|
81
|
+
|
|
82
|
+
When validating between multiple fields on the same page (e.g., a `MultiFieldInputQuestion`), both current and reference values come from `req.body`. Use `useBodyValues: true`.
|
|
83
|
+
|
|
84
|
+
```js
|
|
85
|
+
const siteAreaQuestion = {
|
|
86
|
+
type: COMPONENT_TYPES.MULTI_FIELD_INPUT,
|
|
87
|
+
fieldName: 'siteArea',
|
|
88
|
+
question: 'What is the area of the site?',
|
|
89
|
+
inputFields: [
|
|
90
|
+
{ fieldName: 'siteAreaHectares', label: 'Site area in hectares (optional)' },
|
|
91
|
+
{ fieldName: 'siteAreaSquareMetres', label: 'Site area in square metres (optional)' }
|
|
92
|
+
],
|
|
93
|
+
validators: [
|
|
94
|
+
// Note: this is a custom validator not provided by default
|
|
95
|
+
new MultiFieldInputValidator({
|
|
96
|
+
fields: [
|
|
97
|
+
{
|
|
98
|
+
fieldName: 'siteAreaHectares',
|
|
99
|
+
validators: [
|
|
100
|
+
new CrossQuestionValidator({
|
|
101
|
+
dependencyFieldName: 'siteAreaSquareMetres',
|
|
102
|
+
useBodyValues: true,
|
|
103
|
+
validationFunction: (hectares, squareMetres) => {
|
|
104
|
+
if (hectares?.trim() && squareMetres?.trim()) {
|
|
105
|
+
throw new Error('Enter the site area in either hectares or square metres, not both');
|
|
106
|
+
}
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
]
|
|
111
|
+
}
|
|
112
|
+
]
|
|
113
|
+
})
|
|
114
|
+
]
|
|
115
|
+
};
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
**When to use:** Multiple fields are on the same page and both values need to be read from `req.body` before either is saved.
|
|
119
|
+
|
|
120
|
+
## How data sources are determined
|
|
121
|
+
|
|
122
|
+
| `useBodyValues` | `useBodyValuesForCurrent` | Current Answer Source | Dependency Answer Source |
|
|
123
|
+
| --------------- | ------------------------- | ---------------------------- | ------------------------ |
|
|
124
|
+
| `false` | `false` | JourneyResponse | JourneyResponse |
|
|
125
|
+
| `false` | `true` | Body (via `getDataToSave()`) | JourneyResponse |
|
|
126
|
+
| `true` | (ignored) | Body (via `getDataToSave()`) | Body (raw) |
|
|
127
|
+
|
|
128
|
+
## Notes
|
|
129
|
+
|
|
130
|
+
- When `useBodyValuesForCurrent` is `true`, the validator uses the question's `getDataToSave()` method to format body data. This automatically handles complex field types like dates (`fieldName_day`, `fieldName_month`, `fieldName_year`).
|
|
131
|
+
- The validator binds to the appropriate body field using the question's `bodyFieldNames` getter. For simple questions this returns `[fieldName]`, but complex question types override it (e.g., `DateQuestion` returns `[fieldName_day, fieldName_month, fieldName_year]`). Custom question components can define their own `bodyFieldNames` getter to ensure proper validation binding.
|
|
@@ -11,16 +11,24 @@ export class CrossQuestionValidator extends BaseValidator {
|
|
|
11
11
|
validationFunction;
|
|
12
12
|
/** @type {boolean} */
|
|
13
13
|
useBodyValues;
|
|
14
|
+
/** @type {boolean} */
|
|
15
|
+
useBodyValuesForCurrent;
|
|
14
16
|
|
|
15
17
|
/**
|
|
16
18
|
* @param {Object} params
|
|
17
19
|
* @param {string} params.dependencyFieldName - The field name of the other question to compare against.
|
|
18
20
|
* @param {((currentAnswer: unknown, dependencyAnswer: unknown) => boolean)| null} params.validationFunction - A function that takes the current question's answer and the other question's answer and returns true if valid, false otherwise.
|
|
19
|
-
* @param {boolean} [params.useBodyValues=false] - Whether to use req.body values for validation (default: false). Usually CrossQuestionValidator will be for validating across saved
|
|
21
|
+
* @param {boolean} [params.useBodyValues=false] - Whether to use req.body values for validation (default: false). Usually CrossQuestionValidator will be for validating across saved JourneyResponse answers, but if used between multiple fields on one question, it will need to use the body.
|
|
22
|
+
* @param {boolean} [params.useBodyValuesForCurrent=false] - Whether to use req.body values for the current question's answer (default: false). This may help with questions where data is not saved to the JourneyResponse on POST (e.g. non-multiple entities questions).
|
|
20
23
|
* @throws {Error} If validationFunction is not provided or is not a function.
|
|
21
24
|
* @throws {Error} If dependencyFieldName is not provided
|
|
22
25
|
*/
|
|
23
|
-
constructor({
|
|
26
|
+
constructor({
|
|
27
|
+
dependencyFieldName,
|
|
28
|
+
validationFunction,
|
|
29
|
+
useBodyValues = false,
|
|
30
|
+
useBodyValuesForCurrent = false
|
|
31
|
+
} = {}) {
|
|
24
32
|
super();
|
|
25
33
|
|
|
26
34
|
if (!dependencyFieldName) {
|
|
@@ -33,24 +41,59 @@ export class CrossQuestionValidator extends BaseValidator {
|
|
|
33
41
|
this.dependencyFieldName = dependencyFieldName;
|
|
34
42
|
this.validationFunction = validationFunction;
|
|
35
43
|
this.useBodyValues = useBodyValues;
|
|
44
|
+
this.useBodyValuesForCurrent = useBodyValues || useBodyValuesForCurrent;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Gets the body field name to bind validation to.
|
|
49
|
+
* Uses the question's bodyFieldNames getter to determine which body field
|
|
50
|
+
* express-validator binds to for triggering validation.
|
|
51
|
+
* @param {import('../questions/question.js').Question} questionObj
|
|
52
|
+
* @returns {string}
|
|
53
|
+
*/
|
|
54
|
+
#getBodyFieldName(questionObj) {
|
|
55
|
+
// Use the first body field name from the question's bodyFieldNames getter
|
|
56
|
+
// This allows questions to define their own body field names, supporting
|
|
57
|
+
// custom components and complex field structures (e.g. date, date-time, address)
|
|
58
|
+
return questionObj.bodyFieldNames?.[0] ?? questionObj.fieldName;
|
|
36
59
|
}
|
|
37
60
|
|
|
38
61
|
/**
|
|
39
62
|
* Validates response body against individual field validators.
|
|
40
|
-
*
|
|
63
|
+
* Uses the question's getDataToSave method to extract and format body values when useBodyValuesForCurrent is true,
|
|
64
|
+
* allowing generic handling of any question type without needing to know its internal structure.
|
|
65
|
+
* @param {import('../questions/question.js').Question} questionObj - The question object containing the fieldName to validate.
|
|
41
66
|
* @param {import('../journey/journey-response.js').JourneyResponse} [journeyResponse={}] - The current journey response, used to access saved answers for validation.
|
|
42
67
|
* @returns {import('express-validator').ValidationChain[]}
|
|
43
68
|
*/
|
|
44
69
|
validate(questionObj, journeyResponse = {}) {
|
|
45
70
|
const fieldName = questionObj.fieldName;
|
|
71
|
+
const bodyFieldName = this.#getBodyFieldName(questionObj);
|
|
46
72
|
|
|
47
73
|
return [
|
|
48
|
-
body(
|
|
74
|
+
body(bodyFieldName).custom(async (value, { req }) => {
|
|
49
75
|
const answers = journeyResponse?.answers || {};
|
|
50
|
-
|
|
76
|
+
|
|
77
|
+
let currentAnswer;
|
|
78
|
+
if (this.useBodyValuesForCurrent) {
|
|
79
|
+
// Use the question's own getDataToSave to format body data generically
|
|
80
|
+
if (typeof questionObj.getDataToSave === 'function') {
|
|
81
|
+
const { answers: formattedAnswers } = await questionObj.getDataToSave(req, journeyResponse);
|
|
82
|
+
// Use the formatted answer if available, otherwise return all formatted answers
|
|
83
|
+
// (handles MultiFieldInputQuestion which returns answers keyed by input field names)
|
|
84
|
+
currentAnswer = fieldName in formattedAnswers ? formattedAnswers[fieldName] : formattedAnswers;
|
|
85
|
+
} else {
|
|
86
|
+
// Fallback to raw body value if getDataToSave is not available
|
|
87
|
+
currentAnswer = req.body[fieldName];
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
currentAnswer = answers[fieldName];
|
|
91
|
+
}
|
|
92
|
+
|
|
51
93
|
const dependencyAnswer = this.useBodyValues
|
|
52
94
|
? req.body[this.dependencyFieldName]
|
|
53
95
|
: answers[this.dependencyFieldName];
|
|
96
|
+
|
|
54
97
|
return (
|
|
55
98
|
this.validationFunction(currentAnswer, dependencyAnswer) ||
|
|
56
99
|
// Fallback if validation fails without throwing an error
|