@defra/forms-engine-plugin 4.2.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 (98) 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/models/FormModel.js +0 -4
  44. package/.server/server/plugins/engine/models/FormModel.js.map +1 -1
  45. package/.server/server/plugins/engine/pageControllers/PageController.d.ts +1 -0
  46. package/.server/server/plugins/engine/pageControllers/PageController.js +2 -0
  47. package/.server/server/plugins/engine/pageControllers/PageController.js.map +1 -1
  48. package/.server/server/plugins/engine/pageControllers/QuestionPageController.js +2 -4
  49. package/.server/server/plugins/engine/pageControllers/QuestionPageController.js.map +1 -1
  50. package/.server/server/plugins/engine/pageControllers/helpers/state.d.ts +8 -7
  51. package/.server/server/plugins/engine/pageControllers/helpers/state.js +39 -12
  52. package/.server/server/plugins/engine/pageControllers/helpers/state.js.map +1 -1
  53. package/.server/server/plugins/engine/pageControllers/helpers/submission.js +13 -1
  54. package/.server/server/plugins/engine/pageControllers/helpers/submission.js.map +1 -1
  55. package/.server/server/plugins/engine/pageControllers/validationOptions.js +2 -1
  56. package/.server/server/plugins/engine/pageControllers/validationOptions.js.map +1 -1
  57. package/.server/server/plugins/engine/routes/index.js +8 -1
  58. package/.server/server/plugins/engine/routes/index.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/geospatial-map.js +1023 -0
  70. package/src/client/javascripts/location-map.js +94 -390
  71. package/src/client/javascripts/map.js +389 -0
  72. package/src/client/javascripts/shared.js +3 -1
  73. package/src/client/stylesheets/shared.scss +7 -0
  74. package/src/server/plugins/engine/components/ComponentBase.ts +2 -0
  75. package/src/server/plugins/engine/components/FormComponent.ts +29 -0
  76. package/src/server/plugins/engine/components/GeospatialField.test.ts +380 -0
  77. package/src/server/plugins/engine/components/GeospatialField.ts +145 -0
  78. package/src/server/plugins/engine/components/helpers/__stubs__/geospatial.ts +85 -0
  79. package/src/server/plugins/engine/components/helpers/components.test.ts +44 -0
  80. package/src/server/plugins/engine/components/helpers/components.ts +10 -0
  81. package/src/server/plugins/engine/components/helpers/geospatial.test.js +55 -0
  82. package/src/server/plugins/engine/components/helpers/geospatial.ts +93 -0
  83. package/src/server/plugins/engine/components/index.ts +1 -0
  84. package/src/server/plugins/engine/models/FormModel.ts +0 -4
  85. package/src/server/plugins/engine/pageControllers/PageController.ts +2 -0
  86. package/src/server/plugins/engine/pageControllers/QuestionPageController.ts +2 -6
  87. package/src/server/plugins/engine/pageControllers/helpers/state.test.ts +80 -16
  88. package/src/server/plugins/engine/pageControllers/helpers/state.ts +57 -17
  89. package/src/server/plugins/engine/pageControllers/helpers/submission.test.ts +74 -0
  90. package/src/server/plugins/engine/pageControllers/helpers/submission.ts +17 -1
  91. package/src/server/plugins/engine/pageControllers/validationOptions.ts +3 -1
  92. package/src/server/plugins/engine/routes/index.test.ts +4 -2
  93. package/src/server/plugins/engine/routes/index.ts +13 -1
  94. package/src/server/plugins/engine/types.ts +77 -4
  95. package/src/server/plugins/engine/views/components/geospatialfield.html +7 -0
  96. package/src/server/plugins/nunjucks/context.test.js +2 -3
  97. package/src/server/routes/types.ts +4 -2
  98. 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'
@@ -48,7 +48,6 @@ import {
48
48
  createPage,
49
49
  type PageControllerClass
50
50
  } from '~/src/server/plugins/engine/pageControllers/helpers/pages.js'
51
- import { copyNotYetValidatedState } from '~/src/server/plugins/engine/pageControllers/helpers/state.js'
52
51
  import { validationOptions as opts } from '~/src/server/plugins/engine/pageControllers/validationOptions.js'
53
52
  import * as defaultServices from '~/src/server/plugins/engine/services/index.js'
54
53
  import {
@@ -402,9 +401,6 @@ export class FormModel {
402
401
  // Add paths for navigation
403
402
  this.assignPaths(context)
404
403
 
405
- // Handle restoration of payload from say a 'save-and-exit' request
406
- copyNotYetValidatedState(request, context)
407
-
408
404
  return context
409
405
  }
410
406
 
@@ -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
 
@@ -31,10 +31,7 @@ import {
31
31
  } from '~/src/server/plugins/engine/helpers.js'
32
32
  import { type FormModel } from '~/src/server/plugins/engine/models/index.js'
33
33
  import { PageController } from '~/src/server/plugins/engine/pageControllers/PageController.js'
34
- import {
35
- clearNotYetValidatedState,
36
- prefillStateFromQueryParameters
37
- } from '~/src/server/plugins/engine/pageControllers/helpers/state.js'
34
+ import { prefillStateFromQueryParameters } from '~/src/server/plugins/engine/pageControllers/helpers/state.js'
38
35
  import {
39
36
  type AnyFormRequest,
40
37
  type FormContext,
@@ -342,8 +339,7 @@ export class QuestionPageController extends PageController {
342
339
 
343
340
  const cacheService = getCacheService(request.server)
344
341
 
345
- // Clear any 'not yet validated' state before saving to cache
346
- return cacheService.setState(request, clearNotYetValidatedState(state))
342
+ return cacheService.setState(request, state)
347
343
  }
348
344
 
349
345
  async mergeState(
@@ -1,19 +1,27 @@
1
- import { ComponentType, type Page } from '@defra/forms-model'
1
+ import { ComponentType, ControllerType, type Page } from '@defra/forms-model'
2
2
 
3
3
  import { type FormModel } from '~/src/server/plugins/engine/models/FormModel.js'
4
4
  import { type PageControllerClass } from '~/src/server/plugins/engine/pageControllers/helpers/pages.js'
5
5
  import {
6
+ checkSaveAndExitRepeater,
6
7
  copyNotYetValidatedState,
7
8
  prefillStateFromQueryParameters,
8
9
  stripParam
9
10
  } from '~/src/server/plugins/engine/pageControllers/helpers/state.js'
10
11
  import {
11
12
  type AnyFormRequest,
12
- type FormContext,
13
- type FormContextRequest
13
+ type FormContext
14
14
  } from '~/src/server/plugins/engine/types.js'
15
15
  import { type FormsService, type Services } from '~/src/server/types.js'
16
16
 
17
+ const mockGetCacheService = jest.fn()
18
+ const mockCacheService = { setState: jest.fn() }
19
+
20
+ jest.mock('~/src/server/plugins/engine/helpers.ts', () => ({
21
+ __esModule: true,
22
+ getCacheService: (...args: unknown[]) => mockGetCacheService(...args)
23
+ }))
24
+
17
25
  function buildMockPage(
18
26
  pagesOverride = {},
19
27
  stateOverride = {},
@@ -225,23 +233,26 @@ describe('State helpers', () => {
225
233
  })
226
234
 
227
235
  describe('copyNotYetValidatedState', () => {
228
- it('should ignore if no invalid state', () => {
229
- const mockRequest = {} as FormContextRequest
236
+ beforeEach(() => {
237
+ mockGetCacheService.mockReturnValue(mockCacheService)
238
+ })
239
+ it('should ignore if no invalid state', async () => {
240
+ const mockRequest = {} as AnyFormRequest
230
241
  const mockContext = {
231
242
  state: { abc: '123' },
232
243
  payload: {}
233
244
  } as unknown as FormContext
234
- copyNotYetValidatedState(mockRequest, mockContext)
245
+ await copyNotYetValidatedState(mockRequest, mockContext)
235
246
  expect(mockContext.state).toEqual({ abc: '123' })
236
247
  expect(mockContext.payload).toEqual({})
237
248
  })
238
249
 
239
- it('should ignore if wrong path', () => {
250
+ it('should ignore if wrong path', async () => {
240
251
  const mockRequest = {
241
252
  url: {
242
253
  pathname: '/form-page1'
243
254
  }
244
- } as unknown as FormContextRequest
255
+ } as unknown as AnyFormRequest
245
256
  const mockContext = {
246
257
  state: {
247
258
  abc: '123',
@@ -252,7 +263,7 @@ describe('State helpers', () => {
252
263
  },
253
264
  payload: {}
254
265
  } as unknown as FormContext
255
- copyNotYetValidatedState(mockRequest, mockContext)
266
+ await copyNotYetValidatedState(mockRequest, mockContext)
256
267
  expect(mockContext.state).toEqual({
257
268
  abc: '123',
258
269
  __stateNotYetValidated: {
@@ -263,12 +274,12 @@ describe('State helpers', () => {
263
274
  expect(mockContext.payload).toEqual({})
264
275
  })
265
276
 
266
- it('should apply if correct path', () => {
277
+ it('should apply if correct path', async () => {
267
278
  const mockRequest = {
268
279
  url: {
269
280
  pathname: '/form-page1'
270
281
  }
271
- } as unknown as FormContextRequest
282
+ } as unknown as AnyFormRequest
272
283
  const mockContext = {
273
284
  state: {
274
285
  abc: '123',
@@ -279,17 +290,70 @@ describe('State helpers', () => {
279
290
  },
280
291
  payload: {}
281
292
  } as unknown as FormContext
282
- copyNotYetValidatedState(mockRequest, mockContext)
293
+ await copyNotYetValidatedState(mockRequest, mockContext)
283
294
  expect(mockContext.state).toEqual({
284
295
  abc: '123',
285
- __stateNotYetValidated: {
286
- def: '456',
287
- __currentPagePath: '/form-page1'
288
- }
296
+ __stateNotYetValidated: undefined
289
297
  })
290
298
  expect(mockContext.payload).toEqual({
291
299
  def: '456'
292
300
  })
293
301
  })
294
302
  })
303
+
304
+ describe('checkSaveAndExitRepeater', () => {
305
+ function createMockContextWithPath(path: string) {
306
+ return {
307
+ state: {
308
+ abc: '123',
309
+ __stateNotYetValidated: {
310
+ def: '456',
311
+ __currentPagePath: path
312
+ }
313
+ },
314
+ payload: {}
315
+ } as unknown as FormContext
316
+ }
317
+
318
+ const mockModel = {
319
+ def: {
320
+ pages: [
321
+ {
322
+ controller: ControllerType.Repeat,
323
+ path: '/personal_details'
324
+ }
325
+ ]
326
+ },
327
+ basePath: 'form/preview/draft/repeater-test'
328
+ } as unknown as FormModel
329
+
330
+ it('should return undefined if url does not end in a guid', () => {
331
+ const mockContext = createMockContextWithPath(
332
+ '/form/preview/draft/repeater-test/personal_details'
333
+ )
334
+ expect(checkSaveAndExitRepeater(mockContext, mockModel)).toBeUndefined()
335
+ })
336
+
337
+ it('should return undefined if url ends in a guid but not a repeater path', () => {
338
+ const mockContext = createMockContextWithPath(
339
+ '/form/preview/draft/repeater-test/wrong_page/7d27fe6e-73e8-4265-84bd-1e118c92470b'
340
+ )
341
+ expect(checkSaveAndExitRepeater(mockContext, mockModel)).toBeUndefined()
342
+ })
343
+
344
+ it('should return undefined if url is not a string', () => {
345
+ // @ts-expect-error - invalid dataype on purpose for this test
346
+ const mockContext = createMockContextWithPath({})
347
+ expect(checkSaveAndExitRepeater(mockContext, mockModel)).toBeUndefined()
348
+ })
349
+
350
+ it('should return correct urls if url ends in a guid and is a repeater path', () => {
351
+ const mockContext = createMockContextWithPath(
352
+ '/form/preview/draft/repeater-test/personal_details/7d27fe6e-73e8-4265-84bd-1e118c92470b'
353
+ )
354
+ expect(checkSaveAndExitRepeater(mockContext, mockModel)).toBe(
355
+ '/form/preview/draft/repeater-test/personal_details/7d27fe6e-73e8-4265-84bd-1e118c92470b'
356
+ )
357
+ })
358
+ })
295
359
  })
@@ -1,21 +1,24 @@
1
- import { getHiddenFields } from '@defra/forms-model'
1
+ import { ControllerType, getHiddenFields } from '@defra/forms-model'
2
+ import { validate as isValidUUID } from 'uuid'
2
3
 
4
+ import { getCacheService } from '~/src/server/plugins/engine/helpers.js'
3
5
  import {
4
6
  CURRENT_PAGE_PATH_KEY,
5
7
  STATE_NOT_YET_VALIDATED
6
8
  } from '~/src/server/plugins/engine/index.js'
9
+ import { type FormModel } from '~/src/server/plugins/engine/models/FormModel.js'
7
10
  import { type PageControllerClass } from '~/src/server/plugins/engine/pageControllers/helpers/pages.js'
8
11
  import {
9
12
  type AnyFormRequest,
10
13
  type FormContext,
11
- type FormContextRequest,
12
14
  type FormStateValue,
13
- type FormSubmissionState,
14
15
  type FormValue
15
16
  } from '~/src/server/plugins/engine/types.js'
16
17
  import { type FormQuery } from '~/src/server/routes/types.js'
17
18
  import { type Services } from '~/src/server/types.js'
18
19
 
20
+ const GUID_LENGTH = 36
21
+
19
22
  /**
20
23
  * A series of functions that can transform a pre-fill input parameter e.g lookup a form title based on form id
21
24
  */
@@ -100,14 +103,56 @@ export async function prefillStateFromQueryParameters(
100
103
  return true
101
104
  }
102
105
 
106
+ /**
107
+ * Checks whether the save-and-exit finished on a repeater with partial state
108
+ * @param context - the form context
109
+ */
110
+ export function checkSaveAndExitRepeater(
111
+ context: FormContext,
112
+ model: FormModel
113
+ ) {
114
+ const potentiallyInvalidState = context.state[STATE_NOT_YET_VALIDATED] as
115
+ | Record<string, FormValue>
116
+ | undefined
117
+ if (!potentiallyInvalidState) {
118
+ return
119
+ }
120
+
121
+ const originalPath = potentiallyInvalidState[CURRENT_PAGE_PATH_KEY]
122
+
123
+ const repeaterPaths = model.def.pages
124
+ .filter((page) => page.controller === ControllerType.Repeat)
125
+ .map((p) => `/${model.basePath}${p.path}/`)
126
+
127
+ if (typeof originalPath !== 'string') {
128
+ return undefined
129
+ }
130
+
131
+ const segments = originalPath.split('/')
132
+ const lastSegment = segments.at(-1) ?? ''
133
+
134
+ if (!isValidUUID(lastSegment)) {
135
+ return undefined
136
+ }
137
+
138
+ const guidStartIndex = originalPath.length - GUID_LENGTH
139
+ const originalPathWithoutGuid = originalPath.substring(0, guidStartIndex)
140
+
141
+ if (!repeaterPaths.includes(originalPathWithoutGuid)) {
142
+ return undefined
143
+ }
144
+
145
+ return originalPath
146
+ }
147
+
103
148
  /**
104
149
  * Copies any potentially invalid state into the payload, and removes those values from state
105
150
  * NOTE - this method has a side-effect on 'context.state' and 'context.payload'
106
151
  * @param request - the form request
107
152
  * @param context - the form context
108
153
  */
109
- export function copyNotYetValidatedState(
110
- request: FormContextRequest,
154
+ export async function copyNotYetValidatedState(
155
+ request: AnyFormRequest,
111
156
  context: FormContext
112
157
  ) {
113
158
  const potentiallyInvalidState = context.state[STATE_NOT_YET_VALIDATED] as
@@ -125,18 +170,13 @@ export function copyNotYetValidatedState(
125
170
  ...potentiallyInvalidState,
126
171
  [CURRENT_PAGE_PATH_KEY]: undefined
127
172
  }
128
- }
129
- }
130
173
 
131
- /**
132
- * Remove any temporary 'not yet validated' state now that it's been validated
133
- * @param state - the form state
134
- */
135
- export function clearNotYetValidatedState(
136
- state: FormSubmissionState
137
- ): FormSubmissionState {
138
- if (state[STATE_NOT_YET_VALIDATED]) {
139
- state[STATE_NOT_YET_VALIDATED] = undefined
174
+ // Remove any temporary 'not yet validated' state now it's been copied to the payload
175
+ if (context.state[STATE_NOT_YET_VALIDATED]) {
176
+ context.state[STATE_NOT_YET_VALIDATED] = undefined
177
+ }
178
+
179
+ const cacheService = getCacheService(request.server)
180
+ await cacheService.setState(request, context.state)
140
181
  }
141
- return state
142
182
  }
@@ -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 =
@@ -86,7 +86,8 @@ describe('redirectOrMakeHandler', () => {
86
86
  // Reset mock model
87
87
  mockModel.getFormContext = jest.fn().mockReturnValue({
88
88
  isForceAccess: false,
89
- data: {}
89
+ data: {},
90
+ state: {}
90
91
  })
91
92
 
92
93
  // Setup mocks
@@ -225,7 +226,8 @@ describe('redirectOrMakeHandler', () => {
225
226
  it('should call makeHandler when context has force access', async () => {
226
227
  mockModel.getFormContext = jest.fn().mockReturnValue({
227
228
  isForceAccess: true,
228
- data: {}
229
+ data: {},
230
+ state: {}
229
231
  })
230
232
 
231
233
  await redirectOrMakeHandler(