Skip to content

Commit 9162c3f

Browse files
Feature/DF-990: Map restrict countries (#377)
* Add ERSI aerial style * Add ESRI aerial to the map configuration * Sonar fixes (Reduce fn size) * Revert "Sonar fixes (Reduce fn size)" This reverts commit 6a484aa. * Bump forms-model and interactive-map * Add geospatial countries geojson route * Add map dataset country layers * Fix formatting * Fix linting warnings * Whitespace * Typo * Bump interactive-map to v0.0.22-alpha * Add tests for GeospatialField boundary errors * Add tests for geojson boundary map routes * Add tests for geospatial maps with a country boundary option set * Remove transformGeocodeRequest - not needed since v0.0.18-alpha * Refactor GeospatialField to support multiple countries and update styling in geospatial map * Bump @defra/forms-model@3.0.655 * Fix merge conflict * Fix typo in test file
1 parent 85025f5 commit 9162c3f

15 files changed

Lines changed: 40009 additions & 75 deletions

File tree

jest.config.cjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ module.exports = {
5252
'nanoid', // Supports ESM only
5353
'slug', // Supports ESM only
5454
'@defra/hapi-tracing', // Supports ESM only
55-
'geodesy' // Supports ESM only|
55+
'geodesy' // Supports ESM only
5656
].join('|')}/)`
5757
],
5858
testTimeout: 10000,

package-lock.json

Lines changed: 394 additions & 28 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,9 @@
8787
},
8888
"license": "SEE LICENSE IN LICENSE",
8989
"dependencies": {
90-
"@defra/forms-model": "^3.0.648",
90+
"@defra/forms-model": "^3.0.655",
9191
"@defra/hapi-tracing": "^1.29.0",
92-
"@defra/interactive-map": "^0.0.17-alpha",
92+
"@defra/interactive-map": "^0.0.22-alpha",
9393
"@elastic/ecs-pino-format": "^1.5.0",
9494
"@hapi/boom": "^10.0.1",
9595
"@hapi/bourne": "^3.0.0",
@@ -105,6 +105,7 @@
105105
"@hapi/wreck": "^18.1.0",
106106
"@hapi/yar": "^11.0.3",
107107
"@turf/bbox": "^7.3.4",
108+
"@turf/boolean-within": "^7.3.5",
108109
"@turf/centroid": "^7.3.4",
109110
"@types/humanize-duration": "^3.27.4",
110111
"accessible-autocomplete": "^3.0.1",

src/client/javascripts/geospatial-map.js

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ const lineFeatureProperties = {
3939
}
4040

4141
const polygonFeatureProperties = {
42-
stroke: 'rgba(0,112,60,1)',
43-
fill: 'rgba(0,112,60,0.2)',
42+
stroke: 'rgb(0, 0, 0)',
43+
fill: 'rgba(255, 221, 0, 0.2)',
4444
strokeWidth: 2
4545
}
4646

@@ -111,11 +111,47 @@ export function processGeospatial(config, geospatial, index) {
111111
const geojson = getGeoJSON(geospatialInput)
112112
const bounds = geojson.features.length ? getBoundingBox(geojson) : undefined
113113
const drawPlugin = defra.drawMLPlugin()
114+
const plugins = [drawPlugin]
115+
const country = geospatial.dataset.country
116+
117+
if (country) {
118+
// Add the country bounds as a dataset plugin to show the valid area on the map
119+
// and provide feedback to the user when they add features outside of the bounds.
120+
const datasetsPlugin = defra.datasetsMaplibrePlugin({
121+
datasets: [
122+
{
123+
id: 'invalid-area',
124+
label: 'Invalid areas',
125+
geojson: `${config.apiPath}/maps/countries.geojson?omit=${country}`,
126+
showInKey: false,
127+
showInMenu: false,
128+
style: {
129+
stroke: 'gray',
130+
strokeWidth: 1,
131+
fill: 'rgba(211,211,211,0.8)'
132+
}
133+
},
134+
{
135+
id: 'valid-area',
136+
label: 'Valid areas',
137+
geojson: `${config.apiPath}/maps/countries.geojson?only=${country}`,
138+
showInKey: false,
139+
showInMenu: false,
140+
style: {
141+
stroke: 'rgba(0,112,60,1)',
142+
strokeWidth: 1
143+
}
144+
}
145+
]
146+
})
147+
148+
plugins.push(datasetsPlugin)
149+
}
114150

115151
const initConfig = {
116152
...defaultConfig,
117153
bounds,
118-
plugins: [drawPlugin]
154+
plugins
119155
}
120156

121157
const { map, interactPlugin } = createMap(mapId, initConfig, config)

src/client/javascripts/map.js

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -240,18 +240,6 @@ export function makeTileRequestTransformer(apiPath) {
240240
}
241241
}
242242

243-
/**
244-
* Temporary transform request function to transform geocode requests. Fixed in v0.0.18 of interactive map so this is not needed when we upgrade.
245-
* @param {object} request
246-
* @param {string} request.url
247-
* @param {{ method: 'get' }} request.options
248-
* @returns {Request}
249-
*/
250-
export const transformGeocodeRequest = (request) => {
251-
const url = new URL(request.url, window.location.origin)
252-
return new Request(url.toString(), request.options)
253-
}
254-
255243
/**
256244
* Create a Defra map instance
257245
* @param {string} mapId - the map id
@@ -267,7 +255,7 @@ export function createMap(mapId, initConfig, mapsConfig) {
267255

268256
const interactPlugin = defra.interactPlugin({
269257
markerColor: { outdoor: '#ff0000', dark: '#00ff00' },
270-
interactionMode: 'marker',
258+
interactionModes: ['placeMarker'],
271259
multiSelect: false
272260
})
273261

@@ -332,7 +320,6 @@ export function createMap(mapId, initConfig, mapsConfig) {
332320
}),
333321
interactPlugin,
334322
defra.searchPlugin({
335-
transformRequest: transformGeocodeRequest,
336323
osNamesURL: `${apiPath}/geocode-proxy?query={query}`,
337324
width: '300px',
338325
showMarker: false

src/server/plugins/engine/components/GeospatialField.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,5 +376,46 @@ describe('GeospatialField', () => {
376376
})
377377
])
378378
})
379+
380+
it('getErrors formats country boundary errors', () => {
381+
const component = {
382+
title: 'Example bounded geospatial field',
383+
name: 'myComponent',
384+
type: ComponentType.GeospatialField,
385+
options: {
386+
countries: ['scotland'],
387+
required: true
388+
}
389+
} satisfies GeospatialFieldComponent
390+
391+
const collection = new ComponentCollection([component], { model })
392+
const invalidSingleState: GeospatialState = [
393+
{
394+
type: 'Feature',
395+
properties: {
396+
coordinateGridReference: 'ST 00001',
397+
centroidGridReference: 'ST 00001',
398+
description: 'Desc'
399+
},
400+
geometry: {
401+
coordinates: [-2.5723699109417737, 53.2380485215034], // Point is outside Scotland should trigger error with href to description field and custom text
402+
type: 'Point'
403+
},
404+
id: 'a'
405+
}
406+
]
407+
408+
const result = collection.validate(getFormData(invalidSingleState))
409+
const geospatialField = collection.components.at(0) as GeospatialField
410+
411+
const errors = geospatialField.getErrors(result.errors)
412+
expect(errors).toEqual([
413+
expect.objectContaining({
414+
name: 0,
415+
href: '#description_0',
416+
text: 'Location 1 must be in Scotland'
417+
})
418+
])
419+
})
379420
})
380421
})

src/server/plugins/engine/components/GeospatialField.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
FormComponent,
77
isGeospatialState
88
} from '~/src/server/plugins/engine/components/FormComponent.js'
9-
import { geospatialSchema } from '~/src/server/plugins/engine/components/helpers/geospatial.js'
9+
import { getGeospatialSchema } from '~/src/server/plugins/engine/components/helpers/geospatial.js'
1010
import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js'
1111
import {
1212
type ErrorMessageTemplateList,
@@ -31,7 +31,9 @@ export class GeospatialField extends FormComponent {
3131

3232
const { options } = def
3333

34-
let formSchema = geospatialSchema.label(this.label).required()
34+
let formSchema = getGeospatialSchema(options.countries?.at(0))
35+
.label(this.label)
36+
.required()
3537

3638
formSchema = formSchema.max(50)
3739

@@ -90,6 +92,7 @@ export class GeospatialField extends FormComponent {
9092

9193
return {
9294
...viewModel,
95+
country: this.options.countries?.at(0),
9396
value
9497
}
9598
}
@@ -101,6 +104,9 @@ export class GeospatialField extends FormComponent {
101104
if (err.name === 'description') {
102105
err.href = `#description_${err.path[1]}`
103106
err.text = `Enter description for location ${Number(err.path[1]) + 1}`
107+
} else if (typeof err.name === 'number' && err.context?.country) {
108+
err.href = `#description_${err.path[1]}`
109+
err.text = `Location ${Number(err.path[1]) + 1} must be in ${err.context.country}`
104110
}
105111
})
106112

src/server/plugins/engine/components/helpers/geospatial.test.js

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1+
import { GeospatialFieldOptionsCountryEnum } from '@defra/forms-model'
2+
13
import { validState } from '~/src/server/plugins/engine/components/helpers/__stubs__/geospatial.js'
2-
import { geospatialSchema } from '~/src/server/plugins/engine/components/helpers/geospatial.js'
4+
import { getGeospatialSchema } from '~/src/server/plugins/engine/components/helpers/geospatial.js'
5+
6+
const geospatialSchema = getGeospatialSchema()
37

48
describe('Geospatial validation helpers', () => {
59
test('it should not have errors for valid geojson object', () => {
@@ -52,4 +56,32 @@ describe('Geospatial validation helpers', () => {
5256
expect(result.error).toBeDefined()
5357
expect(result.value).toBeUndefined()
5458
})
59+
60+
test('it should be valid inside country bounds', () => {
61+
const schema = getGeospatialSchema(
62+
GeospatialFieldOptionsCountryEnum.England
63+
)
64+
65+
expect(schema.validate(validState).error).toBeUndefined()
66+
expect(schema.validate(validState.slice(1)).error).toBeUndefined()
67+
expect(schema.validate(validState.slice(2)).error).toBeUndefined()
68+
expect(schema.validate(validState.slice(3)).error).toBeUndefined()
69+
})
70+
71+
test('it should be invalid outside country bounds', () => {
72+
const schema = getGeospatialSchema(
73+
GeospatialFieldOptionsCountryEnum.Scotland
74+
)
75+
76+
expect(schema.validate(validState).error).toBeDefined()
77+
expect(schema.validate(validState.slice(1)).error).toBeDefined()
78+
expect(schema.validate(validState.slice(2)).error).toBeDefined()
79+
expect(schema.validate(validState.slice(3)).error).toBeDefined()
80+
})
81+
82+
test('it should be valid with no country bounds', () => {
83+
const schema = getGeospatialSchema()
84+
85+
expect(schema.validate(validState).error).toBeUndefined()
86+
})
5587
})

src/server/plugins/engine/components/helpers/geospatial.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,25 @@
1+
import {
2+
GeospatialFieldOptionsCountryEnum,
3+
type GeospatialFieldOptionsCountry
4+
} from '@defra/forms-model'
15
import Bourne from '@hapi/bourne'
2-
import JoiBase from 'joi'
6+
import { booleanWithin } from '@turf/boolean-within'
7+
import JoiBase, { type CustomValidator } from 'joi'
38

49
import {
510
type Coordinates,
611
type Feature,
712
type FeatureProperties,
813
type Geometry
914
} from '~/src/server/plugins/engine/types.js'
15+
import { countries } from '~/src/server/plugins/map/routes/index.js'
16+
17+
const countriesDesc: Record<GeospatialFieldOptionsCountryEnum, string> = {
18+
[GeospatialFieldOptionsCountryEnum.England]: 'England',
19+
[GeospatialFieldOptionsCountryEnum.NorthernIreland]: 'Northern Ireland',
20+
[GeospatialFieldOptionsCountryEnum.Scotland]: 'Scotland',
21+
[GeospatialFieldOptionsCountryEnum.Wales]: 'Wales'
22+
}
1023

1124
const Joi = JoiBase.extend({
1225
type: 'array',
@@ -83,11 +96,44 @@ const featureSchema = Joi.object<Feature>().keys({
8396
geometry: featureGeometrySchema
8497
})
8598

86-
export const geospatialSchema = Joi.array<Feature[]>()
99+
const geospatialSchema = Joi.array<Feature[]>()
87100
.items(featureSchema)
88101
.unique('id')
89102
.required()
90103

104+
export function getGeospatialSchema(country?: GeospatialFieldOptionsCountry) {
105+
if (!country) {
106+
return geospatialSchema
107+
}
108+
109+
const validateCountryBounds: CustomValidator = (value, helpers) => {
110+
const countryFeature = countries.features.find(
111+
(feature) => feature.id === country
112+
)
113+
114+
if (!countryFeature) {
115+
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
116+
return value
117+
}
118+
119+
const result = booleanWithin(value, countryFeature)
120+
121+
if (!result) {
122+
return helpers.error('any.custom', {
123+
country: countriesDesc[country as GeospatialFieldOptionsCountryEnum]
124+
})
125+
}
126+
127+
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
128+
return value
129+
}
130+
131+
return Joi.array<Feature[]>()
132+
.items(featureSchema.custom(validateCountryBounds))
133+
.unique('id')
134+
.required()
135+
}
136+
91137
/**
92138
* @import { CustomHelpers } from 'joi'
93139
*/
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{% from "govuk/components/textarea/macro.njk" import govukTextarea %}
22

33
{% macro GeospatialField(component) %}
4-
<div class="app-geospatial-field">
4+
<div class="app-geospatial-field" data-country="{{component.model.country}}">
55
{{ govukTextarea(component.model) }}
66
</div>
77
{% endmacro %}

0 commit comments

Comments
 (0)