diff --git a/src/server/plugins/engine/components/EastingNorthingField.test.ts b/src/server/plugins/engine/components/EastingNorthingField.test.ts index c03f3d8c8..3f43699a4 100644 --- a/src/server/plugins/engine/components/EastingNorthingField.test.ts +++ b/src/server/plugins/engine/components/EastingNorthingField.test.ts @@ -416,7 +416,36 @@ describe('EastingNorthingField', () => { const staticResult = EastingNorthingField.getAllPossibleErrors() const instanceResult = field.getAllPossibleErrors() - expect(instanceResult).toEqual(staticResult) + // Compare structure and content + expect(instanceResult.baseErrors).toHaveLength( + staticResult.baseErrors.length + ) + expect(instanceResult.advancedSettingsErrors).toHaveLength( + staticResult.advancedSettingsErrors.length + ) + + // Compare error types + expect(instanceResult.baseErrors.map((e) => e.type)).toEqual( + staticResult.baseErrors.map((e) => e.type) + ) + expect( + instanceResult.advancedSettingsErrors.map((e) => e.type) + ).toEqual(staticResult.advancedSettingsErrors.map((e) => e.type)) + + // Compare rendered templates + expect( + instanceResult.baseErrors.map((e) => + typeof e.template === 'object' && 'rendered' in e.template + ? e.template.rendered + : e.template + ) + ).toEqual( + staticResult.baseErrors.map((e) => + typeof e.template === 'object' && 'rendered' in e.template + ? e.template.rendered + : e.template + ) + ) }) }) }) diff --git a/src/server/plugins/engine/components/EastingNorthingField.ts b/src/server/plugins/engine/components/EastingNorthingField.ts index 3371d2931..45f184c49 100644 --- a/src/server/plugins/engine/components/EastingNorthingField.ts +++ b/src/server/plugins/engine/components/EastingNorthingField.ts @@ -15,6 +15,7 @@ import { getLocationFieldViewModel } from '~/src/server/plugins/engine/components/LocationFieldHelpers.js' import { NumberField } from '~/src/server/plugins/engine/components/NumberField.js' +import { createLowerFirstExpression } from '~/src/server/plugins/engine/components/helpers/index.js' import { type EastingNorthingState } from '~/src/server/plugins/engine/components/types.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { @@ -198,29 +199,41 @@ export class EastingNorthingField extends FormComponent { { type: 'required', template: messageTemplate.required }, { type: 'eastingFormat', - template: 'Easting for {{#title}} must be between 1 and 6 digits' + template: createLowerFirstExpression( + 'Easting for {{lowerFirst(#title)}} must be between 1 and 6 digits' + ) }, { type: 'northingFormat', - template: 'Northing for {{#title}} must be between 1 and 7 digits' + template: createLowerFirstExpression( + 'Northing for {{lowerFirst(#title)}} must be between 1 and 7 digits' + ) } ], advancedSettingsErrors: [ { type: 'eastingMin', - template: `Easting for {{#title}} must be between ${DEFAULT_EASTING_MIN} and ${DEFAULT_EASTING_MAX}` + template: createLowerFirstExpression( + `Easting for {{lowerFirst(#title)}} must be between ${DEFAULT_EASTING_MIN} and ${DEFAULT_EASTING_MAX}` + ) }, { type: 'eastingMax', - template: `Easting for {{#title}} must be between ${DEFAULT_EASTING_MIN} and ${DEFAULT_EASTING_MAX}` + template: createLowerFirstExpression( + `Easting for {{lowerFirst(#title)}} must be between ${DEFAULT_EASTING_MIN} and ${DEFAULT_EASTING_MAX}` + ) }, { type: 'northingMin', - template: `Northing for {{#title}} must be between ${DEFAULT_NORTHING_MIN} and ${DEFAULT_NORTHING_MAX}` + template: createLowerFirstExpression( + `Northing for {{lowerFirst(#title)}} must be between ${DEFAULT_NORTHING_MIN} and ${DEFAULT_NORTHING_MAX}` + ) }, { type: 'northingMax', - template: `Northing for {{#title}} must be between ${DEFAULT_NORTHING_MIN} and ${DEFAULT_NORTHING_MAX}` + template: createLowerFirstExpression( + `Northing for {{lowerFirst(#title)}} must be between ${DEFAULT_NORTHING_MIN} and ${DEFAULT_NORTHING_MAX}` + ) } ] } diff --git a/src/server/plugins/engine/components/LatLongField.test.ts b/src/server/plugins/engine/components/LatLongField.test.ts index 25ad591ad..70bb2e875 100644 --- a/src/server/plugins/engine/components/LatLongField.test.ts +++ b/src/server/plugins/engine/components/LatLongField.test.ts @@ -404,7 +404,36 @@ describe('LatLongField', () => { const staticResult = LatLongField.getAllPossibleErrors() const instanceResult = field.getAllPossibleErrors() - expect(instanceResult).toEqual(staticResult) + // Compare structure and content + expect(instanceResult.baseErrors).toHaveLength( + staticResult.baseErrors.length + ) + expect(instanceResult.advancedSettingsErrors).toHaveLength( + staticResult.advancedSettingsErrors.length + ) + + // Compare error types + expect(instanceResult.baseErrors.map((e) => e.type)).toEqual( + staticResult.baseErrors.map((e) => e.type) + ) + expect( + instanceResult.advancedSettingsErrors.map((e) => e.type) + ).toEqual(staticResult.advancedSettingsErrors.map((e) => e.type)) + + // Compare rendered templates + expect( + instanceResult.baseErrors.map((e) => + typeof e.template === 'object' && 'rendered' in e.template + ? e.template.rendered + : e.template + ) + ).toEqual( + staticResult.baseErrors.map((e) => + typeof e.template === 'object' && 'rendered' in e.template + ? e.template.rendered + : e.template + ) + ) }) }) }) diff --git a/src/server/plugins/engine/components/LatLongField.ts b/src/server/plugins/engine/components/LatLongField.ts index 25121a5a5..0fe4c6f14 100644 --- a/src/server/plugins/engine/components/LatLongField.ts +++ b/src/server/plugins/engine/components/LatLongField.ts @@ -12,6 +12,7 @@ import { getLocationFieldViewModel } from '~/src/server/plugins/engine/components/LocationFieldHelpers.js' import { NumberField } from '~/src/server/plugins/engine/components/NumberField.js' +import { createLowerFirstExpression } from '~/src/server/plugins/engine/components/helpers/index.js' import { type LatLongState } from '~/src/server/plugins/engine/components/types.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { @@ -194,29 +195,41 @@ export class LatLongField extends FormComponent { { type: 'required', template: messageTemplate.required }, { type: 'latitudeFormat', - template: 'Enter a valid latitude for {{#title}} like 51.519450' + template: createLowerFirstExpression( + 'Enter a valid latitude for {{lowerFirst(#title)}} like 51.519450' + ) }, { type: 'longitudeFormat', - template: 'Enter a valid longitude for {{#title}} like -0.127758' + template: createLowerFirstExpression( + 'Enter a valid longitude for {{lowerFirst(#title)}} like -0.127758' + ) } ], advancedSettingsErrors: [ { type: 'latitudeMin', - template: 'Latitude for {{#title}} must be between 49 and 60' + template: createLowerFirstExpression( + 'Latitude for {{lowerFirst(#title)}} must be between 49 and 60' + ) }, { type: 'latitudeMax', - template: 'Latitude for {{#title}} must be between 49 and 60' + template: createLowerFirstExpression( + 'Latitude for {{lowerFirst(#title)}} must be between 49 and 60' + ) }, { type: 'longitudeMin', - template: 'Longitude for {{#title}} must be between -9 and 2' + template: createLowerFirstExpression( + 'Longitude for {{lowerFirst(#title)}} must be between -9 and 2' + ) }, { type: 'longitudeMax', - template: 'Longitude for {{#title}} must be between -9 and 2' + template: createLowerFirstExpression( + 'Longitude for {{lowerFirst(#title)}} must be between -9 and 2' + ) } ] } diff --git a/src/server/plugins/engine/components/LocationFieldBase.ts b/src/server/plugins/engine/components/LocationFieldBase.ts index 928599a48..ed1c943a1 100644 --- a/src/server/plugins/engine/components/LocationFieldBase.ts +++ b/src/server/plugins/engine/components/LocationFieldBase.ts @@ -1,5 +1,9 @@ import { type FormComponentsDef } from '@defra/forms-model' -import joi, { type LanguageMessages, type StringSchema } from 'joi' +import joi, { + type JoiExpression, + type LanguageMessages, + type StringSchema +} from 'joi' import { FormComponent, @@ -15,6 +19,7 @@ import { type FormSubmissionError, type FormSubmissionState } from '~/src/server/plugins/engine/types.js' +import { convertToLanguageMessages } from '~/src/server/utils/type-utils.js' interface LocationFieldOptions { instructionText?: string @@ -26,8 +31,8 @@ interface LocationFieldOptions { interface ValidationConfig { pattern: RegExp - patternErrorMessage: string - requiredMessage?: string + patternErrorMessage: JoiExpression + requiredMessage?: JoiExpression } /** @@ -42,7 +47,7 @@ export abstract class LocationFieldBase extends FormComponent { protected abstract getValidationConfig(): ValidationConfig protected abstract getErrorTemplates(): { type: string - template: string + template: JoiExpression }[] constructor( @@ -61,11 +66,11 @@ export abstract class LocationFieldBase extends FormComponent { const requiredMessage = config.requiredMessage ?? (messageTemplate.required as string) - const messages: LanguageMessages = { + const messages = convertToLanguageMessages({ 'any.required': requiredMessage, 'string.empty': requiredMessage, 'string.pattern.base': config.patternErrorMessage - } + }) let formSchema = joi .string() diff --git a/src/server/plugins/engine/components/NationalGridFieldNumberField.test.ts b/src/server/plugins/engine/components/NationalGridFieldNumberField.test.ts index b87d4e2e9..00a67d634 100644 --- a/src/server/plugins/engine/components/NationalGridFieldNumberField.test.ts +++ b/src/server/plugins/engine/components/NationalGridFieldNumberField.test.ts @@ -129,7 +129,7 @@ describe('NationalGridFieldNumberField', () => { expect(result.errors).toEqual([ expect.objectContaining({ - text: 'Enter Example National Grid field number' + text: 'Enter example National Grid field number' }) ]) }) @@ -293,7 +293,7 @@ describe('NationalGridFieldNumberField', () => { value: getFormData('NG1234567'), errors: expect.arrayContaining([ expect.objectContaining({ - text: 'Enter a valid National Grid field number for Grid field like NG 1234 5678' + text: 'Enter a valid National Grid field number for grid field like NG 1234 5678' }) ]) } @@ -304,7 +304,7 @@ describe('NationalGridFieldNumberField', () => { value: getFormData('N123456789'), errors: expect.arrayContaining([ expect.objectContaining({ - text: 'Enter a valid National Grid field number for Grid field like NG 1234 5678' + text: 'Enter a valid National Grid field number for grid field like NG 1234 5678' }) ]) } @@ -315,7 +315,7 @@ describe('NationalGridFieldNumberField', () => { value: getFormData('NGABCDEFGH'), errors: expect.arrayContaining([ expect.objectContaining({ - text: 'Enter a valid National Grid field number for Grid field like NG 1234 5678' + text: 'Enter a valid National Grid field number for grid field like NG 1234 5678' }) ]) } diff --git a/src/server/plugins/engine/components/NationalGridFieldNumberField.ts b/src/server/plugins/engine/components/NationalGridFieldNumberField.ts index 962336b4b..acf08c955 100644 --- a/src/server/plugins/engine/components/NationalGridFieldNumberField.ts +++ b/src/server/plugins/engine/components/NationalGridFieldNumberField.ts @@ -1,6 +1,7 @@ import { type NationalGridFieldNumberFieldComponent } from '@defra/forms-model' import { LocationFieldBase } from '~/src/server/plugins/engine/components/LocationFieldBase.js' +import { createLowerFirstExpression } from '~/src/server/plugins/engine/components/helpers/index.js' export class NationalGridFieldNumberField extends LocationFieldBase { declare options: NationalGridFieldNumberFieldComponent['options'] @@ -12,10 +13,15 @@ export class NationalGridFieldNumberField extends LocationFieldBase { const pattern = /^((([sS]|[nN])[a-hA-Hj-zJ-Z])|(([tT]|[oO])[abfglmqrvwABFGLMQRVW])|([hH][l-zL-Z])|([jJ][lmqrvwLMQRVW]))\s?([0-9]{4})\s?([0-9]{4})$/ + const patternTemplate = + 'Enter a valid National Grid field number for {{lowerFirst(#title)}} like NG 1234 5678' + return { pattern, - patternErrorMessage: `Enter a valid National Grid field number for {{#title}} like NG 1234 5678`, - requiredMessage: 'Enter {{#title}}' + patternErrorMessage: createLowerFirstExpression(patternTemplate), + requiredMessage: createLowerFirstExpression( + 'Enter {{lowerFirst(#title)}}' + ) } } @@ -23,8 +29,9 @@ export class NationalGridFieldNumberField extends LocationFieldBase { return [ { type: 'pattern', - template: - 'Enter a valid National Grid field number for {{#title}} like NG 1234 5678' + template: createLowerFirstExpression( + 'Enter a valid National Grid field number for {{lowerFirst(#title)}} like NG 1234 5678' + ) } ] } diff --git a/src/server/plugins/engine/components/OsGridRefField.test.ts b/src/server/plugins/engine/components/OsGridRefField.test.ts index bad21aa81..9183fb081 100644 --- a/src/server/plugins/engine/components/OsGridRefField.test.ts +++ b/src/server/plugins/engine/components/OsGridRefField.test.ts @@ -135,7 +135,7 @@ describe('OsGridRefField', () => { expect(result.errors).toEqual([ expect.objectContaining({ - text: 'Enter Example OS grid reference' + text: 'Enter example OS grid reference' }) ]) }) @@ -308,7 +308,7 @@ describe('OsGridRefField', () => { value: getFormData('TQ12345'), errors: expect.arrayContaining([ expect.objectContaining({ - text: 'Enter a valid OS grid reference for Grid reference like TQ123456' + text: 'Enter a valid OS grid reference for grid reference like TQ123456' }) ]) } @@ -319,7 +319,7 @@ describe('OsGridRefField', () => { value: getFormData('AA1234567'), errors: expect.arrayContaining([ expect.objectContaining({ - text: 'Enter a valid OS grid reference for Grid reference like TQ123456' + text: 'Enter a valid OS grid reference for grid reference like TQ123456' }) ]) } @@ -330,7 +330,7 @@ describe('OsGridRefField', () => { value: getFormData('TQABCDEF'), errors: expect.arrayContaining([ expect.objectContaining({ - text: 'Enter a valid OS grid reference for Grid reference like TQ123456' + text: 'Enter a valid OS grid reference for grid reference like TQ123456' }) ]) } diff --git a/src/server/plugins/engine/components/OsGridRefField.ts b/src/server/plugins/engine/components/OsGridRefField.ts index 4c0d9ec8d..f2561e7f6 100644 --- a/src/server/plugins/engine/components/OsGridRefField.ts +++ b/src/server/plugins/engine/components/OsGridRefField.ts @@ -1,6 +1,7 @@ import { type OsGridRefFieldComponent } from '@defra/forms-model' import { LocationFieldBase } from '~/src/server/plugins/engine/components/LocationFieldBase.js' +import { createLowerFirstExpression } from '~/src/server/plugins/engine/components/helpers/index.js' export class OsGridRefField extends LocationFieldBase { declare options: OsGridRefFieldComponent['options'] @@ -15,10 +16,15 @@ export class OsGridRefField extends LocationFieldBase { const pattern = /^((([sS]|[nN])[a-hA-Hj-zJ-Z])|(([tT]|[oO])[abfglmqrvwABFGLMQRVW])|([hH][l-zL-Z])|([jJ][lmqrvwLMQRVW]))\s?(([0-9]{3})\s?([0-9]{3})|([0-9]{4})\s?([0-9]{4})|([0-9]{5})\s?([0-9]{5}))$/ + const patternTemplate = + 'Enter a valid OS grid reference for {{lowerFirst(#title)}} like TQ123456' + return { pattern, - patternErrorMessage: `Enter a valid OS grid reference for {{#title}} like TQ123456`, - requiredMessage: 'Enter {{#title}}' + patternErrorMessage: createLowerFirstExpression(patternTemplate), + requiredMessage: createLowerFirstExpression( + 'Enter {{lowerFirst(#title)}}' + ) } } @@ -26,7 +32,9 @@ export class OsGridRefField extends LocationFieldBase { return [ { type: 'pattern', - template: 'Enter a valid OS grid reference for {{#title}} like TQ123456' + template: createLowerFirstExpression( + 'Enter a valid OS grid reference for {{lowerFirst(#title)}} like TQ123456' + ) } ] } diff --git a/src/server/plugins/engine/components/helpers/helpers.test.ts b/src/server/plugins/engine/components/helpers/helpers.test.ts index ab4a4762a..de49574a3 100644 --- a/src/server/plugins/engine/components/helpers/helpers.test.ts +++ b/src/server/plugins/engine/components/helpers/helpers.test.ts @@ -6,6 +6,10 @@ import { LatLongField } from '~/src/server/plugins/engine/components/LatLongFiel import { NationalGridFieldNumberField } from '~/src/server/plugins/engine/components/NationalGridFieldNumberField.js' import { OsGridRefField } from '~/src/server/plugins/engine/components/OsGridRefField.js' import { createComponent } from '~/src/server/plugins/engine/components/helpers/components.js' +import { + createLowerFirstExpression, + lowerFirstExpressionOptions +} from '~/src/server/plugins/engine/components/helpers/index.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' import definition from '~/test/form/definitions/basic.js' @@ -123,3 +127,39 @@ describe('ComponentBase tests', () => { expect(component.title).toBe('Context Field') }) }) + +describe('lowerFirst expression helpers', () => { + test('lowerFirstExpressionOptions should have lowerFirst function', () => { + expect(lowerFirstExpressionOptions).toHaveProperty('functions') + expect(lowerFirstExpressionOptions).toHaveProperty( + 'functions.lowerFirst', + expect.any(Function) + ) + }) + + test('createLowerFirstExpression should create a Joi expression', () => { + const template = 'Enter {{lowerFirst(#title)}}' + const expression = createLowerFirstExpression(template) + + expect(expression).toBeDefined() + expect(typeof expression).toBe('object') + expect(expression).toHaveProperty('_template') + }) + + test('createLowerFirstExpression should render template with lowerFirst', () => { + const template = 'Enter {{lowerFirst(#title)}}' + const expression = createLowerFirstExpression(template) + + // Check the rendered template is stored + expect(expression).toHaveProperty('rendered', template) + }) + + test('createLowerFirstExpression should support multiple interpolations', () => { + const template = + 'Easting for {{lowerFirst(#title)}} must be between {{#min}} and {{#max}}' + const expression = createLowerFirstExpression(template) + + expect(expression).toBeDefined() + expect(expression).toHaveProperty('rendered', template) + }) +}) diff --git a/src/server/plugins/engine/components/helpers/index.ts b/src/server/plugins/engine/components/helpers/index.ts index 0ad181c06..d7dd1a748 100644 --- a/src/server/plugins/engine/components/helpers/index.ts +++ b/src/server/plugins/engine/components/helpers/index.ts @@ -1,4 +1,6 @@ import { type ComponentDef } from '@defra/forms-model' +import joi, { type JoiExpression, type ReferenceOptions } from 'joi' +import lowerFirst from 'lodash/lowerFirst.js' /** * Prevent Markdown formatting @@ -36,3 +38,19 @@ export const addClassOptionIfNone = ( ) => { options.classes ??= className } + +/** + * Configuration for Joi expressions that use lowerFirst function + */ +export const lowerFirstExpressionOptions = { + functions: { + lowerFirst + } +} as ReferenceOptions + +/** + * Creates a Joi expression with lowerFirst function support + * Used for error messages in location field components + */ +export const createLowerFirstExpression = (template: string): JoiExpression => + joi.expression(template, lowerFirstExpressionOptions) as JoiExpression