@defra/forms-engine-plugin 4.3.0 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/.public/javascripts/application.min.js.map +1 -1
  2. package/.public/javascripts/shared.min.js +1 -1
  3. package/.public/javascripts/shared.min.js.map +1 -1
  4. package/.public/javascripts/vendor/accessible-autocomplete.min.js.map +1 -1
  5. package/.public/stylesheets/application.min.css +1 -1
  6. package/.public/stylesheets/application.min.css.map +1 -1
  7. package/.server/client/javascripts/geospatial-map.d.ts +189 -0
  8. package/.server/client/javascripts/geospatial-map.js +1068 -0
  9. package/.server/client/javascripts/geospatial-map.js.map +1 -0
  10. package/.server/client/javascripts/location-map.d.ts +6 -91
  11. package/.server/client/javascripts/location-map.js +78 -385
  12. package/.server/client/javascripts/location-map.js.map +1 -1
  13. package/.server/client/javascripts/map.d.ts +199 -0
  14. package/.server/client/javascripts/map.js +384 -0
  15. package/.server/client/javascripts/map.js.map +1 -0
  16. package/.server/client/javascripts/shared.d.ts +3 -1
  17. package/.server/client/javascripts/shared.js +3 -1
  18. package/.server/client/javascripts/shared.js.map +1 -1
  19. package/.server/client/stylesheets/shared.scss +7 -0
  20. package/.server/server/plugins/engine/components/ComponentBase.d.ts +1 -0
  21. package/.server/server/plugins/engine/components/ComponentBase.js +2 -0
  22. package/.server/server/plugins/engine/components/ComponentBase.js.map +1 -1
  23. package/.server/server/plugins/engine/components/FormComponent.d.ts +9 -1
  24. package/.server/server/plugins/engine/components/FormComponent.js +22 -0
  25. package/.server/server/plugins/engine/components/FormComponent.js.map +1 -1
  26. package/.server/server/plugins/engine/components/GeospatialField.d.ts +77 -0
  27. package/.server/server/plugins/engine/components/GeospatialField.js +102 -0
  28. package/.server/server/plugins/engine/components/GeospatialField.js.map +1 -0
  29. package/.server/server/plugins/engine/components/helpers/__stubs__/geospatial.d.ts +3 -0
  30. package/.server/server/plugins/engine/components/helpers/__stubs__/geospatial.js +63 -0
  31. package/.server/server/plugins/engine/components/helpers/__stubs__/geospatial.js.map +1 -0
  32. package/.server/server/plugins/engine/components/helpers/components.d.ts +1 -1
  33. package/.server/server/plugins/engine/components/helpers/components.js +7 -0
  34. package/.server/server/plugins/engine/components/helpers/components.js.map +1 -1
  35. package/.server/server/plugins/engine/components/helpers/geospatial.d.ts +6 -0
  36. package/.server/server/plugins/engine/components/helpers/geospatial.js +71 -0
  37. package/.server/server/plugins/engine/components/helpers/geospatial.js.map +1 -0
  38. package/.server/server/plugins/engine/components/helpers/geospatial.test.js +42 -0
  39. package/.server/server/plugins/engine/components/helpers/geospatial.test.js.map +1 -0
  40. package/.server/server/plugins/engine/components/index.d.ts +1 -0
  41. package/.server/server/plugins/engine/components/index.js +1 -0
  42. package/.server/server/plugins/engine/components/index.js.map +1 -1
  43. package/.server/server/plugins/engine/pageControllers/PageController.d.ts +1 -0
  44. package/.server/server/plugins/engine/pageControllers/PageController.js +2 -0
  45. package/.server/server/plugins/engine/pageControllers/PageController.js.map +1 -1
  46. package/.server/server/plugins/engine/pageControllers/helpers/submission.js +13 -1
  47. package/.server/server/plugins/engine/pageControllers/helpers/submission.js.map +1 -1
  48. package/.server/server/plugins/engine/pageControllers/validationOptions.js +2 -1
  49. package/.server/server/plugins/engine/pageControllers/validationOptions.js.map +1 -1
  50. package/.server/server/plugins/engine/types.d.ts +63 -2
  51. package/.server/server/plugins/engine/types.js +33 -0
  52. package/.server/server/plugins/engine/types.js.map +1 -1
  53. package/.server/server/plugins/engine/views/components/geospatialfield.html +7 -0
  54. package/.server/server/plugins/nunjucks/context.test.js.map +1 -1
  55. package/.server/server/plugins/nunjucks/filters/field.d.ts +1 -1
  56. package/.server/server/routes/types.js.map +1 -1
  57. package/.server/server/services/cacheService.js +3 -0
  58. package/.server/server/services/cacheService.js.map +1 -1
  59. package/package.json +9 -5
  60. package/src/client/javascripts/geospatial-map.js +1023 -0
  61. package/src/client/javascripts/location-map.js +94 -390
  62. package/src/client/javascripts/map.js +389 -0
  63. package/src/client/javascripts/shared.js +3 -1
  64. package/src/client/stylesheets/shared.scss +7 -0
  65. package/src/server/plugins/engine/components/ComponentBase.ts +2 -0
  66. package/src/server/plugins/engine/components/FormComponent.ts +29 -0
  67. package/src/server/plugins/engine/components/GeospatialField.test.ts +380 -0
  68. package/src/server/plugins/engine/components/GeospatialField.ts +145 -0
  69. package/src/server/plugins/engine/components/helpers/__stubs__/geospatial.ts +85 -0
  70. package/src/server/plugins/engine/components/helpers/components.test.ts +44 -0
  71. package/src/server/plugins/engine/components/helpers/components.ts +10 -0
  72. package/src/server/plugins/engine/components/helpers/geospatial.test.js +55 -0
  73. package/src/server/plugins/engine/components/helpers/geospatial.ts +93 -0
  74. package/src/server/plugins/engine/components/index.ts +1 -0
  75. package/src/server/plugins/engine/pageControllers/PageController.ts +2 -0
  76. package/src/server/plugins/engine/pageControllers/helpers/submission.test.ts +74 -0
  77. package/src/server/plugins/engine/pageControllers/helpers/submission.ts +17 -1
  78. package/src/server/plugins/engine/pageControllers/validationOptions.ts +3 -1
  79. package/src/server/plugins/engine/types.ts +77 -4
  80. package/src/server/plugins/engine/views/components/geospatialfield.html +7 -0
  81. package/src/server/plugins/nunjucks/context.test.js +2 -3
  82. package/src/server/routes/types.ts +4 -2
  83. package/src/server/services/cacheService.ts +2 -0
@@ -0,0 +1,55 @@
1
+ import { validState } from '~/src/server/plugins/engine/components/helpers/__stubs__/geospatial.js'
2
+ import { geospatialSchema } from '~/src/server/plugins/engine/components/helpers/geospatial.js'
3
+
4
+ describe('Geospatial validation helpers', () => {
5
+ test('it should not have errors for valid geojson object', () => {
6
+ const result = geospatialSchema.validate(validState)
7
+
8
+ expect(result.error).toBeUndefined()
9
+ expect(result.value).toBeDefined()
10
+ expect(result.value).toHaveLength(4)
11
+ })
12
+
13
+ test('it should not have errors for valid geojson string', () => {
14
+ const result = geospatialSchema.validate(JSON.stringify(validState))
15
+
16
+ expect(result.error).toBeUndefined()
17
+ expect(result.value).toBeDefined()
18
+ expect(result.value).toHaveLength(4)
19
+ })
20
+
21
+ test('it should have errors for invalid json string', () => {
22
+ const result = geospatialSchema.validate('{')
23
+
24
+ expect(result.error).toBeDefined()
25
+ expect(result.value).toBe('{')
26
+ })
27
+
28
+ test('it should have errors for invalid geojson string', () => {
29
+ const result = geospatialSchema.validate('[')
30
+
31
+ expect(result.error).toBeDefined()
32
+ expect(result.value).toBe('[')
33
+ })
34
+
35
+ test('it should validate an empty array', () => {
36
+ const result = geospatialSchema.validate('[]')
37
+
38
+ expect(result.error).toBeUndefined()
39
+ expect(result.value).toEqual([])
40
+ })
41
+
42
+ test('it should not validate an empty object', () => {
43
+ const result = geospatialSchema.validate('{}')
44
+
45
+ expect(result.error).toBeDefined()
46
+ expect(result.value).toBeUndefined()
47
+ })
48
+
49
+ test('it should validate an empty string', () => {
50
+ const result = geospatialSchema.validate('')
51
+
52
+ expect(result.error).toBeDefined()
53
+ expect(result.value).toBeUndefined()
54
+ })
55
+ })
@@ -0,0 +1,93 @@
1
+ import Bourne from '@hapi/bourne'
2
+ import JoiBase from 'joi'
3
+
4
+ import {
5
+ type Coordinates,
6
+ type Feature,
7
+ type FeatureProperties,
8
+ type Geometry
9
+ } from '~/src/server/plugins/engine/types.js'
10
+
11
+ const Joi = JoiBase.extend({
12
+ type: 'array',
13
+ base: JoiBase.array(),
14
+ messages: {
15
+ 'object.invalidjson': '{{#label}} must be a valid json array string'
16
+ },
17
+ coerce: {
18
+ from: 'string',
19
+ method(value, helpers) {
20
+ if (typeof value === 'string') {
21
+ if (value.trim() === '') {
22
+ return {
23
+ value: undefined
24
+ }
25
+ }
26
+
27
+ try {
28
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
29
+ return { value: Bourne.parse(value) }
30
+ } catch {
31
+ const result = {
32
+ value,
33
+ errors: [helpers.error('object.invalidjson')]
34
+ }
35
+
36
+ return result
37
+ }
38
+ } else {
39
+ return {
40
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
41
+ value
42
+ }
43
+ }
44
+ }
45
+ }
46
+ }) as JoiBase.Root
47
+
48
+ const coordinatesSchema = Joi.array<Coordinates[]>()
49
+ .items(Joi.number().required(), Joi.number().required())
50
+ .required()
51
+
52
+ const featurePropertiesSchema = Joi.object<FeatureProperties>()
53
+ .keys({
54
+ description: Joi.string().required(),
55
+ coordinateGridReference: Joi.string().required(),
56
+ centroidGridReference: Joi.string().required()
57
+ })
58
+ .required()
59
+
60
+ const featureGeometrySchema = Joi.object<Geometry>().keys({
61
+ type: Joi.string().valid('Point', 'LineString', 'Polygon').required(),
62
+ coordinates: Joi.array()
63
+ .when('type', {
64
+ switch: [
65
+ { is: 'Point', then: coordinatesSchema },
66
+ {
67
+ is: 'LineString',
68
+ then: Joi.array().items(coordinatesSchema).min(2)
69
+ },
70
+ {
71
+ is: 'Polygon',
72
+ then: Joi.array().items(Joi.array().items(coordinatesSchema).min(3))
73
+ }
74
+ ]
75
+ })
76
+ .required()
77
+ })
78
+
79
+ const featureSchema = Joi.object<Feature>().keys({
80
+ id: Joi.string().required(),
81
+ type: Joi.string().valid('Feature').required(),
82
+ properties: featurePropertiesSchema,
83
+ geometry: featureGeometrySchema
84
+ })
85
+
86
+ export const geospatialSchema = Joi.array<Feature[]>()
87
+ .items(featureSchema)
88
+ .unique('id')
89
+ .required()
90
+
91
+ /**
92
+ * @import { CustomHelpers } from 'joi'
93
+ */
@@ -30,3 +30,4 @@ export { NationalGridFieldNumberField } from '~/src/server/plugins/engine/compon
30
30
  export { LatLongField } from '~/src/server/plugins/engine/components/LatLongField.js'
31
31
  export { HiddenField } from '~/src/server/plugins/engine/components/HiddenField.js'
32
32
  export { PaymentField } from '~/src/server/plugins/engine/components/PaymentField.js'
33
+ export { GeospatialField } from '~/src/server/plugins/engine/components/GeospatialField.js'
@@ -37,6 +37,7 @@ export class PageController {
37
37
  name?: string
38
38
  model: FormModel
39
39
  pageDef: Page
40
+ id?: string
40
41
  title: string
41
42
  section?: Section
42
43
  condition?: ExecutableCondition
@@ -52,6 +53,7 @@ export class PageController {
52
53
  this.name = def.name
53
54
  this.model = model
54
55
  this.pageDef = pageDef
56
+ this.id = pageDef.id
55
57
  this.title = pageDef.title
56
58
  this.events = pageDef.events
57
59
 
@@ -1,5 +1,7 @@
1
+ import { GeospatialField } from '~/src/server/plugins/engine/components/GeospatialField.js'
1
2
  import { PaymentField } from '~/src/server/plugins/engine/components/PaymentField.js'
2
3
  import { TextField } from '~/src/server/plugins/engine/components/TextField.js'
4
+ import { validSingleState } from '~/src/server/plugins/engine/components/helpers/__stubs__/geospatial.js'
3
5
  import { type DetailItemField } from '~/src/server/plugins/engine/models/types.js'
4
6
  import {
5
7
  buildMainRecords,
@@ -242,6 +244,34 @@ describe('Submission helpers', () => {
242
244
 
243
245
  expect(result).toEqual([])
244
246
  })
247
+
248
+ it('should JSON stringify GeospatialField', () => {
249
+ const mockGeospatialField = Object.create(GeospatialField.prototype)
250
+ mockGeospatialField.name = 'geospatial'
251
+
252
+ const items = [
253
+ {
254
+ name: 'geospatial',
255
+ label: 'Site features',
256
+ field: mockGeospatialField,
257
+ state: {
258
+ geospatial: validSingleState
259
+ } as FormSubmissionState
260
+ }
261
+ ] as unknown as DetailItemField[]
262
+
263
+ const result = buildMainRecords(items)
264
+
265
+ expect(result).toHaveLength(1)
266
+ expect(result).toEqual([
267
+ {
268
+ name: 'geospatial',
269
+ title: 'Site features',
270
+ value:
271
+ '[{"type":"Feature","properties":{"description":"My farm house","coordinateGridReference":"ST 00001","centroidGridReference":"ST 00001"},"geometry":{"coordinates":[-2.5723699109417737,53.2380485215034],"type":"Point"},"id":"a"}]'
272
+ }
273
+ ])
274
+ })
245
275
  })
246
276
 
247
277
  describe('buildRepeaterRecords', () => {
@@ -295,5 +325,49 @@ describe('Submission helpers', () => {
295
325
  expect(result[0].title).toBe('Addresses')
296
326
  expect(result[0].value).toHaveLength(1)
297
327
  })
328
+
329
+ it('should JSON stringify GeospatialField', () => {
330
+ const mockGeospatialField = Object.create(GeospatialField.prototype)
331
+ mockGeospatialField.name = 'geospatial'
332
+
333
+ const items = [
334
+ {
335
+ name: 'features',
336
+ label: 'Site features repeater',
337
+ subItems: [
338
+ [
339
+ {
340
+ name: 'geospatial',
341
+ label: 'Site features',
342
+ field: mockGeospatialField,
343
+ state: {
344
+ geospatial: validSingleState
345
+ } as FormSubmissionState
346
+ } as unknown as DetailItemField[]
347
+ ]
348
+ ]
349
+ }
350
+ ] as unknown as DetailItemField[]
351
+
352
+ const result = buildRepeaterRecords(items)
353
+
354
+ expect(result).toHaveLength(1)
355
+ expect(result).toEqual([
356
+ {
357
+ name: 'features',
358
+ title: 'Site features repeater',
359
+ value: [
360
+ [
361
+ {
362
+ name: 'geospatial',
363
+ title: 'Site features',
364
+ value:
365
+ '[{"type":"Feature","properties":{"description":"My farm house","coordinateGridReference":"ST 00001","centroidGridReference":"ST 00001"},"geometry":{"coordinates":[-2.5723699109417737,53.2380485215034],"type":"Point"},"id":"a"}]'
366
+ }
367
+ ]
368
+ ]
369
+ }
370
+ ])
371
+ })
298
372
  })
299
373
  })
@@ -1,5 +1,6 @@
1
1
  import { type SubmitPayload } from '@defra/forms-model'
2
2
 
3
+ import { GeospatialField } from '~/src/server/plugins/engine/components/GeospatialField.js'
3
4
  import { PaymentField } from '~/src/server/plugins/engine/components/PaymentField.js'
4
5
  import { getAnswer } from '~/src/server/plugins/engine/components/helpers/components.js'
5
6
  import {
@@ -32,6 +33,14 @@ export function buildMainRecords(items: DetailItem[]): SubmitRecord[] {
32
33
  for (const item of fieldItems) {
33
34
  if (item.field instanceof PaymentField) {
34
35
  records.push(...buildPaymentRecords(item))
36
+ } else if (item.field instanceof GeospatialField) {
37
+ // Stringify of GeoJSON is done here rather than inside `getContextValueFromState`
38
+ // so we don't incur the overhead of JSON.stringify on every request when building context
39
+ records.push({
40
+ name: item.name,
41
+ title: item.label,
42
+ value: JSON.stringify(item.field.getFormValueFromState(item.state))
43
+ })
35
44
  } else {
36
45
  records.push({
37
46
  name: item.name,
@@ -103,7 +112,14 @@ export function buildRepeaterRecords(
103
112
  detailItems.map((subItem) => ({
104
113
  name: subItem.name,
105
114
  title: subItem.label,
106
- value: getAnswer(subItem.field, subItem.state, { format: 'data' })
115
+ value:
116
+ // Stringify of GeoJSON is done here rather than inside `getContextValueFromState`
117
+ // so we don't incur the overhead of JSON.stringify on every request when building context
118
+ subItem.field instanceof GeospatialField
119
+ ? JSON.stringify(
120
+ subItem.field.getFormValueFromState(subItem.state)
121
+ )
122
+ : getAnswer(subItem.field, subItem.state, { format: 'data' })
107
123
  }))
108
124
  )
109
125
  }))
@@ -89,7 +89,9 @@ export const messages: LanguageMessagesExt = {
89
89
  'date.base': messageTemplate.dateFormat,
90
90
  'date.format': messageTemplate.dateFormat,
91
91
  'date.min': messageTemplate.dateMin,
92
- 'date.max': messageTemplate.dateMax
92
+ 'date.max': messageTemplate.dateMax,
93
+
94
+ 'object.invalidjson': messageTemplate.format
93
95
  }
94
96
 
95
97
  export const messagesPre: LanguageMessages =
@@ -94,8 +94,10 @@ export type FormSubmissionState = {
94
94
  upload?: Record<string, TempFileState>
95
95
  } & FormState
96
96
 
97
- export interface FormSubmissionError
98
- extends Pick<ValidationErrorItem, 'context' | 'path'> {
97
+ export interface FormSubmissionError extends Pick<
98
+ ValidationErrorItem,
99
+ 'context' | 'path'
100
+ > {
99
101
  href: string // e.g: '#dateField__day'
100
102
  name: string // e.g: 'dateField__day'
101
103
  text: string // e.g: 'Date field must be a real date'
@@ -125,6 +127,7 @@ export type FormValue =
125
127
  | Item['value'][]
126
128
  | UploadState
127
129
  | RepeatListState
130
+ | GeospatialState
128
131
  | undefined
129
132
 
130
133
  export type FormState = Partial<Record<string, FormStateValue>>
@@ -285,6 +288,76 @@ export interface RepeatItemState extends FormPayload {
285
288
 
286
289
  export type RepeatListState = RepeatItemState[]
287
290
 
291
+ /**
292
+ * A longitude/latitude coordinate pair in WGS84 format
293
+ * Format: [longitude, latitude]
294
+ */
295
+ export type Coordinates = [longitude: number, latitude: number]
296
+
297
+ /**
298
+ * GeoJSON Point geometry
299
+ */
300
+ export interface PointGeometry {
301
+ type: 'Point'
302
+ coordinates: Coordinates
303
+ }
304
+
305
+ /**
306
+ * GeoJSON LineString geometry
307
+ */
308
+ export interface LineStringGeometry {
309
+ type: 'LineString'
310
+ coordinates: Coordinates[]
311
+ }
312
+
313
+ /**
314
+ * GeoJSON Polygon geometry
315
+ */
316
+ export interface PolygonGeometry {
317
+ type: 'Polygon'
318
+ coordinates: Coordinates[][]
319
+ }
320
+
321
+ /**
322
+ * Supported geometry types
323
+ */
324
+ export type Geometry = PointGeometry | LineStringGeometry | PolygonGeometry
325
+
326
+ /**
327
+ * Feature metadata
328
+ */
329
+ export interface FeatureProperties {
330
+ /**
331
+ * Human-readable description of the feature
332
+ */
333
+ description: string
334
+ /**
335
+ * The OS grid reference of the first coordinate of the feature
336
+ */
337
+ coordinateGridReference?: string
338
+ /**
339
+ * The OS grid reference of the centroid of the feature
340
+ */
341
+ centroidGridReference?: string
342
+ }
343
+
344
+ /**
345
+ * A single GeoJSON Feature
346
+ */
347
+ export interface Feature {
348
+ id: string
349
+ type: 'Feature'
350
+ properties: FeatureProperties
351
+ geometry: Geometry
352
+ }
353
+
354
+ /**
355
+ * A GeoJSON FeatureCollection
356
+ */
357
+ export type FeatureCollection = Feature[]
358
+
359
+ export type GeospatialState = FeatureCollection
360
+
288
361
  export interface CheckAnswers {
289
362
  title?: ComponentText
290
363
  summaryList: SummaryList
@@ -502,6 +575,7 @@ export type RichFormValue =
502
575
  | UkAddressState
503
576
  | EastingNorthingState
504
577
  | LatLongState
578
+ | GeospatialState
505
579
 
506
580
  export interface FormAdapterSubmissionMessageData {
507
581
  main: Record<string, RichFormValue | null>
@@ -516,8 +590,7 @@ export interface FormAdapterSubmissionMessagePayload {
516
590
  result: FormAdapterSubmissionMessageResult
517
591
  }
518
592
 
519
- export interface FormAdapterSubmissionMessage
520
- extends FormAdapterSubmissionMessagePayload {
593
+ export interface FormAdapterSubmissionMessage extends FormAdapterSubmissionMessagePayload {
521
594
  messageId: string
522
595
  recordCreatedAt: Date
523
596
  }
@@ -0,0 +1,7 @@
1
+ {% from "govuk/components/textarea/macro.njk" import govukTextarea %}
2
+
3
+ {% macro GeospatialField(component) %}
4
+ <div class="app-geospatial-field">
5
+ {{ govukTextarea(component.model) }}
6
+ </div>
7
+ {% endmacro %}
@@ -33,9 +33,8 @@ describe('Nunjucks context', () => {
33
33
  const { config } = await import('~/src/config/index.js')
34
34
 
35
35
  // Import when isolated to avoid cache
36
- const { devtoolContext } = await import(
37
- '~/src/server/plugins/nunjucks/context.js'
38
- )
36
+ const { devtoolContext } =
37
+ await import('~/src/server/plugins/nunjucks/context.js')
39
38
 
40
39
  // Update config for missing manifest
41
40
  config.set('publicDir', tmpdir())
@@ -24,8 +24,10 @@ export interface FormParams extends Partial<Record<string, string>> {
24
24
  state?: FormStatus
25
25
  }
26
26
 
27
- export interface FormRequestRefs
28
- extends Omit<ReqRefDefaults, 'Params' | 'Payload' | 'Query'> {
27
+ export interface FormRequestRefs extends Omit<
28
+ ReqRefDefaults,
29
+ 'Params' | 'Payload' | 'Query'
30
+ > {
29
31
  Params: FormParams
30
32
  Payload: object | undefined
31
33
  Query: FormQuery
@@ -147,7 +147,9 @@ export class CacheService {
147
147
  throw new Error('No session ID found')
148
148
  }
149
149
 
150
+ // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style
150
151
  const state = (request.params.state as string) || ''
152
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
151
153
  const slug = (request.params.slug as string) || ''
152
154
  const key = `${request.yar.id}:${state}:${slug}:`
153
155