diff --git a/src/server/plugins/engine/components/GeospatialField.test.ts b/src/server/plugins/engine/components/GeospatialField.test.ts index 18cc87789..0a6c9e67e 100644 --- a/src/server/plugins/engine/components/GeospatialField.test.ts +++ b/src/server/plugins/engine/components/GeospatialField.test.ts @@ -261,7 +261,7 @@ describe('GeospatialField', () => { value: getFormData([]), errors: [ expect.objectContaining({ - text: 'Example geospatial field must contain at least 1 items' + text: 'Define at least 1 features' }) ] } @@ -291,6 +291,171 @@ describe('GeospatialField', () => { } ] }, + { + description: 'Required with min constraints', + component: { + title: 'Example geospatial field', + name: 'myComponent', + type: ComponentType.GeospatialField, + options: { + required: true + }, + schema: { + min: 2 + } + } satisfies GeospatialFieldComponent, + assertions: [ + { + input: getFormData([]), + output: { + value: getFormData([]), + errors: [ + expect.objectContaining({ + text: 'Define at least 2 features' + }) + ] + } + }, + { + input: getFormData(), + output: { + value: getFormData(), + errors: [ + expect.objectContaining({ + text: 'Select example geospatial field' + }) + ] + } + }, + { + input: getFormData(validSingleState), + output: { + value: getFormData(validSingleState), + errors: [ + expect.objectContaining({ + text: 'Define at least 2 features' + }) + ] + } + }, + { + input: getFormData(validState), + output: { + value: getFormData(validState) + } + } + ] + }, + { + description: 'Required with max constraints', + component: { + title: 'Example geospatial field', + name: 'myComponent', + type: ComponentType.GeospatialField, + options: { + required: true + }, + schema: { + max: 1 + } + } satisfies GeospatialFieldComponent, + assertions: [ + { + input: getFormData([]), + output: { + value: getFormData([]), + errors: [ + expect.objectContaining({ + text: 'Define at least 1 features' + }) + ] + } + }, + { + input: getFormData(), + output: { + value: getFormData(), + errors: [ + expect.objectContaining({ + text: 'Select example geospatial field' + }) + ] + } + }, + { + input: getFormData(validSingleState), + output: { + value: getFormData(validSingleState) + } + }, + { + input: getFormData(validState), + output: { + value: getFormData(validState), + errors: [ + expect.objectContaining({ + text: 'Only 1 features can be defined' + }) + ] + } + } + ] + }, + { + description: 'Required with exact length constraints', + component: { + title: 'Example geospatial field', + name: 'myComponent', + type: ComponentType.GeospatialField, + options: { + required: true + }, + schema: { + length: 1 + } + } satisfies GeospatialFieldComponent, + assertions: [ + { + input: getFormData([]), + output: { + value: getFormData([]), + errors: [ + expect.objectContaining({ + text: 'Define exactly 1 features' + }) + ] + } + }, + { + input: getFormData(), + output: { + value: getFormData(), + errors: [ + expect.objectContaining({ + text: 'Select example geospatial field' + }) + ] + } + }, + { + input: getFormData(validSingleState), + output: { + value: getFormData(validSingleState) + } + }, + { + input: getFormData(validState), + output: { + value: getFormData(validState), + errors: [ + expect.objectContaining({ + text: 'Define exactly 1 features' + }) + ] + } + } + ] + }, { description: 'Optional', component: { @@ -307,14 +472,148 @@ describe('GeospatialField', () => { output: { value: getFormData([]) } + } + ] + }, + { + description: 'Optional with min constraints', + component: { + title: 'Example geospatial field', + name: 'myComponent', + type: ComponentType.GeospatialField, + options: { + required: false + }, + schema: { + min: 2 + } + } satisfies GeospatialFieldComponent, + assertions: [ + { + input: getFormData([]), + output: { + value: getFormData([]), + errors: [ + expect.objectContaining({ + text: 'Define at least 2 features' + }) + ] + } }, { input: getFormData(), output: { - value: getFormData(), + value: getFormData() + } + }, + { + input: getFormData(validSingleState), + output: { + value: getFormData(validSingleState), errors: [ expect.objectContaining({ - text: 'Select example geospatial field' + text: 'Define at least 2 features' + }) + ] + } + }, + { + input: getFormData(validState), + output: { + value: getFormData(validState) + } + } + ] + }, + { + description: 'Optional with max constraints', + component: { + title: 'Example geospatial field', + name: 'myComponent', + type: ComponentType.GeospatialField, + options: { + required: false + }, + schema: { + max: 1 + } + } satisfies GeospatialFieldComponent, + assertions: [ + { + input: getFormData([]), + output: { + value: getFormData([]) + } + }, + { + input: getFormData(), + output: { + value: getFormData() + } + }, + { + input: getFormData(validSingleState), + output: { + value: getFormData(validSingleState) + } + }, + { + input: getFormData(validState), + output: { + value: getFormData(validState), + errors: [ + expect.objectContaining({ + text: 'Only 1 features can be defined' + }) + ] + } + } + ] + }, + { + description: 'Optional with exact length constraints', + component: { + title: 'Example geospatial field', + name: 'myComponent', + type: ComponentType.GeospatialField, + options: { + required: false + }, + schema: { + length: 1 + } + } satisfies GeospatialFieldComponent, + assertions: [ + { + input: getFormData([]), + output: { + value: getFormData([]), + errors: [ + expect.objectContaining({ + text: 'Define exactly 1 features' + }) + ] + } + }, + { + input: getFormData(), + output: { + value: getFormData() + } + }, + { + input: getFormData(validSingleState), + output: { + value: getFormData(validSingleState) + } + }, + { + input: getFormData(validState), + output: { + value: getFormData(validState), + errors: [ + expect.objectContaining({ + text: 'Define exactly 1 features' }) ] } diff --git a/src/server/plugins/engine/components/GeospatialField.ts b/src/server/plugins/engine/components/GeospatialField.ts index e3ab2f422..f629f5010 100644 --- a/src/server/plugins/engine/components/GeospatialField.ts +++ b/src/server/plugins/engine/components/GeospatialField.ts @@ -31,18 +31,21 @@ export class GeospatialField extends FormComponent { const { options } = def - let formSchema = getGeospatialSchema(options.countries?.at(0)) + const formSchema = getGeospatialSchema(def) .label(this.label) - .required() + .messages({ + 'array.min': messageTemplate.featuresMin as string, + 'array.max': messageTemplate.featuresMax as string, + 'array.length': messageTemplate.featuresLength as string + }) - formSchema = formSchema.max(50) + this.formSchema = formSchema + this.stateSchema = formSchema.default(null) - if (options.required !== false) { - formSchema = formSchema.min(1) + if (options.required === false) { + this.stateSchema = this.stateSchema.allow(null) } - this.formSchema = formSchema - this.stateSchema = formSchema.default(null) this.options = options } diff --git a/src/server/plugins/engine/components/helpers/geospatial.test.js b/src/server/plugins/engine/components/helpers/geospatial.test.js index 0ef438dcd..e5e37deac 100644 --- a/src/server/plugins/engine/components/helpers/geospatial.test.js +++ b/src/server/plugins/engine/components/helpers/geospatial.test.js @@ -1,9 +1,22 @@ -import { GeospatialFieldOptionsCountryEnum } from '@defra/forms-model' +import { + ComponentType, + GeospatialFieldOptionsCountryEnum +} from '@defra/forms-model' import { validState } from '~/src/server/plugins/engine/components/helpers/__stubs__/geospatial.js' import { getGeospatialSchema } from '~/src/server/plugins/engine/components/helpers/geospatial.js' -const geospatialSchema = getGeospatialSchema() +/** + * @type {import('@defra/forms-model').GeospatialFieldComponent} + */ +const geospatialComponent = { + name: 'geospatial', + title: 'Geospatial', + type: ComponentType.GeospatialField, + options: {} +} + +const geospatialSchema = getGeospatialSchema(geospatialComponent) describe('Geospatial validation helpers', () => { test('it should not have errors for valid geojson object', () => { @@ -39,8 +52,19 @@ describe('Geospatial validation helpers', () => { test('it should validate an empty array', () => { const result = geospatialSchema.validate('[]') + expect(result.error).toBeDefined() + expect(result.value).toBeUndefined() + }) + + test('it should validate an empty array when optional', () => { + const schema = getGeospatialSchema({ + ...geospatialComponent, + options: { required: false } + }) + const result = schema.validate('[]') + expect(result.error).toBeUndefined() - expect(result.value).toEqual([]) + expect(result.value).toBeUndefined() }) test('it should not validate an empty object', () => { @@ -58,9 +82,10 @@ describe('Geospatial validation helpers', () => { }) test('it should be valid inside country bounds', () => { - const schema = getGeospatialSchema( - GeospatialFieldOptionsCountryEnum.England - ) + const schema = getGeospatialSchema({ + ...geospatialComponent, + options: { countries: [GeospatialFieldOptionsCountryEnum.England] } + }) expect(schema.validate(validState).error).toBeUndefined() expect(schema.validate(validState.slice(1)).error).toBeUndefined() @@ -69,9 +94,10 @@ describe('Geospatial validation helpers', () => { }) test('it should be invalid outside country bounds', () => { - const schema = getGeospatialSchema( - GeospatialFieldOptionsCountryEnum.Scotland - ) + const schema = getGeospatialSchema({ + ...geospatialComponent, + options: { countries: [GeospatialFieldOptionsCountryEnum.Scotland] } + }) expect(schema.validate(validState).error).toBeDefined() expect(schema.validate(validState.slice(1)).error).toBeDefined() @@ -80,7 +106,7 @@ describe('Geospatial validation helpers', () => { }) test('it should be valid with no country bounds', () => { - const schema = getGeospatialSchema() + const schema = getGeospatialSchema(geospatialComponent) expect(schema.validate(validState).error).toBeUndefined() }) diff --git a/src/server/plugins/engine/components/helpers/geospatial.ts b/src/server/plugins/engine/components/helpers/geospatial.ts index baecedb67..6acefd840 100644 --- a/src/server/plugins/engine/components/helpers/geospatial.ts +++ b/src/server/plugins/engine/components/helpers/geospatial.ts @@ -1,5 +1,6 @@ import { GeospatialFieldOptionsCountryEnum, + type GeospatialFieldComponent, type GeospatialFieldOptionsCountry } from '@defra/forms-model' import Bourne from '@hapi/bourne' @@ -31,7 +32,8 @@ const Joi = JoiBase.extend({ from: 'string', method(value, helpers) { if (typeof value === 'string') { - if (value.trim() === '') { + const trimmed = value.trim() + if (trimmed === '' || trimmed === '[]') { return { value: undefined } @@ -96,14 +98,48 @@ const featureSchema = Joi.object().keys({ geometry: featureGeometrySchema }) -const geospatialSchema = Joi.array() - .items(featureSchema) - .unique('id') - .required() +function applySchemaConstraints( + schema: JoiBase.ArraySchema, + def: GeospatialFieldComponent +) { + const { options, schema: constraints } = def + const isOptional = options.required === false + + if (typeof constraints?.length === 'number') { + schema = schema.length(constraints.length) + } else { + if (typeof constraints?.min === 'number') { + schema = schema.min(constraints.min) + } else if (!isOptional) { + schema = schema.min(1) + } + + schema = schema.max( + typeof constraints?.max === 'number' ? constraints.max : 50 + ) + } + + if (isOptional) { + schema = schema.optional() + } else { + schema = schema.required() + } + + return schema +} + +export function getGeospatialSchema( + def: GeospatialFieldComponent +): JoiBase.ArraySchema { + const { options = {} } = def + const country: GeospatialFieldOptionsCountry | undefined = + options.countries?.at(0) -export function getGeospatialSchema(country?: GeospatialFieldOptionsCountry) { if (!country) { - return geospatialSchema + return applySchemaConstraints( + Joi.array().items(featureSchema).unique('id'), + def + ) } const validateCountryBounds: CustomValidator = (value, helpers) => { @@ -128,10 +164,12 @@ export function getGeospatialSchema(country?: GeospatialFieldOptionsCountry) { return value } - return Joi.array() - .items(featureSchema.custom(validateCountryBounds)) - .unique('id') - .required() + return applySchemaConstraints( + Joi.array() + .items(featureSchema.custom(validateCountryBounds)) + .unique('id'), + def + ) } /** diff --git a/src/server/plugins/engine/pageControllers/validationOptions.ts b/src/server/plugins/engine/pageControllers/validationOptions.ts index e81d46e60..e70bf78ec 100644 --- a/src/server/plugins/engine/pageControllers/validationOptions.ts +++ b/src/server/plugins/engine/pageControllers/validationOptions.ts @@ -64,7 +64,10 @@ export const messageTemplate: Record = { dateMax: '{{#title}} must be the same as or before {{#limit}}', arrayMin: 'Select at least {{#limit}} options from the list', arrayMax: 'Only {{#limit}} can be selected from the list', - arrayLength: 'Select only {{#limit}} options from the list' + arrayLength: 'Select only {{#limit}} options from the list', + featuresMin: 'Define at least {{#limit}} features', + featuresMax: 'Only {{#limit}} features can be defined', + featuresLength: 'Define exactly {{#limit}} features' } export const messages: LanguageMessagesExt = {