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` } }