@defra/forms-engine-plugin 4.3.0 → 4.5.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 (97) hide show
  1. package/.public/javascripts/application.min.js +1 -1
  2. package/.public/javascripts/application.min.js.map +1 -1
  3. package/.public/javascripts/shared.min.js +1 -1
  4. package/.public/javascripts/shared.min.js.map +1 -1
  5. package/.public/javascripts/vendor/accessible-autocomplete.min.js.map +1 -1
  6. package/.public/stylesheets/application.min.css +1 -1
  7. package/.public/stylesheets/application.min.css.map +1 -1
  8. package/.server/client/javascripts/file-upload.js +13 -8
  9. package/.server/client/javascripts/file-upload.js.map +1 -1
  10. package/.server/client/javascripts/geospatial-map.d.ts +189 -0
  11. package/.server/client/javascripts/geospatial-map.js +1068 -0
  12. package/.server/client/javascripts/geospatial-map.js.map +1 -0
  13. package/.server/client/javascripts/location-map.d.ts +6 -91
  14. package/.server/client/javascripts/location-map.js +78 -385
  15. package/.server/client/javascripts/location-map.js.map +1 -1
  16. package/.server/client/javascripts/map.d.ts +199 -0
  17. package/.server/client/javascripts/map.js +384 -0
  18. package/.server/client/javascripts/map.js.map +1 -0
  19. package/.server/client/javascripts/shared.d.ts +3 -1
  20. package/.server/client/javascripts/shared.js +3 -1
  21. package/.server/client/javascripts/shared.js.map +1 -1
  22. package/.server/client/stylesheets/shared.scss +7 -0
  23. package/.server/server/plugins/engine/components/ComponentBase.d.ts +1 -0
  24. package/.server/server/plugins/engine/components/ComponentBase.js +2 -0
  25. package/.server/server/plugins/engine/components/ComponentBase.js.map +1 -1
  26. package/.server/server/plugins/engine/components/FileUploadField.d.ts +3 -2
  27. package/.server/server/plugins/engine/components/FileUploadField.js +11 -3
  28. package/.server/server/plugins/engine/components/FileUploadField.js.map +1 -1
  29. package/.server/server/plugins/engine/components/FormComponent.d.ts +9 -1
  30. package/.server/server/plugins/engine/components/FormComponent.js +22 -0
  31. package/.server/server/plugins/engine/components/FormComponent.js.map +1 -1
  32. package/.server/server/plugins/engine/components/GeospatialField.d.ts +77 -0
  33. package/.server/server/plugins/engine/components/GeospatialField.js +102 -0
  34. package/.server/server/plugins/engine/components/GeospatialField.js.map +1 -0
  35. package/.server/server/plugins/engine/components/helpers/__stubs__/geospatial.d.ts +3 -0
  36. package/.server/server/plugins/engine/components/helpers/__stubs__/geospatial.js +63 -0
  37. package/.server/server/plugins/engine/components/helpers/__stubs__/geospatial.js.map +1 -0
  38. package/.server/server/plugins/engine/components/helpers/components.d.ts +1 -1
  39. package/.server/server/plugins/engine/components/helpers/components.js +7 -0
  40. package/.server/server/plugins/engine/components/helpers/components.js.map +1 -1
  41. package/.server/server/plugins/engine/components/helpers/geospatial.d.ts +6 -0
  42. package/.server/server/plugins/engine/components/helpers/geospatial.js +71 -0
  43. package/.server/server/plugins/engine/components/helpers/geospatial.js.map +1 -0
  44. package/.server/server/plugins/engine/components/helpers/geospatial.test.js +42 -0
  45. package/.server/server/plugins/engine/components/helpers/geospatial.test.js.map +1 -0
  46. package/.server/server/plugins/engine/components/index.d.ts +1 -0
  47. package/.server/server/plugins/engine/components/index.js +1 -0
  48. package/.server/server/plugins/engine/components/index.js.map +1 -1
  49. package/.server/server/plugins/engine/pageControllers/FileUploadPageController.d.ts +11 -0
  50. package/.server/server/plugins/engine/pageControllers/FileUploadPageController.js +65 -28
  51. package/.server/server/plugins/engine/pageControllers/FileUploadPageController.js.map +1 -1
  52. package/.server/server/plugins/engine/pageControllers/PageController.d.ts +1 -0
  53. package/.server/server/plugins/engine/pageControllers/PageController.js +2 -0
  54. package/.server/server/plugins/engine/pageControllers/PageController.js.map +1 -1
  55. package/.server/server/plugins/engine/pageControllers/helpers/submission.js +13 -1
  56. package/.server/server/plugins/engine/pageControllers/helpers/submission.js.map +1 -1
  57. package/.server/server/plugins/engine/pageControllers/validationOptions.js +2 -1
  58. package/.server/server/plugins/engine/pageControllers/validationOptions.js.map +1 -1
  59. package/.server/server/plugins/engine/types.d.ts +63 -2
  60. package/.server/server/plugins/engine/types.js +33 -0
  61. package/.server/server/plugins/engine/types.js.map +1 -1
  62. package/.server/server/plugins/engine/views/components/geospatialfield.html +7 -0
  63. package/.server/server/plugins/nunjucks/context.test.js.map +1 -1
  64. package/.server/server/plugins/nunjucks/filters/field.d.ts +1 -1
  65. package/.server/server/routes/types.js.map +1 -1
  66. package/.server/server/services/cacheService.js +3 -0
  67. package/.server/server/services/cacheService.js.map +1 -1
  68. package/package.json +9 -5
  69. package/src/client/javascripts/file-upload.js +12 -8
  70. package/src/client/javascripts/geospatial-map.js +1023 -0
  71. package/src/client/javascripts/location-map.js +94 -390
  72. package/src/client/javascripts/map.js +389 -0
  73. package/src/client/javascripts/shared.js +3 -1
  74. package/src/client/stylesheets/shared.scss +7 -0
  75. package/src/server/plugins/engine/components/ComponentBase.ts +2 -0
  76. package/src/server/plugins/engine/components/FileUploadField.test.ts +11 -8
  77. package/src/server/plugins/engine/components/FileUploadField.ts +14 -5
  78. package/src/server/plugins/engine/components/FormComponent.ts +29 -0
  79. package/src/server/plugins/engine/components/GeospatialField.test.ts +380 -0
  80. package/src/server/plugins/engine/components/GeospatialField.ts +145 -0
  81. package/src/server/plugins/engine/components/helpers/__stubs__/geospatial.ts +85 -0
  82. package/src/server/plugins/engine/components/helpers/components.test.ts +44 -0
  83. package/src/server/plugins/engine/components/helpers/components.ts +10 -0
  84. package/src/server/plugins/engine/components/helpers/geospatial.test.js +55 -0
  85. package/src/server/plugins/engine/components/helpers/geospatial.ts +93 -0
  86. package/src/server/plugins/engine/components/index.ts +1 -0
  87. package/src/server/plugins/engine/pageControllers/FileUploadPageController.test.ts +109 -5
  88. package/src/server/plugins/engine/pageControllers/FileUploadPageController.ts +69 -21
  89. package/src/server/plugins/engine/pageControllers/PageController.ts +2 -0
  90. package/src/server/plugins/engine/pageControllers/helpers/submission.test.ts +74 -0
  91. package/src/server/plugins/engine/pageControllers/helpers/submission.ts +17 -1
  92. package/src/server/plugins/engine/pageControllers/validationOptions.ts +3 -1
  93. package/src/server/plugins/engine/types.ts +77 -4
  94. package/src/server/plugins/engine/views/components/geospatialfield.html +7 -0
  95. package/src/server/plugins/nunjucks/context.test.js +2 -3
  96. package/src/server/routes/types.ts +4 -2
  97. 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'
@@ -795,6 +795,83 @@ describe('FileUploadPageController', () => {
795
795
  })
796
796
  })
797
797
 
798
+ it('collects all file errors into a single flash when multiple files fail', async () => {
799
+ const state = {
800
+ upload: {
801
+ [controller.path]: {
802
+ upload: {
803
+ uploadId: 'some-id',
804
+ uploadUrl: 'some-url',
805
+ statusUrl: 'some-status-url'
806
+ },
807
+ files: []
808
+ }
809
+ }
810
+ } as unknown as FormSubmissionState
811
+
812
+ const errorStatus = {
813
+ uploadStatus: UploadStatus.ready,
814
+ form: {
815
+ file: [
816
+ {
817
+ fileStatus: FileStatus.rejected,
818
+ errorMessage: 'File too large'
819
+ },
820
+ {
821
+ fileStatus: FileStatus.rejected,
822
+ errorMessage: 'Invalid file type'
823
+ }
824
+ ]
825
+ }
826
+ }
827
+
828
+ jest
829
+ .spyOn(uploadService, 'getUploadStatus')
830
+ .mockResolvedValue(errorStatus as unknown as UploadStatusResponse)
831
+
832
+ jest.spyOn(tempItemSchema, 'validate').mockReturnValue({
833
+ value: {
834
+ status: errorStatus,
835
+ uploadId: 'some-id'
836
+ },
837
+ error: undefined
838
+ } as ValidationResult)
839
+
840
+ const testController = controller as TestableFileUploadPageController
841
+
842
+ const initiateSpy = jest.spyOn(
843
+ testController,
844
+ 'initiateAndStoreNewUpload'
845
+ ) as jest.SpyInstance<
846
+ Promise<FormSubmissionState>,
847
+ [FormRequest, FormSubmissionState]
848
+ >
849
+
850
+ initiateSpy.mockResolvedValue(state)
851
+
852
+ const cacheService = getCacheService(request.server)
853
+
854
+ await controller['checkUploadStatus'](request, state, 1)
855
+
856
+ expect(cacheService.setFlash).toHaveBeenCalledTimes(1)
857
+ expect(cacheService.setFlash).toHaveBeenCalledWith(request, {
858
+ errors: [
859
+ {
860
+ path: ['fileUpload'],
861
+ href: '#fileUpload',
862
+ name: 'fileUpload',
863
+ text: 'File too large'
864
+ },
865
+ {
866
+ path: ['fileUpload'],
867
+ href: '#fileUpload',
868
+ name: 'fileUpload',
869
+ text: 'Invalid file type'
870
+ }
871
+ ]
872
+ })
873
+ })
874
+
798
875
  it('sets default error message when none provided', async () => {
799
876
  const state = {
800
877
  upload: {
@@ -859,7 +936,16 @@ describe('FileUploadPageController', () => {
859
936
 
860
937
  describe('file removal', () => {
861
938
  it('returns early when no file is removed', async () => {
862
- const files = [{ uploadId: 'file1' }, { uploadId: 'file2' }]
939
+ const files = [
940
+ {
941
+ uploadId: 'upload1',
942
+ status: { form: { file: { fileId: 'file1' } } }
943
+ },
944
+ {
945
+ uploadId: 'upload2',
946
+ status: { form: { file: { fileId: 'file2' } } }
947
+ }
948
+ ]
863
949
 
864
950
  Object.defineProperty(request, 'params', {
865
951
  value: { itemId: 'nonexistent-file' },
@@ -892,7 +978,16 @@ describe('FileUploadPageController', () => {
892
978
  })
893
979
 
894
980
  it('merges state when file is removed', async () => {
895
- const files = [{ uploadId: 'file1' }, { uploadId: 'file2' }]
981
+ const files = [
982
+ {
983
+ uploadId: 'upload1',
984
+ status: { form: { file: { fileId: 'file1' } } }
985
+ },
986
+ {
987
+ uploadId: 'upload2',
988
+ status: { form: { file: { fileId: 'file2' } } }
989
+ }
990
+ ]
896
991
 
897
992
  Object.defineProperty(request, 'params', {
898
993
  value: { itemId: 'file1' },
@@ -924,7 +1019,12 @@ describe('FileUploadPageController', () => {
924
1019
  expect(mergeStateSpy).toHaveBeenCalledWith(request, state, {
925
1020
  upload: {
926
1021
  [controller.path]: {
927
- files: [{ uploadId: 'file2' }],
1022
+ files: [
1023
+ {
1024
+ uploadId: 'upload2',
1025
+ status: { form: { file: { fileId: 'file2' } } }
1026
+ }
1027
+ ],
928
1028
  upload: {
929
1029
  uploadId: 'upload-123',
930
1030
  uploadUrl: 'some-url',
@@ -1121,11 +1221,15 @@ describe('FileUploadPageController', () => {
1121
1221
  files: [
1122
1222
  {
1123
1223
  uploadId: 'file-1',
1124
- status: { form: { file: { filename: 'file-1.pdf' } } }
1224
+ status: {
1225
+ form: { file: { fileId: 'file-1', filename: 'file-1.pdf' } }
1226
+ }
1125
1227
  },
1126
1228
  {
1127
1229
  uploadId: 'file-2',
1128
- status: { form: { file: { filename: 'file-2.pdf' } } }
1230
+ status: {
1231
+ form: { file: { fileId: 'file-2', filename: 'file-2.pdf' } }
1232
+ }
1129
1233
  }
1130
1234
  ]
1131
1235
  }
@@ -27,6 +27,7 @@ import {
27
27
  type AnyFormRequest,
28
28
  type FeaturedFormPageViewModel,
29
29
  type FileState,
30
+ type FileUpload,
30
31
  type FormContext,
31
32
  type FormContextRequest,
32
33
  type FormSubmissionError,
@@ -177,7 +178,7 @@ export class FileUploadPageController extends QuestionPageController {
177
178
  const files = this.getFilesFromState(state)
178
179
 
179
180
  const fileToRemove = files.find(
180
- ({ uploadId }) => uploadId === params.itemId
181
+ ({ status }) => status.form.file.fileId === params.itemId
181
182
  )
182
183
 
183
184
  if (!fileToRemove) {
@@ -385,39 +386,86 @@ export class FileUploadPageController extends QuestionPageController {
385
386
 
386
387
  // Only add to files state if the file validates.
387
388
  // This secures against html tampering of the file input
388
- // by adding a 'multiple' attribute or it being
389
- // changed to a simple text field or similar.
389
+ // (e.g. changing it to a simple text field or similar).
390
390
  const validationResult = tempItemSchema.validate(
391
391
  { uploadId, status: statusResponse },
392
392
  { stripUnknown: true }
393
393
  )
394
394
  const error = validationResult.error
395
- const fileState = validationResult.value as FileState
396
395
 
397
396
  if (error) {
398
397
  return this.initiateAndStoreNewUpload(request, state)
399
398
  }
400
399
 
401
- const file = fileState.status.form.file
402
- if (file.fileStatus === FileStatus.complete) {
403
- files.unshift(prepareFileState(fileState))
400
+ // CDP returns form.file as a single object for one file,
401
+ // or an array for multiple files. The Joi schema normalises
402
+ // both to an array via .single().
403
+ await this.processUploadedFiles(
404
+ request,
405
+ state,
406
+ validationResult.value,
407
+ files,
408
+ upload
409
+ )
410
+
411
+ return this.initiateAndStoreNewUpload(request, state)
412
+ }
413
+
414
+ /**
415
+ * Processes the uploaded files from a CDP status response.
416
+ * Complete files are added to state, rejected/pending files
417
+ * have their error messages flashed.
418
+ * @param request - the hapi request
419
+ * @param state - the form state
420
+ * @param validatedItem - the Joi-validated upload item
421
+ * @param files - the current files array from state
422
+ * @param upload - the current upload initiation response
423
+ */
424
+ private async processUploadedFiles(
425
+ request: AnyFormRequest,
426
+ state: FormSubmissionState,
427
+ validatedItem: FileState,
428
+ files: FileState[],
429
+ upload: UploadInitiateResponse | undefined
430
+ ) {
431
+ const { uploadId } = validatedItem
432
+ const validatedStatus = validatedItem.status
433
+ const rawFile = validatedStatus.form.file as unknown as
434
+ | FileUpload
435
+ | FileUpload[]
436
+ const uploadedFiles = Array.isArray(rawFile) ? rawFile : [rawFile]
437
+
438
+ const allErrors: FormSubmissionError[] = []
439
+
440
+ for (const file of uploadedFiles) {
441
+ if (file.fileStatus === FileStatus.complete) {
442
+ const perFileState: FileState = {
443
+ uploadId,
444
+ status: {
445
+ ...validatedStatus,
446
+ form: { file }
447
+ } as FileState['status']
448
+ }
449
+ files.unshift(prepareFileState(perFileState))
450
+ } else {
451
+ // Collect the error for rejected/pending files.
452
+ const { fileUpload } = this
453
+ const name = fileUpload.name
454
+ const text = file.errorMessage ?? 'Unknown error'
455
+ allErrors.push({ path: [name], href: `#${name}`, name, text })
456
+ }
457
+ }
458
+
459
+ if (allErrors.length) {
460
+ const cacheService = getCacheService(request.server)
461
+ cacheService.setFlash(request, { errors: allErrors })
462
+ }
463
+
464
+ if (uploadedFiles.some((f) => f.fileStatus === FileStatus.complete)) {
404
465
  await this.mergeState(request, state, {
405
466
  upload: { [this.path]: { files, upload } }
406
467
  })
407
- } else {
408
- // Flash the error message.
409
- const { fileUpload } = this
410
- const cacheService = getCacheService(request.server)
411
-
412
- const name = fileUpload.name
413
- const text = file.errorMessage ?? 'Unknown error'
414
- const errors: FormSubmissionError[] = [
415
- { path: [name], href: `#${name}`, name, text }
416
- ]
417
- cacheService.setFlash(request, { errors })
418
468
  }
419
-
420
- return this.initiateAndStoreNewUpload(request, state)
421
469
  }
422
470
 
423
471
  /**
@@ -438,7 +486,7 @@ export class FileUploadPageController extends QuestionPageController {
438
486
  const files = this.getFilesFromState(state)
439
487
 
440
488
  const filesUpdated = files.filter(
441
- ({ uploadId }) => uploadId !== params.itemId
489
+ ({ status }) => status.form.file.fileId !== params.itemId
442
490
  )
443
491
 
444
492
  if (filesUpdated.length === files.length) {
@@ -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 =