@planning-inspectorate/dynamic-forms 3.11.0 → 3.13.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planning-inspectorate/dynamic-forms",
3
- "version": "3.11.0",
3
+ "version": "3.13.0",
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",
package/src/index.js CHANGED
@@ -60,6 +60,7 @@ export * from './validator/base-validator.js';
60
60
  export * from './validator/conditional-required-validator.js';
61
61
  export * from './validator/confirmation-checkbox-validator.js';
62
62
  export * from './validator/coordinates-validator.js';
63
+ export * from './validator/cross-question-validator.js';
63
64
  export * from './validator/date-period-validator.js';
64
65
  export * from './validator/date-time-validator.js';
65
66
  export * from './validator/date-validator.js';
@@ -13,6 +13,19 @@ export class BaseValidator {
13
13
  throw new Error("Abstract classes can't be instantiated.");
14
14
  }
15
15
  }
16
+
17
+ /**
18
+ * Validates response body against field validators.
19
+ * Subclasses must override this method.
20
+ * @abstract
21
+ * @param {import('../questions/question-props.js').QuestionProps} questionObj - The question object containing the fieldName to validate.
22
+ * @param {import('../journey/journey-response.js').JourneyResponse} [journeyResponse] - The current journey response (optional).
23
+ * @returns {import('express-validator').ValidationChain | import('express-validator').ValidationChain[]}
24
+ */
25
+ // eslint-disable-next-line no-unused-vars
26
+ validate(questionObj, journeyResponse) {
27
+ throw new Error('validate method must be implemented by subclass');
28
+ }
16
29
  }
17
30
 
18
31
  export default BaseValidator;
@@ -0,0 +1,66 @@
1
+ import BaseValidator from './base-validator.js';
2
+ import { body } from 'express-validator';
3
+
4
+ /**
5
+ * Validator for validating a question's answer against another question's answer using a custom validation function.
6
+ */
7
+ export class CrossQuestionValidator extends BaseValidator {
8
+ /** @type {string} */
9
+ dependencyFieldName;
10
+ /** @type {((currentAnswer: unknown, dependencyAnswer: unknown) => boolean)} */
11
+ validationFunction;
12
+ /** @type {boolean} */
13
+ useBodyValues;
14
+
15
+ /**
16
+ * @param {Object} params
17
+ * @param {string} params.dependencyFieldName - The field name of the other question to compare against.
18
+ * @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 session answers, but if used between multiple fields on one question, it will need to use the body.
20
+ * @throws {Error} If validationFunction is not provided or is not a function.
21
+ * @throws {Error} If dependencyFieldName is not provided
22
+ */
23
+ constructor({ dependencyFieldName, validationFunction, useBodyValues = false } = {}) {
24
+ super();
25
+
26
+ if (!dependencyFieldName) {
27
+ throw new Error('CrossQuestionValidator requires dependencyFieldName');
28
+ }
29
+ if (!validationFunction || typeof validationFunction !== 'function') {
30
+ throw new Error('CrossQuestionValidator requires a validationFunction');
31
+ }
32
+
33
+ this.dependencyFieldName = dependencyFieldName;
34
+ this.validationFunction = validationFunction;
35
+ this.useBodyValues = useBodyValues;
36
+ }
37
+
38
+ /**
39
+ * Validates response body against individual field validators.
40
+ * @param {import('../questions/question-props.js').QuestionProps} questionObj - The question object containing the fieldName to validate.
41
+ * @param {import('../journey/journey-response.js').JourneyResponse} [journeyResponse={}] - The current journey response, used to access saved answers for validation.
42
+ * @returns {import('express-validator').ValidationChain[]}
43
+ */
44
+ validate(questionObj, journeyResponse = {}) {
45
+ const fieldName = questionObj.fieldName;
46
+
47
+ return [
48
+ body(fieldName).custom((value, { req }) => {
49
+ const answers = journeyResponse?.answers || {};
50
+ const currentAnswer = this.useBodyValues ? value : answers[fieldName];
51
+ const dependencyAnswer = this.useBodyValues
52
+ ? req.body[this.dependencyFieldName]
53
+ : answers[this.dependencyFieldName];
54
+ return (
55
+ this.validationFunction(currentAnswer, dependencyAnswer) ||
56
+ // Fallback if validation fails without throwing an error
57
+ Promise.reject(
58
+ new Error(`Cross-question validation failed between ${fieldName} and ${this.dependencyFieldName}`)
59
+ )
60
+ );
61
+ })
62
+ ];
63
+ }
64
+ }
65
+
66
+ export default CrossQuestionValidator;
@@ -0,0 +1,132 @@
1
+ import { describe, test, it, mock } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import CrossQuestionValidator from './cross-question-validator.js';
4
+
5
+ function createMockRequest(answers) {
6
+ return {
7
+ res: {
8
+ locals: {
9
+ journeyResponse: {
10
+ answers: answers || {}
11
+ }
12
+ }
13
+ }
14
+ };
15
+ }
16
+
17
+ describe('CrossQuestionValidator', () => {
18
+ test('should throw an error if dependencyFieldName is missing', () => {
19
+ assert.throws(() => {
20
+ new CrossQuestionValidator({ validationFunction: () => true });
21
+ }, /requires dependencyFieldName/);
22
+ });
23
+
24
+ test('should throw an error if validationFunction is missing', () => {
25
+ assert.throws(() => {
26
+ new CrossQuestionValidator({ dependencyFieldName: 'other' });
27
+ }, /requires a validationFunction/);
28
+ });
29
+
30
+ test('should throw an error if validationFunction is not a function', () => {
31
+ assert.throws(() => {
32
+ new CrossQuestionValidator({ dependencyFieldName: 'other', validationFunction: 123 });
33
+ }, /requires a validationFunction/);
34
+ });
35
+
36
+ test('should return a validation chain that calls the validation function with correct answers', async () => {
37
+ const validationFn = mock.fn(() => true);
38
+ const validator = new CrossQuestionValidator({
39
+ dependencyFieldName: 'other',
40
+ validationFunction: validationFn
41
+ });
42
+ const questionObj = { fieldName: 'field' };
43
+ const req = createMockRequest({ field: 'foo', other: 'bar' });
44
+ const chain = validator.validate(questionObj, req.res.locals.journeyResponse);
45
+ await chain[0].run(req);
46
+ assert.deepEqual(validationFn.mock.calls[0].arguments, ['foo', 'bar']);
47
+ });
48
+
49
+ test('should fail validation if validation function returns false', async () => {
50
+ const validator = new CrossQuestionValidator({
51
+ dependencyFieldName: 'other',
52
+ validationFunction: () => false
53
+ });
54
+ const questionObj = { fieldName: 'field' };
55
+ const chain = validator.validate(questionObj);
56
+ const req = createMockRequest({ field: 'foo', other: 'bar' });
57
+ req.body = { field: 'foo' };
58
+ const result = await chain[0].run(req);
59
+ assert.strictEqual(result.isEmpty(), false);
60
+ assert.match(result.array()[0].msg, /Cross-question validation failed/);
61
+ });
62
+
63
+ test('should call validation function when journeyResponse is missing', async () => {
64
+ const validationFn = mock.fn(() => true);
65
+ const validator = new CrossQuestionValidator({
66
+ dependencyFieldName: 'other',
67
+ validationFunction: validationFn
68
+ });
69
+ const questionObj = { fieldName: 'field' };
70
+ const chain = validator.validate(questionObj);
71
+ const req = { res: { locals: {} } };
72
+ await chain[0].run(req);
73
+ assert.deepEqual(validationFn.mock.calls[0].arguments, [undefined, undefined]);
74
+ });
75
+
76
+ it('should use session answers instead of request body values when useBodyValues is false', async () => {
77
+ const validationFn = mock.fn(() => true);
78
+ const validator = new CrossQuestionValidator({
79
+ dependencyFieldName: 'other',
80
+ validationFunction: validationFn
81
+ });
82
+ const questionObj = { fieldName: 'field' };
83
+ const req = createMockRequest({ field: 'from-session', other: 'from-session-dependency' });
84
+ req.body = { field: 'from-body', other: 'from-body-dependency' };
85
+ const chain = validator.validate(questionObj, req.res.locals.journeyResponse);
86
+ await chain[0].run(req);
87
+ assert.deepEqual(validationFn.mock.calls[0].arguments, ['from-session', 'from-session-dependency']);
88
+ });
89
+
90
+ it('should use request body values when useBodyValues is true', async () => {
91
+ const validationFn = mock.fn(() => true);
92
+ const validator = new CrossQuestionValidator({
93
+ dependencyFieldName: 'other',
94
+ useBodyValues: true,
95
+ validationFunction: validationFn
96
+ });
97
+ const questionObj = { fieldName: 'field' };
98
+ const chain = validator.validate(questionObj);
99
+ const req = createMockRequest({ field: 'from-session', other: 'from-session-dependency' });
100
+ req.body = { field: 'from-body', other: 'from-body-dependency' };
101
+ await chain[0].run(req);
102
+ assert.deepEqual(validationFn.mock.calls[0].arguments, ['from-body', 'from-body-dependency']);
103
+ });
104
+
105
+ it('should fail validation with thrown error message when validation function throws', async () => {
106
+ const validator = new CrossQuestionValidator({
107
+ dependencyFieldName: 'other',
108
+ validationFunction: () => {
109
+ throw new Error('boom');
110
+ }
111
+ });
112
+ const questionObj = { fieldName: 'field' };
113
+ const chain = validator.validate(questionObj);
114
+ const req = createMockRequest({ field: 'foo', other: 'bar' });
115
+ const result = await chain[0].run(req);
116
+ assert.strictEqual(result.isEmpty(), false);
117
+ assert.strictEqual(result.array()[0].msg, 'boom');
118
+ });
119
+
120
+ it('should call validation function with undefined values when journeyResponse has no answers object', async () => {
121
+ const validationFn = mock.fn(() => true);
122
+ const validator = new CrossQuestionValidator({
123
+ dependencyFieldName: 'other',
124
+ validationFunction: validationFn
125
+ });
126
+ const questionObj = { fieldName: 'field' };
127
+ const chain = validator.validate(questionObj);
128
+ const req = { res: { locals: { journeyResponse: {} } } };
129
+ await chain[0].run(req);
130
+ assert.deepEqual(validationFn.mock.calls[0].arguments, [undefined, undefined]);
131
+ });
132
+ });
@@ -35,6 +35,7 @@ export * from "./validator/base-validator.js";
35
35
  export * from "./validator/conditional-required-validator.js";
36
36
  export * from "./validator/confirmation-checkbox-validator.js";
37
37
  export * from "./validator/coordinates-validator.js";
38
+ export * from "./validator/cross-question-validator.js";
38
39
  export * from "./validator/date-period-validator.js";
39
40
  export * from "./validator/date-time-validator.js";
40
41
  export * from "./validator/date-validator.js";
@@ -7,6 +7,15 @@ export class BaseValidator {
7
7
  * @type {string} error message to display to user
8
8
  */
9
9
  errorMessage: string;
10
+ /**
11
+ * Validates response body against field validators.
12
+ * Subclasses must override this method.
13
+ * @abstract
14
+ * @param {import('../questions/question-props.js').QuestionProps} questionObj - The question object containing the fieldName to validate.
15
+ * @param {import('../journey/journey-response.js').JourneyResponse} [journeyResponse] - The current journey response (optional).
16
+ * @returns {import('express-validator').ValidationChain | import('express-validator').ValidationChain[]}
17
+ */
18
+ validate(questionObj: import("../questions/question-props.js").QuestionProps, journeyResponse?: import("../journey/journey-response.js").JourneyResponse): import("express-validator").ValidationChain | import("express-validator").ValidationChain[];
10
19
  }
11
20
  export default BaseValidator;
12
21
  //# sourceMappingURL=base-validator.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"base-validator.d.ts","sourceRoot":"","sources":["../../../src/validator/base-validator.js"],"names":[],"mappings":"AAAA;;;GAGG;AACH;IACC;;OAEG;IACH,cAFU,MAAM,CAEH;CAOb"}
1
+ {"version":3,"file":"base-validator.d.ts","sourceRoot":"","sources":["../../../src/validator/base-validator.js"],"names":[],"mappings":"AAAA;;;GAGG;AACH;IACC;;OAEG;IACH,cAFU,MAAM,CAEH;IAQb;;;;;;;OAOG;IAEH,sBALW,OAAO,gCAAgC,EAAE,aAAa,oBACtD,OAAO,gCAAgC,EAAE,eAAe,GACtD,OAAO,mBAAmB,EAAE,eAAe,GAAG,OAAO,mBAAmB,EAAE,eAAe,EAAE,CAKvG;CACD"}
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Validator for validating a question's answer against another question's answer using a custom validation function.
3
+ */
4
+ export class CrossQuestionValidator extends BaseValidator {
5
+ /**
6
+ * @param {Object} params
7
+ * @param {string} params.dependencyFieldName - The field name of the other question to compare against.
8
+ * @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.
9
+ * @param {boolean} [params.useBodyValues=false] - Whether to use req.body values for validation (default: false). Usually CrossQuestionValidator will be for validating across saved session answers, but if used between multiple fields on one question, it will need to use the body.
10
+ * @throws {Error} If validationFunction is not provided or is not a function.
11
+ * @throws {Error} If dependencyFieldName is not provided
12
+ */
13
+ constructor({ dependencyFieldName, validationFunction, useBodyValues }?: {
14
+ dependencyFieldName: string;
15
+ validationFunction: ((currentAnswer: unknown, dependencyAnswer: unknown) => boolean) | null;
16
+ useBodyValues?: boolean | undefined;
17
+ });
18
+ /** @type {string} */
19
+ dependencyFieldName: string;
20
+ /** @type {((currentAnswer: unknown, dependencyAnswer: unknown) => boolean)} */
21
+ validationFunction: ((currentAnswer: unknown, dependencyAnswer: unknown) => boolean);
22
+ /** @type {boolean} */
23
+ useBodyValues: boolean;
24
+ /**
25
+ * Validates response body against individual field validators.
26
+ * @param {import('../questions/question-props.js').QuestionProps} questionObj - The question object containing the fieldName to validate.
27
+ * @param {import('../journey/journey-response.js').JourneyResponse} [journeyResponse={}] - The current journey response, used to access saved answers for validation.
28
+ * @returns {import('express-validator').ValidationChain[]}
29
+ */
30
+ validate(questionObj: import("../questions/question-props.js").QuestionProps, journeyResponse?: import("../journey/journey-response.js").JourneyResponse): import("express-validator").ValidationChain[];
31
+ }
32
+ export default CrossQuestionValidator;
33
+ import BaseValidator from './base-validator.js';
34
+ //# sourceMappingURL=cross-question-validator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cross-question-validator.d.ts","sourceRoot":"","sources":["../../../src/validator/cross-question-validator.js"],"names":[],"mappings":"AAGA;;GAEG;AACH;IAQC;;;;;;;OAOG;IACH,yEANG;QAAuB,mBAAmB,EAAlC,MAAM;QACyE,kBAAkB,EAAjG,CAAC,CAAC,aAAa,EAAE,OAAO,EAAE,gBAAgB,EAAE,OAAO,KAAK,OAAO,CAAC,GAAE,IAAI;QACrD,aAAa;KACtC,EAgBF;IA5BD,qBAAqB;IACrB,qBADW,MAAM,CACG;IACpB,+EAA+E;IAC/E,oBADW,CAAC,CAAC,aAAa,EAAE,OAAO,EAAE,gBAAgB,EAAE,OAAO,KAAK,OAAO,CAAC,CACxD;IACnB,sBAAsB;IACtB,eADW,OAAO,CACJ;IAyBd;;;;;OAKG;IACH,sBAJW,OAAO,gCAAgC,EAAE,aAAa,oBACtD,OAAO,gCAAgC,EAAE,eAAe,GACtD,OAAO,mBAAmB,EAAE,eAAe,EAAE,CAqBzD;CACD;;0BA/DyB,qBAAqB"}