Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
04d567f
Add ERSI aerial style
davidjamesstone Apr 20, 2026
df21281
Add ESRI aerial to the map configuration
davidjamesstone Apr 20, 2026
6a484aa
Sonar fixes (Reduce fn size)
davidjamesstone Apr 20, 2026
7a85318
Revert "Sonar fixes (Reduce fn size)"
davidjamesstone Apr 20, 2026
9fb78dc
Merge branch 'main' into feature/DF-990-map-restrict-countries
davidjamesstone Apr 27, 2026
e79cb2b
Bump forms-model and interactive-map
davidjamesstone Apr 28, 2026
51a766e
Add geospatial countries geojson route
davidjamesstone Apr 28, 2026
3cb5ff1
Add map dataset country layers
davidjamesstone Apr 28, 2026
d1a6ff9
Merge branch 'main' into feature/DF-990-map-restrict-countries
davidjamesstone Apr 28, 2026
50f4f3d
Fix formatting
davidjamesstone Apr 28, 2026
ffaf732
Fix linting warnings
davidjamesstone Apr 28, 2026
74f6465
Whitespace
davidjamesstone Apr 28, 2026
8207f43
Typo
davidjamesstone Apr 29, 2026
441dad8
Bump interactive-map to v0.0.22-alpha
davidjamesstone Apr 29, 2026
40ba697
Add tests for GeospatialField boundary errors
davidjamesstone Apr 29, 2026
5261afa
Add tests for geojson boundary map routes
davidjamesstone Apr 29, 2026
87a3b10
Add tests for geospatial maps with a country boundary option set
davidjamesstone Apr 29, 2026
3f23aab
Remove transformGeocodeRequest - not needed since v0.0.18-alpha
davidjamesstone Apr 29, 2026
2782a98
Refactor GeospatialField to support multiple countries and update sty…
davidjamesstone Apr 30, 2026
d40258e
Merge branch 'main' into feature/DF-990-map-restrict-countries
davidjamesstone Apr 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion jest.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ module.exports = {
'nanoid', // Supports ESM only
'slug', // Supports ESM only
'@defra/hapi-tracing', // Supports ESM only
'geodesy' // Supports ESM only|
'geodesy' // Supports ESM only
].join('|')}/)`
],
testTimeout: 10000,
Expand Down
428 changes: 397 additions & 31 deletions package-lock.json

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,9 @@
},
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"@defra/forms-model": "^3.0.648",
"@defra/forms-model": "^3.0.651",
"@defra/hapi-tracing": "^1.29.0",
"@defra/interactive-map": "^0.0.17-alpha",
"@defra/interactive-map": "^0.0.22-alpha",
"@elastic/ecs-pino-format": "^1.5.0",
"@hapi/boom": "^10.0.1",
"@hapi/bourne": "^3.0.0",
Expand All @@ -104,6 +104,7 @@
"@hapi/wreck": "^18.1.0",
"@hapi/yar": "^11.0.3",
"@turf/bbox": "^7.3.4",
"@turf/boolean-within": "^7.3.5",
"@turf/centroid": "^7.3.4",
"@types/humanize-duration": "^3.27.4",
"accessible-autocomplete": "^3.0.1",
Expand Down
42 changes: 39 additions & 3 deletions src/client/javascripts/geospatial-map.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ const lineFeatureProperties = {
}

const polygonFeatureProperties = {
stroke: 'rgba(0,112,60,1)',
fill: 'rgba(0,112,60,0.2)',
stroke: 'rgb(0, 0, 0)',
fill: 'rgba(255, 221, 0, 0.2)',
strokeWidth: 2
}

Expand Down Expand Up @@ -111,11 +111,47 @@ export function processGeospatial(config, geospatial, index) {
const geojson = getGeoJSON(geospatialInput)
const bounds = geojson.features.length ? getBoundingBox(geojson) : undefined
const drawPlugin = defra.drawMLPlugin()
const plugins = [drawPlugin]
const country = geospatial.dataset.country

if (country) {
// Add the country bounds as a dataset plugin to show the valid area on the map
// and provide feedback to the user when they add features outside of the bounds.
const datasetsPlugin = defra.datasetsMaplibrePlugin({
datasets: [
{
id: 'invalid-area',
label: 'Invalid areas',
geojson: `${config.apiPath}/maps/countries.geojson?omit=${country}`,
showInKey: false,
showInMenu: false,
style: {
stroke: 'gray',
strokeWidth: 1,
fill: 'rgba(211,211,211,0.8)'
}
},
{
id: 'valid-area',
label: 'Valid areas',
geojson: `${config.apiPath}/maps/countries.geojson?only=${country}`,
showInKey: false,
showInMenu: false,
style: {
stroke: 'rgba(0,112,60,1)',
strokeWidth: 1
}
}
]
})

plugins.push(datasetsPlugin)
}

const initConfig = {
...defaultConfig,
bounds,
plugins: [drawPlugin]
plugins
}

const { map, interactPlugin } = createMap(mapId, initConfig, config)
Expand Down
19 changes: 3 additions & 16 deletions src/client/javascripts/map.js
Original file line number Diff line number Diff line change
Expand Up @@ -240,18 +240,6 @@ export function makeTileRequestTransformer(apiPath) {
}
}

/**
* Temporary transform request function to transform geocode requests. Fixed in v0.0.18 of interactive map so this is not needed when we upgrade.
* @param {object} request
* @param {string} request.url
* @param {{ method: 'get' }} request.options
* @returns {Request}
*/
export const transformGeocodeRequest = (request) => {
const url = new URL(request.url, window.location.origin)
return new Request(url.toString(), request.options)
}

/**
* Create a Defra map instance
* @param {string} mapId - the map id
Expand All @@ -267,7 +255,7 @@ export function createMap(mapId, initConfig, mapsConfig) {

const interactPlugin = defra.interactPlugin({
markerColor: { outdoor: '#ff0000', dark: '#00ff00' },
interactionMode: 'marker',
interactionModes: ['placeMarker'],
multiSelect: false
})

Expand Down Expand Up @@ -324,15 +312,14 @@ export function createMap(mapId, initConfig, mapsConfig) {
label: 'Aerial',
url: data.VTS_AERIAL_URL,
thumbnail: `${assetPath}/interactive-map/assets/images/aerial-map-thumb.jpg`,
logo: `${assetPath}/interactive-map/assets/images/esri-logo.png`,
logoAltText: 'Powered by Esri',
logo: `${assetPath}/interactive-map/assets/images/os-logo-black.svg`,
logoAltText,
attribution: `Tiles ${String.fromCodePoint(COMPANY_SYMBOL_CODE)} Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community ${new Date().getFullYear()}`
}
]
}),
interactPlugin,
defra.searchPlugin({
transformRequest: transformGeocodeRequest,
osNamesURL: `${apiPath}/geocode-proxy?query={query}`,
width: '300px',
showMarker: false
Expand Down
41 changes: 41 additions & 0 deletions src/server/plugins/engine/components/GeospatialField.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,5 +376,46 @@ describe('GeospatialField', () => {
})
])
})

it('getErrors formats country boundary errors', () => {
const component = {
title: 'Example bounded geospatial field',
name: 'myComponent',
type: ComponentType.GeospatialField,
options: {
countries: ['scotland'],
required: true
}
} satisfies GeospatialFieldComponent

const collection = new ComponentCollection([component], { model })
const invalidSingleState: GeospatialState = [
{
type: 'Feature',
properties: {
coordinateGridReference: 'ST 00001',
centroidGridReference: 'ST 00001',
description: 'Desc'
},
geometry: {
coordinates: [-2.5723699109417737, 53.2380485215034], // Point is outsode Scotland should trigger error with href to description field and custom text
type: 'Point'
},
id: 'a'
}
]

const result = collection.validate(getFormData(invalidSingleState))
const geospatialField = collection.components.at(0) as GeospatialField

const errors = geospatialField.getErrors(result.errors)
expect(errors).toEqual([
expect.objectContaining({
name: 0,
href: '#description_0',
text: 'Location 1 must be in Scotland'
})
])
})
})
})
10 changes: 8 additions & 2 deletions src/server/plugins/engine/components/GeospatialField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
FormComponent,
isGeospatialState
} from '~/src/server/plugins/engine/components/FormComponent.js'
import { geospatialSchema } from '~/src/server/plugins/engine/components/helpers/geospatial.js'
import { getGeospatialSchema } from '~/src/server/plugins/engine/components/helpers/geospatial.js'
import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js'
import {
type ErrorMessageTemplateList,
Expand All @@ -31,7 +31,9 @@

const { options } = def

let formSchema = geospatialSchema.label(this.label).required()
let formSchema = getGeospatialSchema(options.countries?.at(0))

Check failure on line 34 in src/server/plugins/engine/components/GeospatialField.ts

View workflow job for this annotation

GitHub Actions / Build (Node 23)

Property 'countries' does not exist on type '{ required?: boolean | undefined; optionalText?: boolean | undefined; classes?: string | undefined; customValidationMessages?: LanguageMessages | undefined; instructionText?: string | undefined; } & { ...; }'.

Check failure on line 34 in src/server/plugins/engine/components/GeospatialField.ts

View workflow job for this annotation

GitHub Actions / Build (Node 22)

Property 'countries' does not exist on type '{ required?: boolean | undefined; optionalText?: boolean | undefined; classes?: string | undefined; customValidationMessages?: LanguageMessages | undefined; instructionText?: string | undefined; } & { ...; }'.

Check failure on line 34 in src/server/plugins/engine/components/GeospatialField.ts

View workflow job for this annotation

GitHub Actions / Build (Node 24)

Property 'countries' does not exist on type '{ required?: boolean | undefined; optionalText?: boolean | undefined; classes?: string | undefined; customValidationMessages?: LanguageMessages | undefined; instructionText?: string | undefined; } & { ...; }'.
.label(this.label)
.required()

formSchema = formSchema.max(50)

Expand Down Expand Up @@ -90,6 +92,7 @@

return {
...viewModel,
country: this.options.countries?.at(0),

Check failure on line 95 in src/server/plugins/engine/components/GeospatialField.ts

View workflow job for this annotation

GitHub Actions / Build (Node 23)

Property 'countries' does not exist on type '{ required?: boolean | undefined; optionalText?: boolean | undefined; classes?: string | undefined; customValidationMessages?: LanguageMessages | undefined; instructionText?: string | undefined; } & { ...; }'.

Check failure on line 95 in src/server/plugins/engine/components/GeospatialField.ts

View workflow job for this annotation

GitHub Actions / Build (Node 22)

Property 'countries' does not exist on type '{ required?: boolean | undefined; optionalText?: boolean | undefined; classes?: string | undefined; customValidationMessages?: LanguageMessages | undefined; instructionText?: string | undefined; } & { ...; }'.

Check failure on line 95 in src/server/plugins/engine/components/GeospatialField.ts

View workflow job for this annotation

GitHub Actions / Build (Node 24)

Property 'countries' does not exist on type '{ required?: boolean | undefined; optionalText?: boolean | undefined; classes?: string | undefined; customValidationMessages?: LanguageMessages | undefined; instructionText?: string | undefined; } & { ...; }'.
value
}
}
Expand All @@ -101,6 +104,9 @@
if (err.name === 'description') {
err.href = `#description_${err.path[1]}`
err.text = `Enter description for location ${Number(err.path[1]) + 1}`
} else if (typeof err.name === 'number' && err.context?.country) {
err.href = `#description_${err.path[1]}`
err.text = `Location ${Number(err.path[1]) + 1} must be in ${err.context.country}`
}
})

Expand Down
34 changes: 33 additions & 1 deletion src/server/plugins/engine/components/helpers/geospatial.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { GeospatialFieldOptionsCountryEnum } from '@defra/forms-model'

import { validState } from '~/src/server/plugins/engine/components/helpers/__stubs__/geospatial.js'
import { geospatialSchema } from '~/src/server/plugins/engine/components/helpers/geospatial.js'
import { getGeospatialSchema } from '~/src/server/plugins/engine/components/helpers/geospatial.js'

const geospatialSchema = getGeospatialSchema()

describe('Geospatial validation helpers', () => {
test('it should not have errors for valid geojson object', () => {
Expand Down Expand Up @@ -52,4 +56,32 @@ describe('Geospatial validation helpers', () => {
expect(result.error).toBeDefined()
expect(result.value).toBeUndefined()
})

test('it should be valid inside country bounds', () => {
const schema = getGeospatialSchema(
GeospatialFieldOptionsCountryEnum.England
)

expect(schema.validate(validState).error).toBeUndefined()
expect(schema.validate(validState.slice(1)).error).toBeUndefined()
expect(schema.validate(validState.slice(2)).error).toBeUndefined()
expect(schema.validate(validState.slice(3)).error).toBeUndefined()
})

test('it should be invalid outside country bounds', () => {
const schema = getGeospatialSchema(
GeospatialFieldOptionsCountryEnum.Scotland
)

expect(schema.validate(validState).error).toBeDefined()
expect(schema.validate(validState.slice(1)).error).toBeDefined()
expect(schema.validate(validState.slice(2)).error).toBeDefined()
expect(schema.validate(validState.slice(3)).error).toBeDefined()
})

test('it should be valid with no country bounds', () => {
const schema = getGeospatialSchema()

expect(schema.validate(validState).error).toBeUndefined()
})
})
50 changes: 48 additions & 2 deletions src/server/plugins/engine/components/helpers/geospatial.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,25 @@
import {
GeospatialFieldOptionsCountryEnum,
type GeospatialFieldOptionsCountry
} from '@defra/forms-model'
import Bourne from '@hapi/bourne'
import JoiBase from 'joi'
import { booleanWithin } from '@turf/boolean-within'
import JoiBase, { type CustomValidator } from 'joi'

import {
type Coordinates,
type Feature,
type FeatureProperties,
type Geometry
} from '~/src/server/plugins/engine/types.js'
import { countries } from '~/src/server/plugins/map/routes/index.js'

const countriesDesc: Record<GeospatialFieldOptionsCountryEnum, string> = {
[GeospatialFieldOptionsCountryEnum.England]: 'England',
[GeospatialFieldOptionsCountryEnum.NorthernIreland]: 'Northern Ireland',
[GeospatialFieldOptionsCountryEnum.Scotland]: 'Scotland',
[GeospatialFieldOptionsCountryEnum.Wales]: 'Wales'
}

const Joi = JoiBase.extend({
type: 'array',
Expand Down Expand Up @@ -83,11 +96,44 @@ const featureSchema = Joi.object<Feature>().keys({
geometry: featureGeometrySchema
})

export const geospatialSchema = Joi.array<Feature[]>()
const geospatialSchema = Joi.array<Feature[]>()
.items(featureSchema)
.unique('id')
.required()

export function getGeospatialSchema(country?: GeospatialFieldOptionsCountry) {
if (!country) {
return geospatialSchema
}

const validateCountryBounds: CustomValidator = (value, helpers) => {
const countryFeature = countries.features.find(
(feature) => feature.id === country
)

if (!countryFeature) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return value
}

const result = booleanWithin(value, countryFeature)

if (!result) {
return helpers.error('any.custom', {
country: countriesDesc[country as GeospatialFieldOptionsCountryEnum]
})
}

// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return value
}

return Joi.array<Feature[]>()
.items(featureSchema.custom(validateCountryBounds))
.unique('id')
.required()
}

/**
* @import { CustomHelpers } from 'joi'
*/
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{% from "govuk/components/textarea/macro.njk" import govukTextarea %}

{% macro GeospatialField(component) %}
<div class="app-geospatial-field">
<div class="app-geospatial-field" data-country="{{component.model.country}}">
{{ govukTextarea(component.model) }}
</div>
{% endmacro %}
Loading
Loading