From a009a1c588dc7a5f801646f72757553383e1abfd Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Wed, 5 Nov 2025 16:34:17 +0000 Subject: [PATCH 1/3] feat: Update latitude and longitude validation ranges in LatLongField --- .../engine/components/LatLongField.test.ts | 2 +- .../plugins/engine/components/LatLongField.ts | 8 +- .../engine/components/NumberField.test.ts | 66 +-------- .../plugins/engine/components/NumberField.ts | 131 ++++++++---------- 4 files changed, 61 insertions(+), 146 deletions(-) diff --git a/src/server/plugins/engine/components/LatLongField.test.ts b/src/server/plugins/engine/components/LatLongField.test.ts index f81922bad..f8d9ce35f 100644 --- a/src/server/plugins/engine/components/LatLongField.test.ts +++ b/src/server/plugins/engine/components/LatLongField.test.ts @@ -149,7 +149,7 @@ describe('LatLongField', () => { const result2 = collection.validate( getFormData({ - latitude: '49.1', + latitude: '50.5', longitude: '-8.9' }) ) diff --git a/src/server/plugins/engine/components/LatLongField.ts b/src/server/plugins/engine/components/LatLongField.ts index a52dd7dc8..0ccb9ef88 100644 --- a/src/server/plugins/engine/components/LatLongField.ts +++ b/src/server/plugins/engine/components/LatLongField.ts @@ -57,10 +57,10 @@ export class LatLongField extends FormComponent { const isRequired = options.required !== false // Read schema values from def.schema with fallback defaults - const latitudeMin = schema?.latitude?.min ?? 49 - const latitudeMax = schema?.latitude?.max ?? 60 - const longitudeMin = schema?.longitude?.min ?? -9 - const longitudeMax = schema?.longitude?.max ?? 2 + const latitudeMin = schema?.latitude?.min ?? 49.85 + const latitudeMax = schema?.latitude?.max ?? 60.859 + const longitudeMin = schema?.longitude?.min ?? -13.687 + const longitudeMax = schema?.longitude?.max ?? 1.767 const customValidationMessages: LanguageMessages = convertToLanguageMessages({ diff --git a/src/server/plugins/engine/components/NumberField.test.ts b/src/server/plugins/engine/components/NumberField.test.ts index b052f7528..8cf284d50 100644 --- a/src/server/plugins/engine/components/NumberField.test.ts +++ b/src/server/plugins/engine/components/NumberField.test.ts @@ -3,8 +3,7 @@ import { ComponentType, type NumberFieldComponent } from '@defra/forms-model' import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' import { NumberField, - validateMinimumPrecision, - validateStringLength + validateMinimumPrecision } from '~/src/server/plugins/engine/components/NumberField.js' import { getAnswer, @@ -24,69 +23,6 @@ describe('NumberField', () => { }) describe('Helper Functions', () => { - describe('validateStringLength', () => { - it('returns valid when no constraints provided', () => { - expect(validateStringLength(123)).toEqual({ isValid: true }) - expect(validateStringLength(123, undefined, undefined)).toEqual({ - isValid: true - }) - }) - - it('validates minimum length correctly', () => { - expect(validateStringLength(12, 3)).toEqual({ - isValid: false, - error: 'minLength' - }) - expect(validateStringLength(123, 3)).toEqual({ isValid: true }) - expect(validateStringLength(1234, 3)).toEqual({ isValid: true }) - }) - - it('validates maximum length correctly', () => { - expect(validateStringLength(123456, undefined, 5)).toEqual({ - isValid: false, - error: 'maxLength' - }) - expect(validateStringLength(12345, undefined, 5)).toEqual({ - isValid: true - }) - expect(validateStringLength(123, undefined, 5)).toEqual({ - isValid: true - }) - }) - - it('validates both min and max length', () => { - expect(validateStringLength(12, 3, 5)).toEqual({ - isValid: false, - error: 'minLength' - }) - expect(validateStringLength(123456, 3, 5)).toEqual({ - isValid: false, - error: 'maxLength' - }) - expect(validateStringLength(1234, 3, 5)).toEqual({ isValid: true }) - }) - - it('handles decimal numbers correctly', () => { - // "52.1" = 4 characters - expect(validateStringLength(52.1, 3, 5)).toEqual({ isValid: true }) - // "52.123456" = 9 characters - expect(validateStringLength(52.123456, undefined, 8)).toEqual({ - isValid: false, - error: 'maxLength' - }) - }) - - it('handles negative numbers correctly', () => { - // "-1.5" = 4 characters - expect(validateStringLength(-1.5, 3, 5)).toEqual({ isValid: true }) - // "-9.1234567" = 10 characters - expect(validateStringLength(-9.1234567, undefined, 9)).toEqual({ - isValid: false, - error: 'maxLength' - }) - }) - }) - describe('validateMinimumPrecision', () => { it('returns false for integers', () => { expect(validateMinimumPrecision(52, 1)).toBe(false) diff --git a/src/server/plugins/engine/components/NumberField.ts b/src/server/plugins/engine/components/NumberField.ts index 699ba505b..e913dbe47 100644 --- a/src/server/plugins/engine/components/NumberField.ts +++ b/src/server/plugins/engine/components/NumberField.ts @@ -1,9 +1,5 @@ import { type NumberFieldComponent } from '@defra/forms-model' -import joi, { - type CustomHelpers, - type CustomValidator, - type NumberSchema -} from 'joi' +import joi, { type CustomValidator, type NumberSchema } from 'joi' import { FormComponent, @@ -72,7 +68,9 @@ export class NumberField extends FormComponent { 'number.precision': message, 'number.integer': message, 'number.min': message, - 'number.max': message + 'number.max': message, + 'number.minLength': message, + 'number.maxLength': message }) } else if (options.customValidationMessages) { formSchema = formSchema.messages(options.customValidationMessages) @@ -165,35 +163,6 @@ export class NumberField extends FormComponent { } } -/** - * Validates string length of a numeric value - * @param value - The numeric value to validate - * @param minLength - Minimum required string length - * @param maxLength - Maximum allowed string length - * @returns Object with validation result - */ -export function validateStringLength( - value: number, - minLength?: number, - maxLength?: number -): { isValid: boolean; error?: 'minLength' | 'maxLength' } { - if (typeof minLength !== 'number' && typeof maxLength !== 'number') { - return { isValid: true } - } - - const valueStr = String(value) - - if (typeof minLength === 'number' && valueStr.length < minLength) { - return { isValid: false, error: 'minLength' } - } - - if (typeof maxLength === 'number' && valueStr.length > maxLength) { - return { isValid: false, error: 'maxLength' } - } - - return { isValid: true } -} - /** * Validates minimum decimal precision * @param value - The numeric value to validate @@ -219,36 +188,49 @@ export function validateMinimumPrecision( return false } -/** - * Helper function to handle length validation errors - * Returns the appropriate error response based on the validation result - */ -function handleLengthValidationError( - lengthCheck: ReturnType, - helpers: CustomHelpers, - custom: string | undefined, +function validateStringLengthWithJoi( + value: number, minLength: number | undefined, - maxLength: number | undefined + maxLength: number | undefined, + helpers: joi.CustomHelpers, + custom: string | undefined, + customMessages: Record | undefined ) { - if (!lengthCheck.isValid && lengthCheck.error) { - const errorType = `number.${lengthCheck.error}` - - if (custom) { - // Only pass the relevant length value in context - const contextData = - lengthCheck.error === 'minLength' - ? { minLength: minLength ?? 0 } - : { maxLength: maxLength ?? 0 } - return helpers.message({ custom }, contextData) - } + if (typeof minLength !== 'number' && typeof maxLength !== 'number') { + return null + } + + const valueStr = String(value) + let stringValidator = joi.string() - const context = - lengthCheck.error === 'minLength' - ? { minLength: minLength ?? 0 } - : { maxLength: maxLength ?? 0 } - return helpers.error(errorType, context) + if (typeof minLength === 'number') { + stringValidator = stringValidator.min(minLength) } - return null + if (typeof maxLength === 'number') { + stringValidator = stringValidator.max(maxLength) + } + + const { error } = stringValidator.validate(valueStr) + if (!error) { + return null + } + + const isMinError = error.details[0]?.type === 'string.min' + const messageKey = isMinError ? 'number.minLength' : 'number.maxLength' + const context = isMinError ? { minLength } : { maxLength } + + if (custom) { + return helpers.message({ custom }, context) + } + + if (customMessages?.[messageKey]) { + return helpers.message({ custom: customMessages[messageKey] }, context) + } + + const defaultMessage = isMinError + ? `{{#label}} must be at least ${minLength} characters` + : `{{#label}} must be no more than ${maxLength} characters` + return helpers.message({ custom: defaultMessage }) } export function getValidatorPrecision(component: NumberField) { @@ -268,19 +250,18 @@ export function getValidatorPrecision(component: NumberField) { } if (!limit || limit <= 0) { - const lengthCheck = validateStringLength(value, minLength, maxLength) - const error = handleLengthValidationError( - lengthCheck, + const lengthError = validateStringLengthWithJoi( + value, + minLength, + maxLength, helpers, custom, - minLength, - maxLength + options.customValidationMessages as Record | undefined ) - if (error) return error + if (lengthError) return lengthError return value } - // Validate precision (max decimal places) const validationSchema = joi .number() .precision(limit) @@ -294,23 +275,21 @@ export function getValidatorPrecision(component: NumberField) { : helpers.error('number.precision', { limit }) } - // Validate minimum precision (min decimal places) if (typeof minPrecision === 'number' && minPrecision > 0) { if (!validateMinimumPrecision(value, minPrecision)) { return helpers.error('number.minPrecision', { minPrecision }) } } - // Check string length validation after precision checks - const lengthCheck = validateStringLength(value, minLength, maxLength) - const error = handleLengthValidationError( - lengthCheck, + const lengthError = validateStringLengthWithJoi( + value, + minLength, + maxLength, helpers, custom, - minLength, - maxLength + options.customValidationMessages as Record | undefined ) - if (error) return error + if (lengthError) return lengthError return value } From 5976188b942f7ca902242642f4c36482ca48e453 Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Thu, 6 Nov 2025 09:29:28 +0000 Subject: [PATCH 2/3] refactor: sonar changes (coverage and duplicated lines) --- .../engine/components/NumberField.test.ts | 41 ++++++++++ .../plugins/engine/components/NumberField.ts | 79 +++++++++++-------- 2 files changed, 87 insertions(+), 33 deletions(-) diff --git a/src/server/plugins/engine/components/NumberField.test.ts b/src/server/plugins/engine/components/NumberField.test.ts index 8cf284d50..9bc995e90 100644 --- a/src/server/plugins/engine/components/NumberField.test.ts +++ b/src/server/plugins/engine/components/NumberField.test.ts @@ -916,6 +916,47 @@ describe('NumberField', () => { } ] }, + { + description: 'Default length validation messages (no custom messages)', + component: { + title: 'Example number field', + name: 'myComponent', + type: ComponentType.NumberField, + options: {}, + schema: { + minLength: 3, + maxLength: 5 + } + } satisfies NumberFieldComponent, + assertions: [ + { + input: getFormData('12'), + output: { + value: getFormData(12), + errors: [ + expect.objectContaining({ + text: 'Example number field must be at least 3 characters' + }) + ] + } + }, + { + input: getFormData('123456'), + output: { + value: getFormData(123456), + errors: [ + expect.objectContaining({ + text: 'Example number field must be no more than 5 characters' + }) + ] + } + }, + { + input: getFormData('1234'), + output: { value: getFormData(1234) } + } + ] + }, { description: 'Optional field', component: { diff --git a/src/server/plugins/engine/components/NumberField.ts b/src/server/plugins/engine/components/NumberField.ts index e913dbe47..524d65921 100644 --- a/src/server/plugins/engine/components/NumberField.ts +++ b/src/server/plugins/engine/components/NumberField.ts @@ -15,6 +15,26 @@ import { type FormSubmissionState } from '~/src/server/plugins/engine/types.js' +/** + * Checks if precision requires integer-only validation + * @param precision - The precision value from schema + * @returns true if integers only (precision <= 0 or undefined) + */ +function isIntegerOnlyPrecision( + precision: number | undefined +): precision is number { + return typeof precision === 'number' && precision <= 0 +} + +/** + * Checks if field should use numeric inputmode + * @param precision - The precision value from schema + * @returns true if numeric inputmode should be used + */ +function shouldUseNumericInputMode(precision: number | undefined): boolean { + return typeof precision === 'undefined' || precision <= 0 +} + export class NumberField extends FormComponent { declare options: NumberFieldComponent['options'] declare schema: NumberFieldComponent['schema'] @@ -55,7 +75,7 @@ export class NumberField extends FormComponent { formSchema = formSchema.max(schema.max) } - if (typeof schema.precision === 'number' && schema.precision <= 0) { + if (isIntegerOnlyPrecision(schema.precision)) { formSchema = formSchema.integer() } @@ -97,7 +117,7 @@ export class NumberField extends FormComponent { const viewModel = super.getViewModel(payload, errors) let { attributes, prefix, suffix, value } = viewModel - if (typeof schema.precision === 'undefined' || schema.precision <= 0) { + if (shouldUseNumericInputMode(schema.precision)) { // If precision isn't provided or provided and // less than or equal to 0, use numeric inputmode attributes.inputmode = 'numeric' @@ -196,17 +216,20 @@ function validateStringLengthWithJoi( custom: string | undefined, customMessages: Record | undefined ) { - if (typeof minLength !== 'number' && typeof maxLength !== 'number') { + const hasMinLength = typeof minLength === 'number' + const hasMaxLength = typeof maxLength === 'number' + + if (!hasMinLength && !hasMaxLength) { return null } const valueStr = String(value) let stringValidator = joi.string() - if (typeof minLength === 'number') { + if (hasMinLength) { stringValidator = stringValidator.min(minLength) } - if (typeof maxLength === 'number') { + if (hasMaxLength) { stringValidator = stringValidator.max(maxLength) } @@ -249,35 +272,25 @@ export function getValidatorPrecision(component: NumberField) { maxLength?: number } - if (!limit || limit <= 0) { - const lengthError = validateStringLengthWithJoi( - value, - minLength, - maxLength, - helpers, - custom, - options.customValidationMessages as Record | undefined - ) - if (lengthError) return lengthError - return value - } - - const validationSchema = joi - .number() - .precision(limit) - .prefs({ convert: false }) - - try { - joi.attempt(value, validationSchema) - } catch { - return custom - ? helpers.message({ custom }, { limit }) - : helpers.error('number.precision', { limit }) - } + // Validate maximum precision if limit is set + // Note: We need a separate schema with convert:false to prevent rounding + if (limit && limit > 0) { + const precisionSchema = joi + .number() + .precision(limit) + .prefs({ convert: false }) + + const { error } = precisionSchema.validate(value) + if (error) { + return custom + ? helpers.message({ custom }, { limit }) + : helpers.error('number.precision', { limit }) + } - if (typeof minPrecision === 'number' && minPrecision > 0) { - if (!validateMinimumPrecision(value, minPrecision)) { - return helpers.error('number.minPrecision', { minPrecision }) + if (typeof minPrecision === 'number' && minPrecision > 0) { + if (!validateMinimumPrecision(value, minPrecision)) { + return helpers.error('number.minPrecision', { minPrecision }) + } } } From 54528fac9be07ed82d511b2c2fcdfdaf8ffe2bf5 Mon Sep 17 00:00:00 2001 From: David Stone Date: Fri, 7 Nov 2025 09:01:47 +0000 Subject: [PATCH 3/3] Feat/df 529 change lat long 2 (#239) * Fix unicorn form * Revert changes to NumberField * Remove minLength and maxLength from EN field * Remove minPrecision, minLength and maxLength from LL field * Update NGR and OS ref fields * Remove case insensitive flag --- .../forms/register-as-a-unicorn-breeder.yaml | 4 +- .../components/EastingNorthingField.test.ts | 14 - .../engine/components/EastingNorthingField.ts | 28 +- .../engine/components/LatLongField.test.ts | 26 +- .../plugins/engine/components/LatLongField.ts | 33 +- .../engine/components/LocationFieldBase.ts | 16 +- .../NationalGridFieldNumberField.test.ts | 30 +- .../NationalGridFieldNumberField.ts | 29 +- .../engine/components/NumberField.test.ts | 493 +----------------- .../plugins/engine/components/NumberField.ts | 159 +----- .../engine/components/OsGridRefField.test.ts | 44 +- .../engine/components/OsGridRefField.ts | 43 +- 12 files changed, 92 insertions(+), 827 deletions(-) diff --git a/src/server/forms/register-as-a-unicorn-breeder.yaml b/src/server/forms/register-as-a-unicorn-breeder.yaml index d8de940d6..2263aebfc 100644 --- a/src/server/forms/register-as-a-unicorn-breeder.yaml +++ b/src/server/forms/register-as-a-unicorn-breeder.yaml @@ -203,7 +203,7 @@ pages: path: '/how-many-members-of-staff-will-look-after-the-unicorns' section: susaYr next: - - path: '/summary' + - path: '/declaration' components: - name: zhJMaM options: @@ -219,7 +219,7 @@ pages: controller: FileUploadPageController section: Regnsa next: - - path: '/declaration' + - path: '/how-many-unicorns-do-you-expect-to-breed-each-year' components: - name: dLzALM title: Documents diff --git a/src/server/plugins/engine/components/EastingNorthingField.test.ts b/src/server/plugins/engine/components/EastingNorthingField.test.ts index fa5e328a2..8cbebf244 100644 --- a/src/server/plugins/engine/components/EastingNorthingField.test.ts +++ b/src/server/plugins/engine/components/EastingNorthingField.test.ts @@ -556,14 +556,7 @@ describe('EastingNorthingField', () => { easting: 12345.5, northing: 1234567 }), - // Two errors expected: decimal input triggers both integer validation - // and length validation ('12345.5' is 7 chars, max is 6) errors: [ - expect.objectContaining({ - text: expect.stringMatching( - /Easting for .* must be between 1 and 6 digits/ - ) - }), expect.objectContaining({ text: expect.stringMatching( /Easting for .* must be between 1 and 6 digits/ @@ -582,14 +575,7 @@ describe('EastingNorthingField', () => { easting: 12345, northing: 1234567.5 }), - // Two errors expected: decimal input triggers both integer validation - // and length validation ('1234567.5' is 9 chars, max is 7) errors: [ - expect.objectContaining({ - text: expect.stringMatching( - /Northing for .* must be between 1 and 7 digits/ - ) - }), expect.objectContaining({ text: expect.stringMatching( /Northing for .* must be between 1 and 7 digits/ diff --git a/src/server/plugins/engine/components/EastingNorthingField.ts b/src/server/plugins/engine/components/EastingNorthingField.ts index 16f38502e..2a48efc08 100644 --- a/src/server/plugins/engine/components/EastingNorthingField.ts +++ b/src/server/plugins/engine/components/EastingNorthingField.ts @@ -32,18 +32,6 @@ const DEFAULT_EASTING_MAX = 700000 const DEFAULT_NORTHING_MIN = 0 const DEFAULT_NORTHING_MAX = 1300000 -// Easting length constraints (integer values only, no decimals) -// Min: 1 char for values like "0" or single digit values -// Max: 6 chars for values up to 700000 (British National Grid easting limit) -const EASTING_MIN_LENGTH = 1 -const EASTING_MAX_LENGTH = 6 - -// Northing length constraints (integer values only, no decimals) -// Min: 1 char for values like "0" or single digit values -// Max: 7 chars for values up to 1300000 (British National Grid northing limit) -const NORTHING_MIN_LENGTH = 1 -const NORTHING_MAX_LENGTH = 7 - export class EastingNorthingField extends FormComponent { declare options: EastingNorthingFieldComponent['options'] declare formSchema: ObjectSchema @@ -73,9 +61,7 @@ export class EastingNorthingField extends FormComponent { 'number.max': `{{#label}} for ${this.title} must be between ${eastingMin} and {{#limit}}`, 'number.precision': `{{#label}} for ${this.title} must be between 1 and 6 digits`, 'number.integer': `{{#label}} for ${this.title} must be between 1 and 6 digits`, - 'number.unsafe': `{{#label}} for ${this.title} must be between 1 and 6 digits`, - 'number.minLength': `{{#label}} for ${this.title} must be between 1 and 6 digits`, - 'number.maxLength': `{{#label}} for ${this.title} must be between 1 and 6 digits` + 'number.unsafe': `{{#label}} for ${this.title} must be between 1 and 6 digits` }) const northingValidationMessages: LanguageMessages = @@ -86,9 +72,7 @@ export class EastingNorthingField extends FormComponent { 'number.max': `{{#label}} for ${this.title} must be between ${northingMin} and {{#limit}}`, 'number.precision': `{{#label}} for ${this.title} must be between 1 and 7 digits`, 'number.integer': `{{#label}} for ${this.title} must be between 1 and 7 digits`, - 'number.unsafe': `{{#label}} for ${this.title} must be between 1 and 7 digits`, - 'number.minLength': `{{#label}} for ${this.title} must be between 1 and 7 digits`, - 'number.maxLength': `{{#label}} for ${this.title} must be between 1 and 7 digits` + 'number.unsafe': `{{#label}} for ${this.title} must be between 1 and 7 digits` }) this.collection = new ComponentCollection( @@ -100,9 +84,7 @@ export class EastingNorthingField extends FormComponent { schema: { min: eastingMin, max: eastingMax, - precision: 0, - minLength: EASTING_MIN_LENGTH, - maxLength: EASTING_MAX_LENGTH + precision: 0 }, options: { required: isRequired, @@ -118,9 +100,7 @@ export class EastingNorthingField extends FormComponent { schema: { min: northingMin, max: northingMax, - precision: 0, - minLength: NORTHING_MIN_LENGTH, - maxLength: NORTHING_MAX_LENGTH + precision: 0 }, options: { required: isRequired, diff --git a/src/server/plugins/engine/components/LatLongField.test.ts b/src/server/plugins/engine/components/LatLongField.test.ts index f8d9ce35f..6536506f6 100644 --- a/src/server/plugins/engine/components/LatLongField.test.ts +++ b/src/server/plugins/engine/components/LatLongField.test.ts @@ -578,15 +578,7 @@ describe('LatLongField', () => { value: getFormData({ latitude: 52, longitude: -1 - }), - errors: [ - expect.objectContaining({ - text: 'Latitude must have at least 1 decimal place' - }), - expect.objectContaining({ - text: 'Longitude must have at least 1 decimal place' - }) - ] + }) } }, { @@ -619,7 +611,6 @@ describe('LatLongField', () => { description: 'Length and precision validation', component: createLatLongComponent(), assertions: [ - // Latitude too short { input: getFormData({ latitude: '52', @@ -629,12 +620,7 @@ describe('LatLongField', () => { value: getFormData({ latitude: 52, longitude: -1.5 - }), - errors: [ - expect.objectContaining({ - text: 'Latitude must have at least 1 decimal place' - }) - ] + }) } }, // Latitude too long @@ -655,7 +641,6 @@ describe('LatLongField', () => { ] } }, - // Longitude too short { input: getFormData({ latitude: '52.1', @@ -665,12 +650,7 @@ describe('LatLongField', () => { value: getFormData({ latitude: 52.1, longitude: -1 - }), - errors: [ - expect.objectContaining({ - text: 'Longitude must have at least 1 decimal place' - }) - ] + }) } }, // Longitude too long diff --git a/src/server/plugins/engine/components/LatLongField.ts b/src/server/plugins/engine/components/LatLongField.ts index 0ccb9ef88..f90beb541 100644 --- a/src/server/plugins/engine/components/LatLongField.ts +++ b/src/server/plugins/engine/components/LatLongField.ts @@ -26,19 +26,6 @@ import { convertToLanguageMessages } from '~/src/server/utils/type-utils.js' // Precision constants // UK latitude/longitude requires high precision for accurate location (within ~11mm) const DECIMAL_PRECISION = 7 // 7 decimal places -const MIN_DECIMAL_PLACES = 1 // At least 1 decimal place required - -// Latitude length constraints -// Min: 3 chars for values like "52.1" (2 digits + decimal + 1 decimal place) -// Max: 10 chars for values like "59.1234567" (2 digits + decimal + 7 decimal places) -const LATITUDE_MIN_LENGTH = 3 -const LATITUDE_MAX_LENGTH = 10 - -// Longitude length constraints -// Min: 2 chars for values like "-1" or single digit with decimal (needs min decimal places) -// Max: 10 chars for values like "-1.1234567" (minus + 1 digit + decimal + 7 decimal places) -const LONGITUDE_MIN_LENGTH = 2 -const LONGITUDE_MAX_LENGTH = 10 export class LatLongField extends FormComponent { declare options: LatLongFieldComponent['options'] @@ -68,8 +55,6 @@ export class LatLongField extends FormComponent { 'number.base': messageTemplate.objectMissing, 'number.precision': '{{#label}} must have no more than 7 decimal places', - 'number.minPrecision': - '{{#label}} must have at least {{#minPrecision}} decimal place', 'number.unsafe': '{{#label}} must be a valid number' }) @@ -77,18 +62,14 @@ export class LatLongField extends FormComponent { ...customValidationMessages, 'number.base': `Enter a valid latitude for ${this.title} like 51.519450`, 'number.min': `Latitude for ${this.title} must be between ${latitudeMin} and ${latitudeMax}`, - 'number.max': `Latitude for ${this.title} must be between ${latitudeMin} and ${latitudeMax}`, - 'number.minLength': `Latitude for ${this.title} must be between 3 and 10 characters`, - 'number.maxLength': `Latitude for ${this.title} must be between 3 and 10 characters` + 'number.max': `Latitude for ${this.title} must be between ${latitudeMin} and ${latitudeMax}` }) const longitudeMessages: LanguageMessages = convertToLanguageMessages({ ...customValidationMessages, 'number.base': `Enter a valid longitude for ${this.title} like -0.127758`, 'number.min': `Longitude for ${this.title} must be between ${longitudeMin} and ${longitudeMax}`, - 'number.max': `Longitude for ${this.title} must be between ${longitudeMin} and ${longitudeMax}`, - 'number.minLength': `Longitude for ${this.title} must be between 2 and 10 characters`, - 'number.maxLength': `Longitude for ${this.title} must be between 2 and 10 characters` + 'number.max': `Longitude for ${this.title} must be between ${longitudeMin} and ${longitudeMax}` }) this.collection = new ComponentCollection( @@ -100,10 +81,7 @@ export class LatLongField extends FormComponent { schema: { min: latitudeMin, max: latitudeMax, - precision: DECIMAL_PRECISION, - minPrecision: MIN_DECIMAL_PLACES, - minLength: LATITUDE_MIN_LENGTH, - maxLength: LATITUDE_MAX_LENGTH + precision: DECIMAL_PRECISION }, options: { required: isRequired, @@ -120,10 +98,7 @@ export class LatLongField extends FormComponent { schema: { min: longitudeMin, max: longitudeMax, - precision: DECIMAL_PRECISION, - minPrecision: MIN_DECIMAL_PLACES, - minLength: LONGITUDE_MIN_LENGTH, - maxLength: LONGITUDE_MAX_LENGTH + precision: DECIMAL_PRECISION }, options: { required: isRequired, diff --git a/src/server/plugins/engine/components/LocationFieldBase.ts b/src/server/plugins/engine/components/LocationFieldBase.ts index e91ca7eed..824d0f0bf 100644 --- a/src/server/plugins/engine/components/LocationFieldBase.ts +++ b/src/server/plugins/engine/components/LocationFieldBase.ts @@ -28,11 +28,6 @@ interface LocationFieldOptions { interface ValidationConfig { pattern: RegExp patternErrorMessage: string - customValidation?: ( - value: string, - helpers: joi.CustomHelpers - ) => string | joi.ErrorReport - additionalMessages?: LanguageMessages } /** @@ -71,14 +66,9 @@ export abstract class LocationFieldBase extends FormComponent { .required() .pattern(config.pattern) .messages({ - 'string.pattern.base': config.patternErrorMessage, - ...config.additionalMessages + 'string.pattern.base': config.patternErrorMessage }) - if (config.customValidation) { - formSchema = formSchema.custom(config.customValidation) - } - if (locationOptions.required === false) { formSchema = formSchema.allow('') } @@ -91,10 +81,6 @@ export abstract class LocationFieldBase extends FormComponent { 'string.pattern.base' ] - if (config.additionalMessages) { - messageKeys.push(...Object.keys(config.additionalMessages)) - } - const messages = messageKeys.reduce((acc, key) => { acc[key] = message return acc diff --git a/src/server/plugins/engine/components/NationalGridFieldNumberField.test.ts b/src/server/plugins/engine/components/NationalGridFieldNumberField.test.ts index 7b505c406..2f52ca83d 100644 --- a/src/server/plugins/engine/components/NationalGridFieldNumberField.test.ts +++ b/src/server/plugins/engine/components/NationalGridFieldNumberField.test.ts @@ -99,13 +99,27 @@ describe('NationalGridFieldNumberField', () => { }) it('accepts valid values', () => { - const result1 = collection.validate(getFormData('NG12345678')) - const result2 = collection.validate(getFormData('ng12345678')) - const result3 = collection.validate(getFormData('AB98765432')) + // Test 8-digit parcel ID format (2x4) + const result1 = collection.validate(getFormData('TQ12345678')) + const result2 = collection.validate(getFormData('TQ 1234 5678')) + + // Test 10-digit OS grid reference format (2x5) + const result3 = collection.validate(getFormData('SU1234567890')) + const result4 = collection.validate(getFormData('SU 12345 67890')) + + expect(result1.errors).toBeUndefined() + expect(result2.errors).toBeUndefined() + expect(result3.errors).toBeUndefined() + expect(result4.errors).toBeUndefined() + + // Test case-insensitive + const result5 = collection.validate(getFormData('nt12345678')) expect(result1.errors).toBeUndefined() expect(result2.errors).toBeUndefined() expect(result3.errors).toBeUndefined() + expect(result4.errors).toBeUndefined() + expect(result5.errors).toBeUndefined() }) it('formats values with spaces per GDS guidance', () => { @@ -114,8 +128,8 @@ describe('NationalGridFieldNumberField', () => { const result3 = collection.validate(getFormData('NG12345,678')) expect(result1.value.myComponent).toBe('NG 1234 5678') - expect(result2.value.myComponent).toBe('NG 1234 5678') - expect(result3.value.myComponent).toBe('NG 1234 5678') + expect(result2.value.myComponent).toBe('NG12345678') + expect(result3.value.myComponent).toBe('NG12345,678') }) it('adds errors for empty value', () => { @@ -258,15 +272,15 @@ describe('NationalGridFieldNumberField', () => { assertions: [ { input: getFormData(' NG12345678'), - output: { value: getFormData('NG 1234 5678') } + output: { value: getFormData('NG12345678') } }, { input: getFormData('NG12345678 '), - output: { value: getFormData('NG 1234 5678') } + output: { value: getFormData('NG12345678') } }, { input: getFormData(' NG12345678 \n\n'), - output: { value: getFormData('NG 1234 5678') } + output: { value: getFormData('NG12345678') } } ] }, diff --git a/src/server/plugins/engine/components/NationalGridFieldNumberField.ts b/src/server/plugins/engine/components/NationalGridFieldNumberField.ts index a1efe74c9..19400d7e4 100644 --- a/src/server/plugins/engine/components/NationalGridFieldNumberField.ts +++ b/src/server/plugins/engine/components/NationalGridFieldNumberField.ts @@ -1,5 +1,4 @@ import { type NationalGridFieldNumberFieldComponent } from '@defra/forms-model' -import type joi from 'joi' import { LocationFieldBase } from '~/src/server/plugins/engine/components/LocationFieldBase.js' @@ -7,26 +6,16 @@ export class NationalGridFieldNumberField extends LocationFieldBase { declare options: NationalGridFieldNumberFieldComponent['options'] protected getValidationConfig() { - return { - // Pattern allows spaces and commas in the input since custom validation will clean them - pattern: /^[A-Z]{2}[\d\s,]*$/i, - patternErrorMessage: `Enter a valid National Grid field number for ${this.title} like NG 1234 5678`, - customValidation: (value: string, helpers: joi.CustomHelpers) => { - // Strip spaces and commas for validation - const cleanValue = value.replace(/[\s,]/g, '') - - // Check if it matches the exact pattern after cleaning - if (!/^[A-Z]{2}\d{8}$/i.test(cleanValue)) { - return helpers.error('string.pattern.base') - } - - // Format with spaces per GDS guidance: NG 1234 5678 - const letters = cleanValue.substring(0, 2) - const numbers = cleanValue.substring(2) - const formattedValue = `${letters} ${numbers.substring(0, 4)} ${numbers.substring(4)}` + // Regex for OS grid references and parcel IDs + // Validates specific valid OS grid letter combinations with: + // - 2 letters & 8 digits in 2 blocks of 4 (parcel ID) e.g., ST 6789 6789 + // - 2 letters & 10 digits in 2 blocks of 5 (OS grid reference) e.g., SO 12345 12345 + 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})|([0-9]{5})\s?([0-9]{5}))$/ - return formattedValue - } + return { + pattern, + patternErrorMessage: `Enter a valid National Grid field number for ${this.title} like NG 1234 5678` } } diff --git a/src/server/plugins/engine/components/NumberField.test.ts b/src/server/plugins/engine/components/NumberField.test.ts index 9bc995e90..28b266df0 100644 --- a/src/server/plugins/engine/components/NumberField.test.ts +++ b/src/server/plugins/engine/components/NumberField.test.ts @@ -1,10 +1,7 @@ import { ComponentType, type NumberFieldComponent } from '@defra/forms-model' import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' -import { - NumberField, - validateMinimumPrecision -} from '~/src/server/plugins/engine/components/NumberField.js' +import { NumberField } from '~/src/server/plugins/engine/components/NumberField.js' import { getAnswer, type Field @@ -22,31 +19,6 @@ describe('NumberField', () => { }) }) - describe('Helper Functions', () => { - describe('validateMinimumPrecision', () => { - it('returns false for integers', () => { - expect(validateMinimumPrecision(52, 1)).toBe(false) - expect(validateMinimumPrecision(100, 2)).toBe(false) - }) - - it('validates minimum precision correctly', () => { - expect(validateMinimumPrecision(52.1, 1)).toBe(true) - expect(validateMinimumPrecision(52.12, 2)).toBe(true) - expect(validateMinimumPrecision(52.123, 3)).toBe(true) - }) - - it('returns false when precision is insufficient', () => { - expect(validateMinimumPrecision(52.1, 2)).toBe(false) - expect(validateMinimumPrecision(52.12, 3)).toBe(false) - }) - - it('handles exact precision requirement', () => { - expect(validateMinimumPrecision(52.12345, 5)).toBe(true) - expect(validateMinimumPrecision(52.1234, 5)).toBe(false) - }) - }) - }) - describe('Defaults', () => { let def: NumberFieldComponent let collection: ComponentCollection @@ -532,184 +504,17 @@ describe('NumberField', () => { ] }, { - description: 'Schema minPrecision (minimum 1 decimal place)', - component: createPrecisionTestComponent(1), - assertions: [ - { - input: getFormData('52'), - output: { - value: getFormData(52), - errors: [ - expect.objectContaining({ - text: 'Example number field must have at least 1 decimal place' - }) - ] - } - }, - { - input: getFormData('52.0'), - output: { - value: getFormData(52), - errors: [ - expect.objectContaining({ - text: 'Example number field must have at least 1 decimal place' - }) - ] - } - }, - { - input: getFormData('52.1'), - output: { value: getFormData(52.1) } - }, - { - input: getFormData('52.123456'), - output: { value: getFormData(52.123456) } - } - ] - }, - { - description: 'Schema minPrecision (minimum 2 decimal places)', - component: createPrecisionTestComponent(2), - assertions: [ - { - input: getFormData('52.1'), - output: { - value: getFormData(52.1), - errors: [ - expect.objectContaining({ - text: 'Example number field must have at least 2 decimal places' - }) - ] - } - }, - { - input: getFormData('52.12'), - output: { value: getFormData(52.12) } - }, - { - input: getFormData('52.1234567'), - output: { value: getFormData(52.1234567) } - } - ] - }, - { - description: 'Schema minLength (minimum 3 characters)', - component: createLengthTestComponent(3, undefined), - assertions: [ - { - input: getFormData('12'), - output: { - value: getFormData(12), - errors: [ - expect.objectContaining({ - text: 'Example number field must be at least 3 characters' - }) - ] - } - }, - { - input: getFormData('123'), - output: { value: getFormData(123) } - }, - { - input: getFormData('1234'), - output: { value: getFormData(1234) } - } - ] - }, - { - description: 'Schema maxLength (maximum 5 characters)', - component: createLengthTestComponent(undefined, 5), - assertions: [ - { - input: getFormData('123456'), - output: { - value: getFormData(123456), - errors: [ - expect.objectContaining({ - text: 'Example number field must be no more than 5 characters' - }) - ] - } - }, - { - input: getFormData('12345'), - output: { value: getFormData(12345) } - }, - { - input: getFormData('123'), - output: { value: getFormData(123) } - } - ] - }, - { - description: - 'Schema minLength and maxLength (3-8 characters, like latitude)', + description: 'Schema min and max', component: { - title: 'Latitude field', - shortDescription: 'Latitude', + title: 'Example number field', name: 'myComponent', type: ComponentType.NumberField, - options: { - customValidationMessages: { - 'number.minPrecision': - '{{#label}} must have at least {{#minPrecision}} decimal place', - 'number.minLength': - '{{#label}} must be between 3 and 10 characters', - 'number.maxLength': - '{{#label}} must be between 3 and 10 characters' - } - }, - schema: { - min: 49, - max: 60, - precision: 7, - minPrecision: 1, - minLength: 3, - maxLength: 10 - } - } as NumberFieldComponent, - assertions: [ - { - input: getFormData('52'), - output: { - value: getFormData(52), - errors: [ - expect.objectContaining({ - text: 'Latitude must have at least 1 decimal place' - }) - ] - } - }, - { - input: getFormData('52.12345678'), - output: { - value: getFormData(52.12345678), - errors: [ - expect.objectContaining({ - text: 'Latitude must have 7 or fewer decimal places' - }) - ] - } - }, - { - input: getFormData('52.1'), - output: { value: getFormData(52.1) } - }, - { - input: getFormData('52.1234'), - output: { value: getFormData(52.1234) } - } - ] - }, - { - description: 'Schema min and max', - component: createNumberComponent({ + options: {}, schema: { min: 5, max: 8 } - }), + } satisfies NumberFieldComponent, assertions: [ { input: getFormData('4'), @@ -737,7 +542,10 @@ describe('NumberField', () => { }, { description: 'Custom validation message', - component: createNumberComponent({ + component: { + title: 'Example number field', + name: 'myComponent', + type: ComponentType.NumberField, options: { customValidationMessage: 'This is a custom error', customValidationMessages: { @@ -746,8 +554,9 @@ describe('NumberField', () => { 'number.min': 'This is not used', 'number.max': 'This is not used' } - } - }), + }, + schema: {} + } satisfies NumberFieldComponent, assertions: [ { input: getFormData(''), @@ -877,86 +686,6 @@ describe('NumberField', () => { } ] }, - { - description: 'Custom validation message overrides length validation', - component: { - title: 'Example number field', - name: 'myComponent', - type: ComponentType.NumberField, - options: { - customValidationMessage: 'This is a custom length error' - }, - schema: { - minLength: 3, - maxLength: 5 - } - } satisfies NumberFieldComponent, - assertions: [ - { - input: getFormData('12'), - output: { - value: getFormData(12), - errors: [ - expect.objectContaining({ - text: 'This is a custom length error' - }) - ] - } - }, - { - input: getFormData('123456'), - output: { - value: getFormData(123456), - errors: [ - expect.objectContaining({ - text: 'This is a custom length error' - }) - ] - } - } - ] - }, - { - description: 'Default length validation messages (no custom messages)', - component: { - title: 'Example number field', - name: 'myComponent', - type: ComponentType.NumberField, - options: {}, - schema: { - minLength: 3, - maxLength: 5 - } - } satisfies NumberFieldComponent, - assertions: [ - { - input: getFormData('12'), - output: { - value: getFormData(12), - errors: [ - expect.objectContaining({ - text: 'Example number field must be at least 3 characters' - }) - ] - } - }, - { - input: getFormData('123456'), - output: { - value: getFormData(123456), - errors: [ - expect.objectContaining({ - text: 'Example number field must be no more than 5 characters' - }) - ] - } - }, - { - input: getFormData('1234'), - output: { value: getFormData(1234) } - } - ] - }, { description: 'Optional field', component: { @@ -991,202 +720,4 @@ describe('NumberField', () => { ) }) }) - - describe('Edge cases', () => { - let collection: ComponentCollection - - beforeEach(() => { - const def = createNumberComponent({ - schema: { - min: -100, - max: 100, - precision: 2 - } - }) - collection = new ComponentCollection([def], { model }) - }) - - it('handles negative numbers correctly', () => { - const result = collection.validate(getFormData('-50.5')) - expect(result).toEqual({ - value: getFormData(-50.5) - }) - }) - - it('handles zero correctly', () => { - const result = collection.validate(getFormData('0')) - expect(result).toEqual({ - value: getFormData(0) - }) - }) - - it('handles zero with decimal correctly', () => { - const result = collection.validate(getFormData('0.0')) - expect(result).toEqual({ - value: getFormData(0) - }) - }) - - it('handles negative zero correctly', () => { - const result = collection.validate(getFormData('-0')) - expect(result).toEqual({ - value: getFormData(0) - }) - }) - - it('handles scientific notation (parsed as number, may fail range)', () => { - // JavaScript parses '1e10' as 10000000000, which exceeds max of 100 - const result = collection.validate(getFormData('1e10')) - expect(result).toEqual({ - value: getFormData(10000000000), - errors: [ - expect.objectContaining({ - text: 'Example number field must be 100 or lower' - }) - ] - }) - }) - - it('handles scientific notation with negative exponent (parsed as number)', () => { - // JavaScript parses '1e-5' as 0.00001, which fails precision check (5 decimal places > 2) - const result = collection.validate(getFormData('1e-5')) - expect(result.value).toEqual(getFormData(0.00001)) - expect(result.errors).toBeDefined() - expect(result.errors?.[0]).toMatchObject({ - text: 'Example number field must have 2 or fewer decimal places' - }) - }) - - it('handles large negative numbers', () => { - const result = collection.validate(getFormData('-99.99')) - expect(result).toEqual({ - value: getFormData(-99.99) - }) - }) - - it('handles numbers at boundary limits', () => { - const maxResult = collection.validate(getFormData('100')) - expect(maxResult).toEqual({ - value: getFormData(100) - }) - - const minResult = collection.validate(getFormData('-100')) - expect(minResult).toEqual({ - value: getFormData(-100) - }) - }) - - describe('with length constraints', () => { - beforeEach(() => { - const def = createNumberComponent({ - schema: { - min: -9, - max: 9, - precision: 7, - minPrecision: 1, - minLength: 2, - maxLength: 10 - }, - options: { - customValidationMessages: { - 'number.minPrecision': - 'Example number field must have at least {{minPrecision}} decimal place', - 'number.precision': - 'Example number field must have no more than {{limit}} decimal places', - 'number.minLength': - 'Example number field must be at least {{minLength}} characters', - 'number.maxLength': - 'Example number field must be no more than {{maxLength}} characters' - } - } - }) - collection = new ComponentCollection([def], { model }) - }) - - it('validates negative numbers with decimals', () => { - const result = collection.validate(getFormData('-5.1234567')) - expect(result).toEqual({ - value: getFormData(-5.1234567) - }) - }) - - it('rejects negative numbers that are too short', () => { - const result = collection.validate(getFormData('-5')) - expect(result.value).toEqual(getFormData(-5)) - expect(result.errors).toBeDefined() - expect(result.errors?.[0].text).toContain('decimal place') - }) - - it('rejects numbers with too many characters', () => { - const result = collection.validate(getFormData('-5.12345678')) - expect(result.value).toEqual(getFormData(-5.12345678)) - expect(result.errors).toBeDefined() - expect(result.errors?.[0].text).toContain('decimal places') - }) - }) - }) }) - -/** - * Factory function to create a default NumberField component with optional overrides - */ -function createNumberComponent( - overrides: Partial = {} -): NumberFieldComponent { - const base = { - title: 'Example number field', - name: 'myComponent', - type: ComponentType.NumberField, - options: {}, - schema: {} - } satisfies NumberFieldComponent - - // Deep merge for nested objects like options and schema - return { - ...base, - ...overrides, - options: { ...base.options, ...(overrides.options ?? {}) }, - schema: { ...base.schema, ...(overrides.schema ?? {}) } - } satisfies NumberFieldComponent -} - -/** - * Helper for precision validation tests - */ -function createPrecisionTestComponent( - minPrecision: number, - precision = 7 -): NumberFieldComponent { - const pluralSuffix = minPrecision > 1 ? 's' : '' - return createNumberComponent({ - options: { - customValidationMessages: { - 'number.minPrecision': `{{#label}} must have at least {{#minPrecision}} decimal place${pluralSuffix}` - } - }, - schema: { precision, minPrecision } - }) -} - -/** - * Helper for length validation tests - */ -function createLengthTestComponent( - minLength?: number, - maxLength?: number -): NumberFieldComponent { - const messages: Record = {} - if (minLength) { - messages['number.minLength'] = - '{{#label}} must be at least {{#minLength}} characters' - } - if (maxLength) { - messages['number.maxLength'] = - '{{#label}} must be no more than {{#maxLength}} characters' - } - - return createNumberComponent({ - options: { customValidationMessages: messages }, - schema: { minLength, maxLength } - }) -} diff --git a/src/server/plugins/engine/components/NumberField.ts b/src/server/plugins/engine/components/NumberField.ts index 524d65921..787716255 100644 --- a/src/server/plugins/engine/components/NumberField.ts +++ b/src/server/plugins/engine/components/NumberField.ts @@ -15,26 +15,6 @@ import { type FormSubmissionState } from '~/src/server/plugins/engine/types.js' -/** - * Checks if precision requires integer-only validation - * @param precision - The precision value from schema - * @returns true if integers only (precision <= 0 or undefined) - */ -function isIntegerOnlyPrecision( - precision: number | undefined -): precision is number { - return typeof precision === 'number' && precision <= 0 -} - -/** - * Checks if field should use numeric inputmode - * @param precision - The precision value from schema - * @returns true if numeric inputmode should be used - */ -function shouldUseNumericInputMode(precision: number | undefined): boolean { - return typeof precision === 'undefined' || precision <= 0 -} - export class NumberField extends FormComponent { declare options: NumberFieldComponent['options'] declare schema: NumberFieldComponent['schema'] @@ -75,7 +55,7 @@ export class NumberField extends FormComponent { formSchema = formSchema.max(schema.max) } - if (isIntegerOnlyPrecision(schema.precision)) { + if (typeof schema.precision === 'number' && schema.precision <= 0) { formSchema = formSchema.integer() } @@ -88,9 +68,7 @@ export class NumberField extends FormComponent { 'number.precision': message, 'number.integer': message, 'number.min': message, - 'number.max': message, - 'number.minLength': message, - 'number.maxLength': message + 'number.max': message }) } else if (options.customValidationMessages) { formSchema = formSchema.messages(options.customValidationMessages) @@ -117,7 +95,7 @@ export class NumberField extends FormComponent { const viewModel = super.getViewModel(payload, errors) let { attributes, prefix, suffix, value } = viewModel - if (shouldUseNumericInputMode(schema.precision)) { + if (typeof schema.precision === 'undefined' || schema.precision <= 0) { // If precision isn't provided or provided and // less than or equal to 0, use numeric inputmode attributes.inputmode = 'numeric' @@ -183,128 +161,29 @@ export class NumberField extends FormComponent { } } -/** - * Validates minimum decimal precision - * @param value - The numeric value to validate - * @param minPrecision - Minimum required decimal places - * @returns true if valid, false if invalid - */ -export function validateMinimumPrecision( - value: number, - minPrecision: number -): boolean { - if (Number.isInteger(value)) { - return false - } - - const valueStr = String(value) - const decimalIndex = valueStr.indexOf('.') - - if (decimalIndex !== -1) { - const decimalPlaces = valueStr.length - decimalIndex - 1 - return decimalPlaces >= minPrecision - } - - return false -} - -function validateStringLengthWithJoi( - value: number, - minLength: number | undefined, - maxLength: number | undefined, - helpers: joi.CustomHelpers, - custom: string | undefined, - customMessages: Record | undefined -) { - const hasMinLength = typeof minLength === 'number' - const hasMaxLength = typeof maxLength === 'number' - - if (!hasMinLength && !hasMaxLength) { - return null - } - - const valueStr = String(value) - let stringValidator = joi.string() - - if (hasMinLength) { - stringValidator = stringValidator.min(minLength) - } - if (hasMaxLength) { - stringValidator = stringValidator.max(maxLength) - } - - const { error } = stringValidator.validate(valueStr) - if (!error) { - return null - } - - const isMinError = error.details[0]?.type === 'string.min' - const messageKey = isMinError ? 'number.minLength' : 'number.maxLength' - const context = isMinError ? { minLength } : { maxLength } - - if (custom) { - return helpers.message({ custom }, context) - } - - if (customMessages?.[messageKey]) { - return helpers.message({ custom: customMessages[messageKey] }, context) - } - - const defaultMessage = isMinError - ? `{{#label}} must be at least ${minLength} characters` - : `{{#label}} must be no more than ${maxLength} characters` - return helpers.message({ custom: defaultMessage }) -} - export function getValidatorPrecision(component: NumberField) { const validator: CustomValidator = (value: number, helpers) => { const { options, schema } = component - const { customValidationMessage: custom } = options - const { - precision: limit, - minPrecision, - minLength, - maxLength - } = schema as { - precision?: number - minPrecision?: number - minLength?: number - maxLength?: number - } - // Validate maximum precision if limit is set - // Note: We need a separate schema with convert:false to prevent rounding - if (limit && limit > 0) { - const precisionSchema = joi - .number() - .precision(limit) - .prefs({ convert: false }) - - const { error } = precisionSchema.validate(value) - if (error) { - return custom - ? helpers.message({ custom }, { limit }) - : helpers.error('number.precision', { limit }) - } + const { customValidationMessage: custom } = options + const { precision: limit } = schema - if (typeof minPrecision === 'number' && minPrecision > 0) { - if (!validateMinimumPrecision(value, minPrecision)) { - return helpers.error('number.minPrecision', { minPrecision }) - } - } + if (!limit || limit <= 0) { + return value } - const lengthError = validateStringLengthWithJoi( - value, - minLength, - maxLength, - helpers, - custom, - options.customValidationMessages as Record | undefined - ) - if (lengthError) return lengthError - - return value + const validationSchema = joi + .number() + .precision(limit) + .prefs({ convert: false }) + + try { + return joi.attempt(value, validationSchema) + } catch { + return custom + ? helpers.message({ custom }, { limit }) + : helpers.error('number.precision', { limit }) + } } return validator diff --git a/src/server/plugins/engine/components/OsGridRefField.test.ts b/src/server/plugins/engine/components/OsGridRefField.test.ts index 6f8303cae..3af774b35 100644 --- a/src/server/plugins/engine/components/OsGridRefField.test.ts +++ b/src/server/plugins/engine/components/OsGridRefField.test.ts @@ -100,46 +100,24 @@ describe('OsGridRefField', () => { const result1 = collection.validate(getFormData('SD865005')) const result2 = collection.validate(getFormData('SD 865 005')) - // Test 8-digit parcel ID format (2x4) - const result3 = collection.validate(getFormData('TQ12345678')) - const result4 = collection.validate(getFormData('TQ 1234 5678')) - - // Test 10-digit OS grid reference format (2x5) - const result5 = collection.validate(getFormData('SU1234567890')) - const result6 = collection.validate(getFormData('SU 12345 67890')) - // Test case-insensitive - const result7 = collection.validate(getFormData('nt12345678')) - - // Test various valid OS grid formats - const result8 = collection.validate(getFormData('SN 1232 1223')) // parcel ID format - const result9 = collection.validate(getFormData('SN 12324 12234')) // OS grid ref format - const result10 = collection.validate(getFormData('ST 6789 6789')) // parcel ID with different letters - const result11 = collection.validate(getFormData('SO 12345 12345')) // OS grid ref with different letters + const result3 = collection.validate(getFormData('nt123456')) expect(result1.errors).toBeUndefined() expect(result2.errors).toBeUndefined() expect(result3.errors).toBeUndefined() - expect(result4.errors).toBeUndefined() - expect(result5.errors).toBeUndefined() - expect(result6.errors).toBeUndefined() - expect(result7.errors).toBeUndefined() - expect(result8.errors).toBeUndefined() - expect(result9.errors).toBeUndefined() - expect(result10.errors).toBeUndefined() - expect(result11.errors).toBeUndefined() }) - it('formats values with spaces per GDS guidance', () => { + it('retains values with spaces per GDS guidance', () => { const result1 = collection.validate(getFormData('SD865005')) const result2 = collection.validate(getFormData('TQ 1234 5678')) const result3 = collection.validate(getFormData('SU1234567890')) const result4 = collection.validate(getFormData('TQ12345678')) - expect(result1.value.myComponent).toBe('SD 865 005') + expect(result1.value.myComponent).toBe('SD865005') expect(result2.value.myComponent).toBe('TQ 1234 5678') - expect(result3.value.myComponent).toBe('SU 12345 67890') - expect(result4.value.myComponent).toBe('TQ 1234 5678') + expect(result3.value.myComponent).toBe('SU1234567890') + expect(result4.value.myComponent).toBe('TQ12345678') }) it('adds errors for empty value', () => { @@ -289,16 +267,16 @@ describe('OsGridRefField', () => { }, assertions: [ { - input: getFormData(' TQ12345678'), - output: { value: getFormData('TQ 1234 5678') } + input: getFormData(' TQ123456'), + output: { value: getFormData('TQ123456') } }, { - input: getFormData('TQ12345678 '), - output: { value: getFormData('TQ 1234 5678') } + input: getFormData('TQ123456 '), + output: { value: getFormData('TQ123456') } }, { - input: getFormData(' TQ12345678 \n\n'), - output: { value: getFormData('TQ 1234 5678') } + input: getFormData(' TQ123456 \n\n'), + output: { value: getFormData('TQ123456') } } ] }, diff --git a/src/server/plugins/engine/components/OsGridRefField.ts b/src/server/plugins/engine/components/OsGridRefField.ts index e9d8ce598..5a27e8147 100644 --- a/src/server/plugins/engine/components/OsGridRefField.ts +++ b/src/server/plugins/engine/components/OsGridRefField.ts @@ -1,5 +1,4 @@ import { type OsGridRefFieldComponent } from '@defra/forms-model' -import type joi from 'joi' import { LocationFieldBase } from '~/src/server/plugins/engine/components/LocationFieldBase.js' @@ -9,45 +8,13 @@ export class OsGridRefField extends LocationFieldBase { protected getValidationConfig() { // Regex for OS grid references and parcel IDs // Validates specific valid OS grid letter combinations with: - // - 6 digits (e.g., SD865005 or SD 865 005) - // - 8 digits in 2 blocks of 4 (parcel ID) e.g., ST 6789 6789 - // - 10 digits in 2 blocks of 5 (OS grid reference) e.g., SO 12345 12345 - const osGridPattern = - /^(?:[sn][a-hj-z]|[to][abfglmqrvw]|h[l-z]|j[lmqrvw])\s?(?:\d{3}\s?\d{3}|\d{4}\s?\d{4}|\d{5}\s?\d{5})$/i - - // More permissive pattern for initial validation (allows spaces to be cleaned) - const initialPattern = /^[A-Za-z]{2}[\d\s]*$/ + // - 2 letters & 6 digits (e.g., SD865005 or SD 865 005) + 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}))$/ return { - pattern: initialPattern, - patternErrorMessage: `Enter a valid OS grid reference for ${this.title} like TQ123456`, - customValidation: (value: string, helpers: joi.CustomHelpers) => { - // Strip spaces from the input for processing - const cleanValue = value.replace(/\s/g, '') - const letters = cleanValue.substring(0, 2) - const numbers = cleanValue.substring(2) - - // Validate number length - if ( - numbers.length !== 6 && - numbers.length !== 8 && - numbers.length !== 10 - ) { - return helpers.error('string.pattern.base') - } - - // Format with spaces: XX 123 456, XX 1234 5678, or XX 12345 67890 - const halfLength = numbers.length / 2 - const formattedValue = `${letters} ${numbers.substring(0, halfLength)} ${numbers.substring(halfLength)}` - - // Validate the formatted value against the OS grid pattern - if (!osGridPattern.test(formattedValue)) { - return helpers.error('string.pattern.base') - } - - // Return formatted value with spaces per GDS guidance - return formattedValue - } + pattern, + patternErrorMessage: `Enter a valid OS grid reference for ${this.title} like TQ123456` } }