From 3003a0b6d605b94a3099856c86f35eeaa1d329ec Mon Sep 17 00:00:00 2001 From: whitewater design Date: Mon, 20 Oct 2025 21:42:06 +0100 Subject: [PATCH 01/10] feat: DF-341 - Setup DeclarationField.ts --- src/server/forms/components.json | 7 + .../components/DeclarationField.test.ts | 359 ++++++++++++++++++ .../engine/components/DeclarationField.ts | 119 ++++++ .../engine/components/helpers/components.ts | 3 + src/server/plugins/engine/components/index.ts | 1 + .../pageControllers/validationOptions.ts | 4 + .../engine/services/localFormsService.js | 7 + .../views/components/declarationfield.html | 14 + src/server/plugins/nunjucks/filters/index.js | 1 + src/server/plugins/nunjucks/filters/merge.js | 18 + .../plugins/nunjucks/filters/merge.test.js | 18 + 11 files changed, 551 insertions(+) create mode 100644 src/server/plugins/engine/components/DeclarationField.test.ts create mode 100644 src/server/plugins/engine/components/DeclarationField.ts create mode 100644 src/server/plugins/engine/views/components/declarationfield.html create mode 100644 src/server/plugins/nunjucks/filters/merge.js create mode 100644 src/server/plugins/nunjucks/filters/merge.test.js diff --git a/src/server/forms/components.json b/src/server/forms/components.json index b96bea36c..574fb7775 100644 --- a/src/server/forms/components.json +++ b/src/server/forms/components.json @@ -120,6 +120,13 @@ "content": "### This is a H3 in markdown\n\n[An internal link](http://localhost:3009/fictional-page)\n\n[An external link](https://defra.gov.uk/fictional-page)", "options": {}, "schema": {} + }, + { + "type": "DeclarationField", + "name": "declaration", + "title": "Declaration", + "content": "By submitting this form, I agree to:\n\n- Provide accurate and complete information\n- Comply with all applicable regulations\n- Accept responsibility for any false statements", + "hint": "Please read and confirm the following terms" } ] } diff --git a/src/server/plugins/engine/components/DeclarationField.test.ts b/src/server/plugins/engine/components/DeclarationField.test.ts new file mode 100644 index 000000000..24dafc47d --- /dev/null +++ b/src/server/plugins/engine/components/DeclarationField.test.ts @@ -0,0 +1,359 @@ +import { + ComponentType, + type DeclarationFieldComponent +} from '@defra/forms-model' +import joi, { type BooleanSchema } from 'joi' + +import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' +import { DeclarationField } from '~/src/server/plugins/engine/components/DeclarationField.js' +import { + getAnswer, + type Field +} from '~/src/server/plugins/engine/components/helpers/components.js' +import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' +import definition from '~/test/form/definitions/blank.js' +import { getFormData, getFormState } from '~/test/helpers/component-helpers.js' + +describe('DeclarationField', () => { + let model: FormModel + + beforeEach(() => { + model = new FormModel(definition, { + basePath: 'test' + }) + }) + + describe('Defaults', () => { + let def: DeclarationFieldComponent + let collection: ComponentCollection + let field: Field + + beforeEach(() => { + def = { + title: 'Example Declaration Component', + name: 'myComponent', + content: 'Lorem ipsum dolar sit amet', + shortDescription: 'Terms and conditions', + type: ComponentType.DeclarationField, + options: {} + } satisfies DeclarationFieldComponent + + collection = new ComponentCollection([def], { model }) + field = collection.fields[0] + }) + + describe('Schema', () => { + it('uses component title as label as default', () => { + const { formSchema } = collection + const { keys } = formSchema.describe() + + expect(keys).toHaveProperty( + 'myComponent', + expect.objectContaining({ + flags: expect.objectContaining({ + label: 'Terms and conditions' + }) + }) + ) + }) + + it('uses component name as keys', () => { + const { formSchema } = collection + const { keys } = formSchema.describe() + + expect(field.keys).toEqual(['myComponent']) + expect(field.collection).toBeUndefined() + + for (const key of field.keys) { + expect(keys).toHaveProperty(key) + } + }) + + it('is required by default', () => { + const { formSchema } = collection + const { keys } = formSchema.describe() + + expect(keys).toHaveProperty( + 'myComponent', + expect.objectContaining({ + flags: expect.objectContaining({ + presence: 'required' + }) + }) + ) + }) + + it('is cast to a string', () => { + const { formSchema } = collection + const { keys } = formSchema.describe() + + expect(keys).toHaveProperty( + 'myComponent', + expect.objectContaining({ + flags: expect.objectContaining({ + cast: 'string' + }) + }) + ) + }) + + it('is optional when configured', () => { + const collectionOptional = new ComponentCollection( + [{ ...def, options: { required: false } }], + { model } + ) + + const { formSchema } = collectionOptional + const { keys } = formSchema.describe() + + expect(keys).toHaveProperty( + 'myComponent', + expect.objectContaining({ + flags: expect.objectContaining({ + presence: 'optional' + }) + }) + ) + + const result = collectionOptional.validate(getFormData()) + expect(result.errors).toBeUndefined() + }) + + it('accepts valid values', () => { + const result1 = collection.validate(getFormData('true')) + + expect(result1.errors).toBeUndefined() + }) + + it('adds errors for empty value', () => { + const result = collection.validate(getFormData()) + + expect(result.errors).toEqual([ + expect.objectContaining({ + text: 'You must confirm you understand and agree with terms and conditions to continue' + }) + ]) + }) + + it('adds errors for invalid values', () => { + const result1 = collection.validate(getFormData(['invalid'])) + const result2 = collection.validate( + // @ts-expect-error - Allow invalid param for test + getFormData({ unknown: 'invalid' }) + ) + const result3 = collection.validate('false') + + expect(result1.errors).toBeTruthy() + expect(result2.errors).toBeTruthy() + expect(result3.errors).toBeTruthy() + }) + }) + + describe('State', () => { + it('returns text from state', () => { + const state1 = getFormState('true') + const state2 = getFormState() + // context - boolean + // state - boolean + // string - I confirm that I understand and accept this declaration + const answer1 = getAnswer(field, state1) + const answer2 = getAnswer(field, state2) + + expect(answer1).toBe('true') + expect(answer2).toBe('false') + }) + + it('returns payload from state', () => { + const state1 = getFormState('Text field') + const state2 = getFormState(null) + + const payload1 = field.getFormDataFromState(state1) + const payload2 = field.getFormDataFromState(state2) + + expect(payload1).toEqual(getFormData('Text field')) + expect(payload2).toEqual(getFormData()) + }) + + it('returns value from state', () => { + const state1 = getFormState('Text field') + const state2 = getFormState(null) + + const value1 = field.getFormValueFromState(state1) + const value2 = field.getFormValueFromState(state2) + + expect(value1).toBe('Text field') + expect(value2).toBeUndefined() + }) + + it('returns context for conditions and form submission', () => { + const state1 = getFormState('Text field') + const state2 = getFormState(null) + + const value1 = field.getContextValueFromState(state1) + const value2 = field.getContextValueFromState(state2) + + expect(value1).toBe('Text field') + expect(value2).toBeNull() + }) + + it('returns state from payload', () => { + const payload1 = getFormData('Text field') + const payload2 = getFormData() + + const value1 = field.getStateFromValidForm(payload1) + const value2 = field.getStateFromValidForm(payload2) + + expect(value1).toEqual(getFormState('Text field')) + expect(value2).toEqual(getFormState(null)) + }) + }) + + describe('View model', () => { + it('sets Nunjucks component defaults', () => { + const viewModel = field.getViewModel(getFormData()) + + expect(viewModel).toEqual( + expect.objectContaining({ + label: { text: def.title }, + name: 'myComponent', + attributes: {}, + value: undefined, + content: 'Lorem ipsum dolar sit amet', + id: 'myComponent', + fieldset: { + legend: { + text: 'Example Declaration Component' + } + }, + items: [ + { + value: 'true', + text: 'I confirm that I understand and accept this declaration' + } + ] + }) + ) + }) + + it('sets Nunjucks component value when posted', () => { + def = { + ...def, + hint: 'Please read and confirm the following' + } satisfies DeclarationFieldComponent + + collection = new ComponentCollection([def], { model }) + field = collection.fields[0] + const viewModel = field.getViewModel(getFormData('true')) + + expect(viewModel).toEqual( + expect.objectContaining({ + value: 'true', + hint: { + text: 'Please read and confirm the following' + } + }) + ) + }) + + it('sets custom message when in def', () => { + def = { + ...def, + title: 'Declaration', + content: + 'Declaration:\n' + + 'By submitting this form, I consent to the collection and processing of my personal data for the purposes described.\n' + + 'I understand that my data may be shared with authorised third parties where required by law', + options: { + declarationConfirmationLabel: + 'I consent to the processing of my personal data' + } + } satisfies DeclarationFieldComponent + + collection = new ComponentCollection([def], { model }) + field = collection.fields[0] + + const viewModel = field.getViewModel(getFormData('true')) + + expect(viewModel).toEqual( + expect.objectContaining({ + items: [ + { + value: 'true', + text: 'I consent to the processing of my personal data' + } + ] + }) + ) + }) + }) + + describe('AllPossibleErrors', () => { + it('should return errors', () => { + const errors = field.getAllPossibleErrors() + expect(errors.baseErrors).not.toBeEmpty() + expect(errors.advancedSettingsErrors).toBeEmpty() + }) + }) + }) + + describe('Validation', () => { + describe.each([ + { + description: 'Use short description if it exists', + component: { + title: 'Terms and conditions', + shortDescription: 'The terms and conditions', + content: 'Lorem ipsum dolar sit amet', + name: 'myComponent', + type: ComponentType.DeclarationField, + options: {} + } satisfies DeclarationFieldComponent, + assertions: [ + { + input: getFormData(), + output: { + value: getFormData('false'), + errors: [ + expect.objectContaining({ + text: 'You must confirm you understand and agree with the terms and conditions to continue' + }) + ] + } + } + ] + }, + { + description: 'Optional field', + component: { + title: 'Example text field', + name: 'myComponent', + content: 'Lorem ipsum dolar sit amet', + type: ComponentType.DeclarationField, + options: { + required: false + } + } satisfies DeclarationFieldComponent, + assertions: [ + { + input: getFormData(), + output: { value: getFormData('false') } + } + ] + } + ])('$description', ({ component: def, assertions }) => { + let collection: ComponentCollection + + beforeEach(() => { + collection = new ComponentCollection([def], { model }) + }) + + it.each([...assertions])( + 'validates custom example', + ({ input, output }) => { + const result = collection.validate(input) + expect(result).toEqual(output) + } + ) + }) + }) +}) diff --git a/src/server/plugins/engine/components/DeclarationField.ts b/src/server/plugins/engine/components/DeclarationField.ts new file mode 100644 index 000000000..81377e4ac --- /dev/null +++ b/src/server/plugins/engine/components/DeclarationField.ts @@ -0,0 +1,119 @@ +import { type DeclarationFieldComponent } from '@defra/forms-model' +import joi, { type BooleanSchema, type StringSchema } from 'joi' + +import { + FormComponent, + isFormValue +} from '~/src/server/plugins/engine/components/FormComponent.js' +import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' +import { + type ErrorMessageTemplateList, + type FormPayload, + type FormState, + type FormStateValue, + type FormSubmissionError, + type FormSubmissionState +} from '~/src/server/plugins/engine/types.js' + +export class DeclarationField extends FormComponent { + declare options: DeclarationFieldComponent['options'] + + declare schema: DeclarationFieldComponent['schema'] + + declare formSchema: BooleanSchema + declare stateSchema: BooleanSchema + declare content: string + + constructor( + def: DeclarationFieldComponent, + props: ConstructorParameters[1] + ) { + super(def, props) + + const { options, content } = def + + let formSchema = joi + .boolean() + .valid(true) + .cast('string') + .label(this.label) + .required() as BooleanSchema + + if (options.required === false) { + formSchema = formSchema.optional() + } else { + formSchema = formSchema.messages({ + 'any.required': messageTemplate.declarationRequired as string + }) + } + + this.formSchema = formSchema.default(false) + this.stateSchema = formSchema.default(false) + this.options = options + this.content = content + this.declarationConfirmationLabel = options.declarationConfirmationLabel + } + + getFormValueFromState(state: FormSubmissionState) { + const { name } = this + return this.getFormValue(state[name]) + } + + getFormValue(value?: FormStateValue | FormState) { + return this.isValue(value) ? value : undefined + } + + getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { + const defaultDeclarationConfirmationLabel = + 'I confirm that I understand and accept this declaration' + const { + title, + hint, + content, + declarationConfirmationLabel = defaultDeclarationConfirmationLabel + } = this + return { + ...super.getViewModel(payload, errors), + hint: hint && { text: hint }, + fieldset: { + legend: { + text: title + } + }, + content, + items: [ + { + text: declarationConfirmationLabel, + value: 'true' + } + ] + } + } + + isValue(value?: FormStateValue | FormState): value is string { + return DeclarationField.isBool(value) + } + + /** + * For error preview page that shows all possible errors on a component + */ + getAllPossibleErrors(): ErrorMessageTemplateList { + return DeclarationField.getAllPossibleErrors() + } + + /** + * Static version of getAllPossibleErrors that doesn't require a component instance. + */ + static getAllPossibleErrors(): ErrorMessageTemplateList { + return { + baseErrors: [ + { type: 'required', template: messageTemplate.declarationRequired } + ], + advancedSettingsErrors: [] + } + } + + static isBool(value?: FormStateValue | FormState): value is string { + return isFormValue(value) && typeof value === 'string' + } +} diff --git a/src/server/plugins/engine/components/helpers/components.ts b/src/server/plugins/engine/components/helpers/components.ts index 8fa55556d..a0ce1bb20 100644 --- a/src/server/plugins/engine/components/helpers/components.ts +++ b/src/server/plugins/engine/components/helpers/components.ts @@ -197,6 +197,9 @@ export function createComponent( case ComponentType.FileUploadField: component = new Components.FileUploadField(def, options) break + + case ComponentType.DeclarationField: + component = new Components.DeclarationField(def, options) } if (typeof component === 'undefined') { diff --git a/src/server/plugins/engine/components/index.ts b/src/server/plugins/engine/components/index.ts index 8b6d001b7..9a95732c9 100644 --- a/src/server/plugins/engine/components/index.ts +++ b/src/server/plugins/engine/components/index.ts @@ -7,6 +7,7 @@ export { AutocompleteField } from '~/src/server/plugins/engine/components/AutocompleteField.js' export { CheckboxesField } from '~/src/server/plugins/engine/components/CheckboxesField.js' export { DatePartsField } from '~/src/server/plugins/engine/components/DatePartsField.js' +export { DeclarationField } from '~/src/server/plugins/engine/components/DeclarationField.js' export { Details } from '~/src/server/plugins/engine/components/Details.js' export { EmailAddressField } from '~/src/server/plugins/engine/components/EmailAddressField.js' export { FileUploadField } from '~/src/server/plugins/engine/components/FileUploadField.js' diff --git a/src/server/plugins/engine/pageControllers/validationOptions.ts b/src/server/plugins/engine/pageControllers/validationOptions.ts index 4c4575c58..1e2142f31 100644 --- a/src/server/plugins/engine/pageControllers/validationOptions.ts +++ b/src/server/plugins/engine/pageControllers/validationOptions.ts @@ -20,6 +20,10 @@ const opts = { * see @link https://joi.dev/api/?v=17.4.2#template-syntax for template syntax */ export const messageTemplate: Record = { + declarationRequired: joi.expression( + 'You must confirm you understand and agree with {{lowerFirst(#label)}} to continue', + opts + ) as JoiExpression, required: joi.expression( 'Enter {{lowerFirst(#label)}}', opts diff --git a/src/server/plugins/engine/services/localFormsService.js b/src/server/plugins/engine/services/localFormsService.js index c824bad34..c5e4e2356 100644 --- a/src/server/plugins/engine/services/localFormsService.js +++ b/src/server/plugins/engine/services/localFormsService.js @@ -51,5 +51,12 @@ export const formsService = async () => { slug: 'components' }) + await loader.addForm('src/server/forms/error.json', { + ...metadata, + id: '0920b6b1-c5b4-4b7d-8783-7dda7fd559f8', + title: 'Error', + slug: 'error' + }) + return loader.toFormsService() } diff --git a/src/server/plugins/engine/views/components/declarationfield.html b/src/server/plugins/engine/views/components/declarationfield.html new file mode 100644 index 000000000..97414bc4c --- /dev/null +++ b/src/server/plugins/engine/views/components/declarationfield.html @@ -0,0 +1,14 @@ +{% from "govuk/components/fieldset/macro.njk" import govukFieldset %} +{% from "govuk/components/checkboxes/macro.njk" import govukCheckboxes %} +{% from "govuk/components/hint/macro.njk" import govukHint %} + +{% macro DeclarationField(component) %} + {% set content %} +
+ {{ component.model.content | markdown | safe }} +
+ {% endset %} + {% set checkboxes = component.model | merge({ formGroup: { beforeInputs: { html: content } } }) %} + {{ govukCheckboxes(checkboxes) }} + {{ checkboxes | dump }} +{% endmacro %} diff --git a/src/server/plugins/nunjucks/filters/index.js b/src/server/plugins/nunjucks/filters/index.js index 0e636b5c0..b622c5ab8 100644 --- a/src/server/plugins/nunjucks/filters/index.js +++ b/src/server/plugins/nunjucks/filters/index.js @@ -5,3 +5,4 @@ export { answer } from '~/src/server/plugins/nunjucks/filters/answer.js' export { href } from '~/src/server/plugins/nunjucks/filters/href.js' export { field } from '~/src/server/plugins/nunjucks/filters/field.js' export { page } from '~/src/server/plugins/nunjucks/filters/page.js' +export { merge } from '~/src/server/plugins/nunjucks/filters/merge.js' diff --git a/src/server/plugins/nunjucks/filters/merge.js b/src/server/plugins/nunjucks/filters/merge.js new file mode 100644 index 000000000..ad3cd3f39 --- /dev/null +++ b/src/server/plugins/nunjucks/filters/merge.js @@ -0,0 +1,18 @@ +/** + * Nunjucks filter to get the page for a given path + * @param {Record} targetDictionary - Object to extend + * @param {Record} sourceDictionary - Object to merge into target + */ +export function merge(targetDictionary, sourceDictionary) { + if ( + typeof targetDictionary !== 'object' || + typeof sourceDictionary !== 'object' + ) { + return targetDictionary + } + + return { + ...targetDictionary, + ...sourceDictionary + } +} diff --git a/src/server/plugins/nunjucks/filters/merge.test.js b/src/server/plugins/nunjucks/filters/merge.test.js new file mode 100644 index 000000000..62f72ed25 --- /dev/null +++ b/src/server/plugins/nunjucks/filters/merge.test.js @@ -0,0 +1,18 @@ +import { merge } from '~/src/server/plugins/nunjucks/filters/merge.js' + +describe('merge', () => { + const propertyToMerge = { lorem: 'ipsum' } + it('should return the target if target is not an object', () => { + expect(merge('string', propertyToMerge)).toBe('string') + }) + it('should return the target if source is not an object', () => { + expect(merge(propertyToMerge, 'dolar')).toBe(propertyToMerge) + }) + + it('should merge the properties if they are valid', () => { + expect(merge({ lorem: 'dolar', dolar: 'sit' }, propertyToMerge)).toEqual({ + lorem: 'ipsum', + dolar: 'sit' + }) + }) +}) From 696108770f305693622275b27092c2665603afd6 Mon Sep 17 00:00:00 2001 From: whitewater design Date: Wed, 22 Oct 2025 09:02:13 +0100 Subject: [PATCH 02/10] feat: DF-341 - Update the declaration field checkbox label --- .../components/DeclarationField.test.ts | 26 +++++++++---------- .../engine/components/DeclarationField.ts | 22 +++++++++++----- .../engine/services/localFormsService.js | 7 ----- .../views/components/declarationfield.html | 1 - 4 files changed, 28 insertions(+), 28 deletions(-) diff --git a/src/server/plugins/engine/components/DeclarationField.test.ts b/src/server/plugins/engine/components/DeclarationField.test.ts index 24dafc47d..69cde3330 100644 --- a/src/server/plugins/engine/components/DeclarationField.test.ts +++ b/src/server/plugins/engine/components/DeclarationField.test.ts @@ -2,10 +2,8 @@ import { ComponentType, type DeclarationFieldComponent } from '@defra/forms-model' -import joi, { type BooleanSchema } from 'joi' import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' -import { DeclarationField } from '~/src/server/plugins/engine/components/DeclarationField.js' import { getAnswer, type Field @@ -151,7 +149,7 @@ describe('DeclarationField', () => { describe('State', () => { it('returns text from state', () => { - const state1 = getFormState('true') + const state1 = getFormState(true) const state2 = getFormState() // context - boolean // state - boolean @@ -159,51 +157,51 @@ describe('DeclarationField', () => { const answer1 = getAnswer(field, state1) const answer2 = getAnswer(field, state2) - expect(answer1).toBe('true') - expect(answer2).toBe('false') + expect(answer1).toBe('I understand and agree') + expect(answer2).toBe('') }) it('returns payload from state', () => { - const state1 = getFormState('Text field') + const state1 = getFormState(true) const state2 = getFormState(null) const payload1 = field.getFormDataFromState(state1) const payload2 = field.getFormDataFromState(state2) - expect(payload1).toEqual(getFormData('Text field')) + expect(payload1).toEqual(getFormData(true)) expect(payload2).toEqual(getFormData()) }) it('returns value from state', () => { - const state1 = getFormState('Text field') + const state1 = getFormState(true) const state2 = getFormState(null) const value1 = field.getFormValueFromState(state1) const value2 = field.getFormValueFromState(state2) - expect(value1).toBe('Text field') + expect(value1).toBe(true) expect(value2).toBeUndefined() }) it('returns context for conditions and form submission', () => { - const state1 = getFormState('Text field') + const state1 = getFormState(true) const state2 = getFormState(null) const value1 = field.getContextValueFromState(state1) const value2 = field.getContextValueFromState(state2) - expect(value1).toBe('Text field') + expect(value1).toBe(true) expect(value2).toBeNull() }) it('returns state from payload', () => { - const payload1 = getFormData('Text field') + const payload1 = getFormData(true) const payload2 = getFormData() const value1 = field.getStateFromValidForm(payload1) const value2 = field.getStateFromValidForm(payload2) - expect(value1).toEqual(getFormState('Text field')) + expect(value1).toEqual(getFormState(true)) expect(value2).toEqual(getFormState(null)) }) }) @@ -228,7 +226,7 @@ describe('DeclarationField', () => { items: [ { value: 'true', - text: 'I confirm that I understand and accept this declaration' + text: 'I understand and agree' } ] }) diff --git a/src/server/plugins/engine/components/DeclarationField.ts b/src/server/plugins/engine/components/DeclarationField.ts index 81377e4ac..e86eebe2e 100644 --- a/src/server/plugins/engine/components/DeclarationField.ts +++ b/src/server/plugins/engine/components/DeclarationField.ts @@ -1,5 +1,5 @@ import { type DeclarationFieldComponent } from '@defra/forms-model' -import joi, { type BooleanSchema, type StringSchema } from 'joi' +import joi, { type BooleanSchema } from 'joi' import { FormComponent, @@ -12,14 +12,19 @@ import { type FormState, type FormStateValue, type FormSubmissionError, - type FormSubmissionState + type FormSubmissionState, + type FormValue } from '~/src/server/plugins/engine/types.js' export class DeclarationField extends FormComponent { + private readonly DEFAULT_DECLARATION_LABEL = 'I understand and agree' + declare options: DeclarationFieldComponent['options'] declare schema: DeclarationFieldComponent['schema'] + declare declarationConfirmationLabel: string + declare formSchema: BooleanSchema declare stateSchema: BooleanSchema declare content: string @@ -51,7 +56,8 @@ export class DeclarationField extends FormComponent { this.stateSchema = formSchema.default(false) this.options = options this.content = content - this.declarationConfirmationLabel = options.declarationConfirmationLabel + this.declarationConfirmationLabel = + options.declarationConfirmationLabel ?? this.DEFAULT_DECLARATION_LABEL } getFormValueFromState(state: FormSubmissionState) { @@ -63,6 +69,10 @@ export class DeclarationField extends FormComponent { return this.isValue(value) ? value : undefined } + getDisplayStringFromFormValue(value: FormValue | FormPayload): string { + return value ? this.declarationConfirmationLabel : '' + } + getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { const defaultDeclarationConfirmationLabel = 'I confirm that I understand and accept this declaration' @@ -90,7 +100,7 @@ export class DeclarationField extends FormComponent { } } - isValue(value?: FormStateValue | FormState): value is string { + isValue(value?: FormStateValue | FormState): value is boolean { return DeclarationField.isBool(value) } @@ -113,7 +123,7 @@ export class DeclarationField extends FormComponent { } } - static isBool(value?: FormStateValue | FormState): value is string { - return isFormValue(value) && typeof value === 'string' + static isBool(value?: FormStateValue | FormState): value is boolean { + return isFormValue(value) && typeof value === 'boolean' } } diff --git a/src/server/plugins/engine/services/localFormsService.js b/src/server/plugins/engine/services/localFormsService.js index c5e4e2356..c824bad34 100644 --- a/src/server/plugins/engine/services/localFormsService.js +++ b/src/server/plugins/engine/services/localFormsService.js @@ -51,12 +51,5 @@ export const formsService = async () => { slug: 'components' }) - await loader.addForm('src/server/forms/error.json', { - ...metadata, - id: '0920b6b1-c5b4-4b7d-8783-7dda7fd559f8', - title: 'Error', - slug: 'error' - }) - return loader.toFormsService() } diff --git a/src/server/plugins/engine/views/components/declarationfield.html b/src/server/plugins/engine/views/components/declarationfield.html index 97414bc4c..02ddf9ec8 100644 --- a/src/server/plugins/engine/views/components/declarationfield.html +++ b/src/server/plugins/engine/views/components/declarationfield.html @@ -10,5 +10,4 @@ {% endset %} {% set checkboxes = component.model | merge({ formGroup: { beforeInputs: { html: content } } }) %} {{ govukCheckboxes(checkboxes) }} - {{ checkboxes | dump }} {% endmacro %} From 7fe8ef1d939a83923c9520fbb37f2dd431fab302 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Wed, 22 Oct 2025 15:50:18 +0100 Subject: [PATCH 03/10] working declaration --- .../engine/components/ComponentBase.ts | 1 + .../engine/components/DeclarationField.ts | 37 +++++++++++-------- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/src/server/plugins/engine/components/ComponentBase.ts b/src/server/plugins/engine/components/ComponentBase.ts index 307583354..33bc0ac19 100644 --- a/src/server/plugins/engine/components/ComponentBase.ts +++ b/src/server/plugins/engine/components/ComponentBase.ts @@ -90,6 +90,7 @@ export type ComponentSchema = | ArraySchema | ArraySchema | BooleanSchema + | BooleanSchema | DateSchema | NumberSchema | NumberSchema diff --git a/src/server/plugins/engine/components/DeclarationField.ts b/src/server/plugins/engine/components/DeclarationField.ts index e86eebe2e..e4c0f7a82 100644 --- a/src/server/plugins/engine/components/DeclarationField.ts +++ b/src/server/plugins/engine/components/DeclarationField.ts @@ -1,5 +1,5 @@ import { type DeclarationFieldComponent } from '@defra/forms-model' -import joi, { type BooleanSchema } from 'joi' +import joi, { type BooleanSchema, type StringSchema } from 'joi' import { FormComponent, @@ -21,12 +21,10 @@ export class DeclarationField extends FormComponent { declare options: DeclarationFieldComponent['options'] - declare schema: DeclarationFieldComponent['schema'] - declare declarationConfirmationLabel: string - declare formSchema: BooleanSchema - declare stateSchema: BooleanSchema + declare formSchema: StringSchema + declare stateSchema: BooleanSchema declare content: string constructor( @@ -37,12 +35,7 @@ export class DeclarationField extends FormComponent { const { options, content } = def - let formSchema = joi - .boolean() - .valid(true) - .cast('string') - .label(this.label) - .required() as BooleanSchema + let formSchema = joi.string().valid('true').label(this.label).required() if (options.required === false) { formSchema = formSchema.optional() @@ -52,8 +45,9 @@ export class DeclarationField extends FormComponent { }) } - this.formSchema = formSchema.default(false) - this.stateSchema = formSchema.default(false) + this.formSchema = formSchema + this.stateSchema = joi.boolean().cast('string').label(this.label).required() + this.options = options this.content = content this.declarationConfirmationLabel = @@ -62,7 +56,19 @@ export class DeclarationField extends FormComponent { getFormValueFromState(state: FormSubmissionState) { const { name } = this - return this.getFormValue(state[name]) + return state[name] === true ? 'true' : undefined + } + + getFormDataFromState(state: FormSubmissionState): FormPayload { + const { name } = this + const test = { [name]: state[name] === true ? 'true' : undefined } + return test + } + + getStateFromValidForm(payload: FormPayload): FormState { + const { name } = this + const value = payload[name] === 'true' + return { [name]: value } } getFormValue(value?: FormStateValue | FormState) { @@ -94,7 +100,8 @@ export class DeclarationField extends FormComponent { items: [ { text: declarationConfirmationLabel, - value: 'true' + value: 'true', + checked: payload[this.name] === 'true' } ] } From 02aaedd5731590122308d50ad5ee888601e090db Mon Sep 17 00:00:00 2001 From: whitewater design Date: Thu, 23 Oct 2025 00:17:59 +0100 Subject: [PATCH 04/10] feat: DF-341 - DeclarationField handle checkbox array form value --- .../forms/register-as-a-unicorn-breeder.yaml | 13 +++++++- .../components/DeclarationField.test.ts | 30 ++++++++++++++++--- .../engine/components/DeclarationField.ts | 30 +++++++++++++++---- .../views/components/declarationfield.html | 1 + 4 files changed, 64 insertions(+), 10 deletions(-) diff --git a/src/server/forms/register-as-a-unicorn-breeder.yaml b/src/server/forms/register-as-a-unicorn-breeder.yaml index 3c20764c8..59c7a1627 100644 --- a/src/server/forms/register-as-a-unicorn-breeder.yaml +++ b/src/server/forms/register-as-a-unicorn-breeder.yaml @@ -175,7 +175,7 @@ pages: controller: FileUploadPageController section: Regnsa next: - - path: '/how-many-unicorns-do-you-expect-to-breed-each-year' + - path: '/declaration' components: - name: dLzALM title: Documents @@ -186,6 +186,17 @@ pages: schema: min: 1 max: 3 + - title: Declaration + path: '/declaration' + section: section + components: + - name: diLmal + title: Declaration + type: DeclarationField + content: 'Fill in this field' + options: {} + next: + - path: '/summary' conditions: - displayName: Address is different name: IrVmYz diff --git a/src/server/plugins/engine/components/DeclarationField.test.ts b/src/server/plugins/engine/components/DeclarationField.test.ts index 69cde3330..45fec0ff4 100644 --- a/src/server/plugins/engine/components/DeclarationField.test.ts +++ b/src/server/plugins/engine/components/DeclarationField.test.ts @@ -296,6 +296,28 @@ describe('DeclarationField', () => { describe('Validation', () => { describe.each([ + { + description: 'Default', + component: { + title: 'Terms and conditions', + shortDescription: 'The terms and conditions', + content: 'Lorem ipsum dolar sit amet', + name: 'myComponent', + type: ComponentType.DeclarationField, + options: {} + } satisfies DeclarationFieldComponent, + assertions: [ + { + input: getFormData(['unchecked', 'true']), + output: { + value: { + myComponent: ['true'] + }, + errors: undefined + } + } + ] + }, { description: 'Use short description if it exists', component: { @@ -308,9 +330,9 @@ describe('DeclarationField', () => { } satisfies DeclarationFieldComponent, assertions: [ { - input: getFormData(), + input: getFormData('unchecked'), output: { - value: getFormData('false'), + value: { myComponent: [] }, errors: [ expect.objectContaining({ text: 'You must confirm you understand and agree with the terms and conditions to continue' @@ -333,8 +355,8 @@ describe('DeclarationField', () => { } satisfies DeclarationFieldComponent, assertions: [ { - input: getFormData(), - output: { value: getFormData('false') } + input: getFormData('unchecked'), + output: { value: {} } } ] } diff --git a/src/server/plugins/engine/components/DeclarationField.ts b/src/server/plugins/engine/components/DeclarationField.ts index e4c0f7a82..0448ed728 100644 --- a/src/server/plugins/engine/components/DeclarationField.ts +++ b/src/server/plugins/engine/components/DeclarationField.ts @@ -35,13 +35,33 @@ export class DeclarationField extends FormComponent { const { options, content } = def - let formSchema = joi.string().valid('true').label(this.label).required() + let formSchema = joi + .array() + .label(this.label) + .items( + joi.string().valid('true').required(), + joi.string().valid('unchecked').strip() + ) + .single() as StringSchema if (options.required === false) { - formSchema = formSchema.optional() + formSchema = joi + .array() + .items( + joi.string().valid('true'), + joi.string().valid('unchecked').strip() + ) + .single() + .label(this.label) + .custom(([first, _]: string[], _helpers) => { + return first + }) as StringSchema } else { formSchema = formSchema.messages({ - 'any.required': messageTemplate.declarationRequired as string + 'any.required': messageTemplate.declarationRequired as string, + 'any.unknown': messageTemplate.declarationRequired as string, + 'array.includesRequiredUnknowns': + messageTemplate.declarationRequired as string }) } @@ -97,11 +117,11 @@ export class DeclarationField extends FormComponent { } }, content, + values: payload[this.name] === 'true' ? ['true'] : [], items: [ { text: declarationConfirmationLabel, - value: 'true', - checked: payload[this.name] === 'true' + value: 'true' } ] } diff --git a/src/server/plugins/engine/views/components/declarationfield.html b/src/server/plugins/engine/views/components/declarationfield.html index 02ddf9ec8..7f18e2383 100644 --- a/src/server/plugins/engine/views/components/declarationfield.html +++ b/src/server/plugins/engine/views/components/declarationfield.html @@ -10,4 +10,5 @@ {% endset %} {% set checkboxes = component.model | merge({ formGroup: { beforeInputs: { html: content } } }) %} {{ govukCheckboxes(checkboxes) }} + {% endmacro %} From 2c6dad87019af3f093680f660911197c8e21a9a4 Mon Sep 17 00:00:00 2001 From: whitewater design Date: Thu, 23 Oct 2025 00:27:32 +0100 Subject: [PATCH 05/10] refactor: DF-341 - Update schema for validation --- .../components/DeclarationField.test.ts | 2 +- .../engine/components/DeclarationField.ts | 34 ++++++------------- 2 files changed, 12 insertions(+), 24 deletions(-) diff --git a/src/server/plugins/engine/components/DeclarationField.test.ts b/src/server/plugins/engine/components/DeclarationField.test.ts index 45fec0ff4..5382c106e 100644 --- a/src/server/plugins/engine/components/DeclarationField.test.ts +++ b/src/server/plugins/engine/components/DeclarationField.test.ts @@ -356,7 +356,7 @@ describe('DeclarationField', () => { assertions: [ { input: getFormData('unchecked'), - output: { value: {} } + output: { value: { myComponent: [] } } } ] } diff --git a/src/server/plugins/engine/components/DeclarationField.ts b/src/server/plugins/engine/components/DeclarationField.ts index 0448ed728..b1400a14e 100644 --- a/src/server/plugins/engine/components/DeclarationField.ts +++ b/src/server/plugins/engine/components/DeclarationField.ts @@ -35,35 +35,23 @@ export class DeclarationField extends FormComponent { const { options, content } = def - let formSchema = joi + let checkboxSchema = joi.string().valid('true') + + if (options.required !== false) { + checkboxSchema = checkboxSchema.required() + } + + const formSchema = joi .array() + .items(checkboxSchema, joi.string().valid('unchecked').strip()) .label(this.label) - .items( - joi.string().valid('true').required(), - joi.string().valid('unchecked').strip() - ) - .single() as StringSchema - - if (options.required === false) { - formSchema = joi - .array() - .items( - joi.string().valid('true'), - joi.string().valid('unchecked').strip() - ) - .single() - .label(this.label) - .custom(([first, _]: string[], _helpers) => { - return first - }) as StringSchema - } else { - formSchema = formSchema.messages({ + .single() + .messages({ 'any.required': messageTemplate.declarationRequired as string, 'any.unknown': messageTemplate.declarationRequired as string, 'array.includesRequiredUnknowns': messageTemplate.declarationRequired as string - }) - } + }) as StringSchema this.formSchema = formSchema this.stateSchema = joi.boolean().cast('string').label(this.label).required() From da5e0fd552f13be6721e0e7827c0576941f8ba3f Mon Sep 17 00:00:00 2001 From: whitewater design Date: Sat, 25 Oct 2025 01:07:13 +0100 Subject: [PATCH 06/10] feat: DF-341 - Map single declaration value to multiple --- .../engine/components/ComponentCollection.ts | 1 + .../components/DeclarationField.test.ts | 82 +++++++++++++------ .../engine/components/DeclarationField.ts | 45 +++++++--- src/server/plugins/nunjucks/filters/merge.js | 4 +- 4 files changed, 93 insertions(+), 39 deletions(-) diff --git a/src/server/plugins/engine/components/ComponentCollection.ts b/src/server/plugins/engine/components/ComponentCollection.ts index faf8271ca..df0ae3f48 100644 --- a/src/server/plugins/engine/components/ComponentCollection.ts +++ b/src/server/plugins/engine/components/ComponentCollection.ts @@ -256,6 +256,7 @@ export class ComponentCollection { */ validate(value: FormPayload = {}): FormValidationResult { const result = this.formSchema.validate(value, opts) + const details = result.error?.details return { diff --git a/src/server/plugins/engine/components/DeclarationField.test.ts b/src/server/plugins/engine/components/DeclarationField.test.ts index 5382c106e..6ba1e9ac3 100644 --- a/src/server/plugins/engine/components/DeclarationField.test.ts +++ b/src/server/plugins/engine/components/DeclarationField.test.ts @@ -74,23 +74,44 @@ describe('DeclarationField', () => { expect(keys).toHaveProperty( 'myComponent', expect.objectContaining({ - flags: expect.objectContaining({ - presence: 'required' - }) + items: expect.arrayContaining([ + expect.objectContaining({ + allow: ['true'], + flags: expect.objectContaining({ + presence: 'required' + }) + }) + ]) }) ) }) - it('is cast to a string', () => { + it('may have unchecked value in addition to true', () => { const { formSchema } = collection const { keys } = formSchema.describe() expect(keys).toHaveProperty( 'myComponent', expect.objectContaining({ - flags: expect.objectContaining({ - cast: 'string' - }) + items: expect.arrayContaining([ + expect.objectContaining({ + allow: ['unchecked'], + flags: expect.objectContaining({ + result: 'strip' + }) + }) + ]) + }) + ) + + expect(keys).toHaveProperty( + 'myComponent', + expect.objectContaining({ + items: expect.arrayContaining([ + expect.objectContaining({ + allow: ['unchecked'] + }) + ]) }) ) }) @@ -107,24 +128,29 @@ describe('DeclarationField', () => { expect(keys).toHaveProperty( 'myComponent', expect.objectContaining({ - flags: expect.objectContaining({ - presence: 'optional' - }) + items: expect.arrayContaining([ + expect.objectContaining({ + allow: ['true'], + flags: expect.not.objectContaining({ + presence: 'required' + }) + }) + ]) }) ) - const result = collectionOptional.validate(getFormData()) + const result = collectionOptional.validate(getFormData(['unchecked'])) expect(result.errors).toBeUndefined() }) it('accepts valid values', () => { - const result1 = collection.validate(getFormData('true')) + const result1 = collection.validate(getFormData(['unchecked', 'true'])) expect(result1.errors).toBeUndefined() }) it('adds errors for empty value', () => { - const result = collection.validate(getFormData()) + const result = collection.validate(getFormData(['unchecked'])) expect(result.errors).toEqual([ expect.objectContaining({ @@ -139,6 +165,7 @@ describe('DeclarationField', () => { // @ts-expect-error - Allow invalid param for test getFormData({ unknown: 'invalid' }) ) + // @ts-expect-error - Allow invalid param for test const result3 = collection.validate('false') expect(result1.errors).toBeTruthy() @@ -168,7 +195,7 @@ describe('DeclarationField', () => { const payload1 = field.getFormDataFromState(state1) const payload2 = field.getFormDataFromState(state2) - expect(payload1).toEqual(getFormData(true)) + expect(payload1).toEqual(getFormData('true')) expect(payload2).toEqual(getFormData()) }) @@ -179,7 +206,7 @@ describe('DeclarationField', () => { const value1 = field.getFormValueFromState(state1) const value2 = field.getFormValueFromState(state2) - expect(value1).toBe(true) + expect(value1).toBe('true') expect(value2).toBeUndefined() }) @@ -191,31 +218,34 @@ describe('DeclarationField', () => { const value2 = field.getContextValueFromState(state2) expect(value1).toBe(true) - expect(value2).toBeNull() + expect(value2).toBe(false) }) it('returns state from payload', () => { - const payload1 = getFormData(true) - const payload2 = getFormData() + const payload1 = getFormData(['true']) + const payload2 = getFormData([]) + const payload3 = getFormData(['unchecked']) const value1 = field.getStateFromValidForm(payload1) const value2 = field.getStateFromValidForm(payload2) + const value3 = field.getStateFromValidForm(payload3) expect(value1).toEqual(getFormState(true)) - expect(value2).toEqual(getFormState(null)) + expect(value2).toEqual(getFormState(false)) + expect(value3).toEqual(getFormState(false)) }) }) describe('View model', () => { it('sets Nunjucks component defaults', () => { - const viewModel = field.getViewModel(getFormData()) + const viewModel = field.getViewModel(getFormData([])) expect(viewModel).toEqual( expect.objectContaining({ label: { text: def.title }, name: 'myComponent', attributes: {}, - value: undefined, + values: [], content: 'Lorem ipsum dolar sit amet', id: 'myComponent', fieldset: { @@ -241,11 +271,11 @@ describe('DeclarationField', () => { collection = new ComponentCollection([def], { model }) field = collection.fields[0] - const viewModel = field.getViewModel(getFormData('true')) + const viewModel = field.getViewModel(getFormData(['true'])) expect(viewModel).toEqual( expect.objectContaining({ - value: 'true', + values: ['true'], hint: { text: 'Please read and confirm the following' } @@ -270,7 +300,7 @@ describe('DeclarationField', () => { collection = new ComponentCollection([def], { model }) field = collection.fields[0] - const viewModel = field.getViewModel(getFormData('true')) + const viewModel = field.getViewModel(getFormData(['unchecked', 'true'])) expect(viewModel).toEqual( expect.objectContaining({ @@ -330,7 +360,7 @@ describe('DeclarationField', () => { } satisfies DeclarationFieldComponent, assertions: [ { - input: getFormData('unchecked'), + input: getFormData(['unchecked']), output: { value: { myComponent: [] }, errors: [ @@ -355,7 +385,7 @@ describe('DeclarationField', () => { } satisfies DeclarationFieldComponent, assertions: [ { - input: getFormData('unchecked'), + input: getFormData(['unchecked']), output: { value: { myComponent: [] } } } ] diff --git a/src/server/plugins/engine/components/DeclarationField.ts b/src/server/plugins/engine/components/DeclarationField.ts index b1400a14e..f328abc19 100644 --- a/src/server/plugins/engine/components/DeclarationField.ts +++ b/src/server/plugins/engine/components/DeclarationField.ts @@ -1,5 +1,9 @@ -import { type DeclarationFieldComponent } from '@defra/forms-model' -import joi, { type BooleanSchema, type StringSchema } from 'joi' +import { type DeclarationFieldComponent, type Item } from '@defra/forms-model' +import joi, { + type ArraySchema, + type BooleanSchema, + type StringSchema +} from 'joi' import { FormComponent, @@ -23,7 +27,7 @@ export class DeclarationField extends FormComponent { declare declarationConfirmationLabel: string - declare formSchema: StringSchema + declare formSchema: ArraySchema declare stateSchema: BooleanSchema declare content: string @@ -51,7 +55,7 @@ export class DeclarationField extends FormComponent { 'any.unknown': messageTemplate.declarationRequired as string, 'array.includesRequiredUnknowns': messageTemplate.declarationRequired as string - }) as StringSchema + }) as ArraySchema this.formSchema = formSchema this.stateSchema = joi.boolean().cast('string').label(this.label).required() @@ -69,16 +73,26 @@ export class DeclarationField extends FormComponent { getFormDataFromState(state: FormSubmissionState): FormPayload { const { name } = this - const test = { [name]: state[name] === true ? 'true' : undefined } - return test + return { [name]: state[name] === true ? 'true' : undefined } } getStateFromValidForm(payload: FormPayload): FormState { const { name } = this - const value = payload[name] === 'true' + const payloadValue = payload[name] + const value = + this.isValue(payloadValue) && + payloadValue.length > 0 && + payloadValue.every((v) => { + return v === 'true' + }) + return { [name]: value } } + getContextValueFromFormValue(value: FormValue | FormPayload): boolean { + return value === 'true' + } + getFormValue(value?: FormStateValue | FormState) { return this.isValue(value) ? value : undefined } @@ -98,14 +112,14 @@ export class DeclarationField extends FormComponent { } = this return { ...super.getViewModel(payload, errors), - hint: hint && { text: hint }, + hint: hint ? { text: hint } : undefined, fieldset: { legend: { text: title } }, content, - values: payload[this.name] === 'true' ? ['true'] : [], + values: payload[this.name], items: [ { text: declarationConfirmationLabel, @@ -115,8 +129,17 @@ export class DeclarationField extends FormComponent { } } - isValue(value?: FormStateValue | FormState): value is boolean { - return DeclarationField.isBool(value) + isValue(value?: FormStateValue | FormState): value is Item['value'][] { + if (!Array.isArray(value)) { + return false + } + + // Skip checks when empty + if (!value.length) { + return true + } + + return value.every(isFormValue) } /** diff --git a/src/server/plugins/nunjucks/filters/merge.js b/src/server/plugins/nunjucks/filters/merge.js index ad3cd3f39..f9f2e121b 100644 --- a/src/server/plugins/nunjucks/filters/merge.js +++ b/src/server/plugins/nunjucks/filters/merge.js @@ -1,7 +1,7 @@ /** * Nunjucks filter to get the page for a given path - * @param {Record} targetDictionary - Object to extend - * @param {Record} sourceDictionary - Object to merge into target + * @param {Record | string} targetDictionary - Object to extend + * @param {Record | string} sourceDictionary - Object to merge into target */ export function merge(targetDictionary, sourceDictionary) { if ( From b3140ef739e95007a0a2096327153487e80263e3 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Fri, 31 Oct 2025 11:51:49 +0000 Subject: [PATCH 07/10] Fixed test --- src/server/index.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/server/index.test.ts b/src/server/index.test.ts index f3f741b52..ebd747845 100644 --- a/src/server/index.test.ts +++ b/src/server/index.test.ts @@ -585,6 +585,7 @@ describe('prepareEnvironment', () => { 'href', 'field', 'page', + 'merge', 'markdown' ] From c45c87074433f69d190a74fe92373a8bb0ddfc70 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Fri, 31 Oct 2025 12:08:19 +0000 Subject: [PATCH 08/10] Corrected typo and added coverage --- .../components/DeclarationField.test.ts | 21 +++++++++++++++++-- .../pageControllers/validationOptions.ts | 2 +- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/server/plugins/engine/components/DeclarationField.test.ts b/src/server/plugins/engine/components/DeclarationField.test.ts index 6ba1e9ac3..bfb8a41a4 100644 --- a/src/server/plugins/engine/components/DeclarationField.test.ts +++ b/src/server/plugins/engine/components/DeclarationField.test.ts @@ -4,6 +4,7 @@ import { } from '@defra/forms-model' import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' +import { DeclarationField } from '~/src/server/plugins/engine/components/DeclarationField.js' import { getAnswer, type Field @@ -154,7 +155,7 @@ describe('DeclarationField', () => { expect(result.errors).toEqual([ expect.objectContaining({ - text: 'You must confirm you understand and agree with terms and conditions to continue' + text: 'You must confirm you understand and agree with the terms and conditions to continue' }) ]) }) @@ -322,6 +323,15 @@ describe('DeclarationField', () => { expect(errors.advancedSettingsErrors).toBeEmpty() }) }) + + describe('getFormValue', () => { + test('should return correct value', () => { + expect(field.getFormValue(undefined)).toBeUndefined() + expect(field.getFormValue([true])).toEqual([true]) + expect(field.getFormValue([])).toEqual([]) + expect(field.getFormValue({})).toBeUndefined() + }) + }) }) describe('Validation', () => { @@ -352,7 +362,7 @@ describe('DeclarationField', () => { description: 'Use short description if it exists', component: { title: 'Terms and conditions', - shortDescription: 'The terms and conditions', + shortDescription: 'Terms and conditions', content: 'Lorem ipsum dolar sit amet', name: 'myComponent', type: ComponentType.DeclarationField, @@ -406,4 +416,11 @@ describe('DeclarationField', () => { ) }) }) + + describe('isBool', () => { + test('should return correct boolean', () => { + expect(DeclarationField.isBool('string')).toBe(false) + expect(DeclarationField.isBool(true)).toBe(true) + }) + }) }) diff --git a/src/server/plugins/engine/pageControllers/validationOptions.ts b/src/server/plugins/engine/pageControllers/validationOptions.ts index 1e2142f31..2c20a6b3e 100644 --- a/src/server/plugins/engine/pageControllers/validationOptions.ts +++ b/src/server/plugins/engine/pageControllers/validationOptions.ts @@ -21,7 +21,7 @@ const opts = { */ export const messageTemplate: Record = { declarationRequired: joi.expression( - 'You must confirm you understand and agree with {{lowerFirst(#label)}} to continue', + 'You must confirm you understand and agree with the {{lowerFirst(#label)}} to continue', opts ) as JoiExpression, required: joi.expression( From acb2c68a448ba739a8c7f64ccee9a17896288137 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Fri, 31 Oct 2025 12:13:03 +0000 Subject: [PATCH 09/10] Sonar fix --- src/server/plugins/nunjucks/filters/merge.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/server/plugins/nunjucks/filters/merge.js b/src/server/plugins/nunjucks/filters/merge.js index f9f2e121b..450834b3a 100644 --- a/src/server/plugins/nunjucks/filters/merge.js +++ b/src/server/plugins/nunjucks/filters/merge.js @@ -2,6 +2,7 @@ * Nunjucks filter to get the page for a given path * @param {Record | string} targetDictionary - Object to extend * @param {Record | string} sourceDictionary - Object to merge into target + * @returns {Record | string} */ export function merge(targetDictionary, sourceDictionary) { if ( From 064258c0c52c507e94444e5be9243a8592fe9970 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Fri, 31 Oct 2025 12:47:54 +0000 Subject: [PATCH 10/10] Sonar fix --- src/server/plugins/nunjucks/filters/merge.js | 9 +++------ src/server/plugins/nunjucks/filters/merge.test.js | 3 --- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/src/server/plugins/nunjucks/filters/merge.js b/src/server/plugins/nunjucks/filters/merge.js index 450834b3a..fccdc62dc 100644 --- a/src/server/plugins/nunjucks/filters/merge.js +++ b/src/server/plugins/nunjucks/filters/merge.js @@ -1,14 +1,11 @@ /** * Nunjucks filter to get the page for a given path - * @param {Record | string} targetDictionary - Object to extend + * @param {Record} targetDictionary - Object to extend * @param {Record | string} sourceDictionary - Object to merge into target - * @returns {Record | string} + * @returns {Record} */ export function merge(targetDictionary, sourceDictionary) { - if ( - typeof targetDictionary !== 'object' || - typeof sourceDictionary !== 'object' - ) { + if (typeof sourceDictionary !== 'object') { return targetDictionary } diff --git a/src/server/plugins/nunjucks/filters/merge.test.js b/src/server/plugins/nunjucks/filters/merge.test.js index 62f72ed25..73f3f5ecf 100644 --- a/src/server/plugins/nunjucks/filters/merge.test.js +++ b/src/server/plugins/nunjucks/filters/merge.test.js @@ -2,9 +2,6 @@ import { merge } from '~/src/server/plugins/nunjucks/filters/merge.js' describe('merge', () => { const propertyToMerge = { lorem: 'ipsum' } - it('should return the target if target is not an object', () => { - expect(merge('string', propertyToMerge)).toBe('string') - }) it('should return the target if source is not an object', () => { expect(merge(propertyToMerge, 'dolar')).toBe(propertyToMerge) })