From 9a3e133a44d858a26c2a3fa08c9c16c2776bc877 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 16 Apr 2026 09:29:58 +0100 Subject: [PATCH 1/3] Add min, max and length constraints to CheckboxesField --- .../engine/components/CheckboxesField.test.ts | 57 ++++++++++++++++++- .../engine/components/CheckboxesField.ts | 40 +++++++++++++ .../pageControllers/validationOptions.ts | 5 +- 3 files changed, 100 insertions(+), 2 deletions(-) diff --git a/src/server/plugins/engine/components/CheckboxesField.test.ts b/src/server/plugins/engine/components/CheckboxesField.test.ts index 08db675eb..3b3b28189 100644 --- a/src/server/plugins/engine/components/CheckboxesField.test.ts +++ b/src/server/plugins/engine/components/CheckboxesField.test.ts @@ -173,6 +173,61 @@ describe.each([ ) }) + it('is configured with min/max items', () => { + const collectionLimited = new ComponentCollection( + [{ ...def, schema: { min: 2, max: 4 } }], + { model } + ) + const { formSchema } = collectionLimited + const { keys } = formSchema.describe() + + expect(keys).toHaveProperty( + 'myComponent', + expect.objectContaining({ + items: [ + { + allow: options.allow, + flags: { + label: def.shortDescription, + only: true + }, + type: options.list.type + } + ], + rules: [ + { args: { limit: 2 }, name: 'min' }, + { args: { limit: 4 }, name: 'max' } + ] + }) + ) + }) + + it('is configured with length items', () => { + const collectionLimited = new ComponentCollection( + [{ ...def, schema: { length: 3 } }], + { model } + ) + const { formSchema } = collectionLimited + const { keys } = formSchema.describe() + + expect(keys).toHaveProperty( + 'myComponent', + expect.objectContaining({ + items: [ + { + allow: options.allow, + flags: { + label: def.shortDescription, + only: true + }, + type: options.list.type + } + ], + rules: [{ args: { limit: 3 }, name: 'length' }] + }) + ) + }) + it('adds errors for empty value', () => { const result = collection.validate(getFormData()) @@ -386,7 +441,7 @@ describe.each([ it('should return errors', () => { const errors = field.getAllPossibleErrors() expect(errors.baseErrors).not.toBeEmpty() - expect(errors.advancedSettingsErrors).toBeEmpty() + expect(errors.advancedSettingsErrors).not.toBeEmpty() }) }) diff --git a/src/server/plugins/engine/components/CheckboxesField.ts b/src/server/plugins/engine/components/CheckboxesField.ts index 665b1161d..6587dcd4d 100644 --- a/src/server/plugins/engine/components/CheckboxesField.ts +++ b/src/server/plugins/engine/components/CheckboxesField.ts @@ -5,7 +5,9 @@ import { isFormValue } from '~/src/server/plugins/engine/components/FormComponen import { SelectionControlField } from '~/src/server/plugins/engine/components/SelectionControlField.js' import { type FormModel } from '~/src/server/plugins/engine/models/FormModel.js' import { type QuestionPageController } from '~/src/server/plugins/engine/pageControllers/QuestionPageController.js' +import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { + type ErrorMessageTemplateList, type FormState, type FormStateValue, type FormSubmissionState @@ -13,6 +15,7 @@ import { export class CheckboxesField extends SelectionControlField { declare options: CheckboxesFieldComponent['options'] + declare schema: CheckboxesFieldComponent['schema'] declare formSchema: ArraySchema | ArraySchema declare stateSchema: ArraySchema | ArraySchema @@ -24,6 +27,7 @@ export class CheckboxesField extends SelectionControlField { const { listType: type } = this const { options } = def + const schema = 'schema' in def ? def.schema : {} let formSchema = type === 'string' ? joi.array() : joi.array() @@ -42,6 +46,18 @@ export class CheckboxesField extends SelectionControlField { formSchema = formSchema.optional() } + if (typeof schema?.length === 'number') { + formSchema = formSchema.length(schema.length) + } else { + if (typeof schema?.min === 'number') { + formSchema = formSchema.min(schema.min) + } + + if (typeof schema?.max === 'number') { + formSchema = formSchema.max(schema.max) + } + } + this.formSchema = formSchema.default([]) this.stateSchema = formSchema.default(null).allow(null) this.options = options @@ -112,6 +128,30 @@ export class CheckboxesField extends SelectionControlField { return this.getContextValueFromFormValue(values) } + /** + * For error preview page that shows all possible errors on a component + */ + getAllPossibleErrors(): ErrorMessageTemplateList { + return CheckboxesField.getAllPossibleErrors() + } + + /** + * Static version of getAllPossibleErrors that doesn't require a component instance. + */ + static getAllPossibleErrors(): ErrorMessageTemplateList { + const parentErrors = SelectionControlField.getAllPossibleErrors() + + return { + ...parentErrors, + advancedSettingsErrors: [ + ...parentErrors.advancedSettingsErrors, + { type: 'array.min', template: messageTemplate.arrayMin }, + { type: 'array.max', template: messageTemplate.arrayMax }, + { type: 'array.length', template: messageTemplate.arrayLength } + ] + } + } + isValue(value?: FormStateValue | FormState): value is Item['value'][] { if (!Array.isArray(value)) { return false diff --git a/src/server/plugins/engine/pageControllers/validationOptions.ts b/src/server/plugins/engine/pageControllers/validationOptions.ts index 7542fa932..73ae3ea0e 100644 --- a/src/server/plugins/engine/pageControllers/validationOptions.ts +++ b/src/server/plugins/engine/pageControllers/validationOptions.ts @@ -61,7 +61,10 @@ export const messageTemplate: Record = { ) as JoiExpression, dateFormat: '{{#title}} must be a real date', dateMin: '{{#title}} must be the same as or after {{#limit}}', - dateMax: '{{#title}} must be the same as or before {{#limit}}' + dateMax: '{{#title}} must be the same as or before {{#limit}}', + arrayMax: '{{#label}} must contain less than or equal to {{#limit}} items', + arrayMin: '{{#label}} must contain at least {{#limit}} items', + arrayLength: '{{#label}} must contain {{#limit}} items' } export const messages: LanguageMessagesExt = { From 8790586c442146176127fcf9a8990ef94c859da2 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 16 Apr 2026 10:52:59 +0100 Subject: [PATCH 2/3] Bump @defra/forms-model to v3.0.647 --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 955f71a7d..efad67f8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@defra/forms-model": "^3.0.644", + "@defra/forms-model": "^3.0.647", "@defra/hapi-tracing": "^1.29.0", "@defra/interactive-map": "^0.0.17-alpha", "@elastic/ecs-pino-format": "^1.5.0", @@ -3511,9 +3511,9 @@ } }, "node_modules/@defra/forms-model": { - "version": "3.0.644", - "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.644.tgz", - "integrity": "sha512-JagkRer3CFF3feWmu6NV72rhdPQCOMQ+z8dpPPNs3DzdUL2Pf7PoYixFqSNMkvZKNWG5XPaS8SDtbjvK4IdRXg==", + "version": "3.0.647", + "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.647.tgz", + "integrity": "sha512-H0zlUy51ownjQE6QnhJtm7anjcnVZrktI7yMloWUuDVhK4hN4c2Obnj5iB7QbqFEAeRxAXDjn2gjBm1JzZlx5A==", "license": "OGL-UK-3.0", "dependencies": { "@joi/date": "^2.1.1", diff --git a/package.json b/package.json index a212a5116..14d4566ad 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,7 @@ }, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@defra/forms-model": "^3.0.644", + "@defra/forms-model": "^3.0.647", "@defra/hapi-tracing": "^1.29.0", "@defra/interactive-map": "^0.0.17-alpha", "@elastic/ecs-pino-format": "^1.5.0", From 9e0603876b7344d4ea7494ad42a30d2fc4590658 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 16 Apr 2026 11:15:34 +0100 Subject: [PATCH 3/3] Update the error messages for Checkboxes schema contraints --- .../plugins/engine/pageControllers/validationOptions.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/server/plugins/engine/pageControllers/validationOptions.ts b/src/server/plugins/engine/pageControllers/validationOptions.ts index 73ae3ea0e..1eb1f1848 100644 --- a/src/server/plugins/engine/pageControllers/validationOptions.ts +++ b/src/server/plugins/engine/pageControllers/validationOptions.ts @@ -62,9 +62,9 @@ export const messageTemplate: Record = { dateFormat: '{{#title}} must be a real date', dateMin: '{{#title}} must be the same as or after {{#limit}}', dateMax: '{{#title}} must be the same as or before {{#limit}}', - arrayMax: '{{#label}} must contain less than or equal to {{#limit}} items', - arrayMin: '{{#label}} must contain at least {{#limit}} items', - arrayLength: '{{#label}} must contain {{#limit}} items' + arrayMax: 'Only {{#limit}} can be selected from the list', + arrayMin: 'Select at least {{#limit}} options from the list', + arrayLength: 'Select only {{#limit}} options from the list' } export const messages: LanguageMessagesExt = {