@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,380 @@
1
+ import {
2
+ ComponentType,
3
+ type GeospatialFieldComponent
4
+ } from '@defra/forms-model'
5
+
6
+ import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js'
7
+ import { type GeospatialField } from '~/src/server/plugins/engine/components/GeospatialField.js'
8
+ import {
9
+ validSingleState,
10
+ validState
11
+ } from '~/src/server/plugins/engine/components/helpers/__stubs__/geospatial.js'
12
+ import {
13
+ getAnswer,
14
+ type Field
15
+ } from '~/src/server/plugins/engine/components/helpers/components.js'
16
+ import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js'
17
+ import { type GeospatialState } from '~/src/server/plugins/engine/types.js'
18
+ import definition from '~/test/form/definitions/blank.js'
19
+ import { getFormData, getFormState } from '~/test/helpers/component-helpers.js'
20
+
21
+ describe('GeospatialField', () => {
22
+ let model: FormModel
23
+
24
+ beforeEach(() => {
25
+ model = new FormModel(definition, {
26
+ basePath: 'test'
27
+ })
28
+ })
29
+
30
+ describe('Defaults', () => {
31
+ let def: GeospatialFieldComponent
32
+ let collection: ComponentCollection
33
+ let field: Field
34
+
35
+ beforeEach(() => {
36
+ def = {
37
+ title: 'Example geospatial title',
38
+ shortDescription: 'Example geospatial',
39
+ name: 'myComponent',
40
+ type: ComponentType.GeospatialField,
41
+ options: {}
42
+ } satisfies GeospatialFieldComponent
43
+
44
+ collection = new ComponentCollection([def], { model })
45
+ field = collection.fields[0]
46
+ })
47
+
48
+ describe('Schema', () => {
49
+ it('uses component short description as label', () => {
50
+ const { formSchema } = collection
51
+ const { keys } = formSchema.describe()
52
+
53
+ expect(keys).toHaveProperty(
54
+ 'myComponent',
55
+ expect.objectContaining({
56
+ flags: expect.objectContaining({
57
+ label: 'Example geospatial'
58
+ })
59
+ })
60
+ )
61
+ })
62
+
63
+ it('uses component name as keys', () => {
64
+ const { formSchema } = collection
65
+ const { keys } = formSchema.describe()
66
+
67
+ expect(field.keys).toEqual(['myComponent'])
68
+ expect(field.collection).toBeUndefined()
69
+
70
+ for (const key of field.keys) {
71
+ expect(keys).toHaveProperty(key)
72
+ }
73
+ })
74
+
75
+ it('is required by default', () => {
76
+ const { formSchema } = collection
77
+ const { keys } = formSchema.describe()
78
+
79
+ expect(keys).toHaveProperty(
80
+ 'myComponent',
81
+ expect.objectContaining({
82
+ flags: expect.objectContaining({
83
+ presence: 'required'
84
+ })
85
+ })
86
+ )
87
+ })
88
+
89
+ it('is optional when configured', () => {
90
+ const collectionOptional = new ComponentCollection(
91
+ [{ ...def, options: { required: false } }],
92
+ { model }
93
+ )
94
+
95
+ const result = collectionOptional.validate(getFormData('[]'))
96
+ expect(result.errors).toBeUndefined()
97
+
98
+ const result2 = collectionOptional.validate(getFormData([]))
99
+ expect(result2.errors).toBeUndefined()
100
+ })
101
+
102
+ it('accepts valid values', () => {
103
+ const result1 = collection.validate(getFormData(validState))
104
+
105
+ expect(result1.errors).toBeUndefined()
106
+ })
107
+
108
+ it('adds errors for empty value', () => {
109
+ const result = collection.validate(getFormData(''))
110
+
111
+ expect(result.errors).toEqual([
112
+ expect.objectContaining({
113
+ text: 'Select example geospatial'
114
+ })
115
+ ])
116
+ })
117
+
118
+ it('adds errors for empty value given no short description', () => {
119
+ def = {
120
+ title: 'Example geospatial title',
121
+ name: 'myComponent',
122
+ type: ComponentType.GeospatialField,
123
+ options: {}
124
+ } satisfies GeospatialFieldComponent
125
+
126
+ collection = new ComponentCollection([def], { model })
127
+ const result = collection.validate(getFormData(''))
128
+
129
+ expect(result.errors).toEqual([
130
+ expect.objectContaining({
131
+ text: 'Select example geospatial title'
132
+ })
133
+ ])
134
+ })
135
+
136
+ it('adds errors for invalid values', () => {
137
+ const result1 = collection.validate(getFormData(['invalid']))
138
+ const result2 = collection.validate(
139
+ // @ts-expect-error - Allow invalid param for test
140
+ getFormData({ unknown: 'invalid' })
141
+ )
142
+
143
+ expect(result1.errors).toBeTruthy()
144
+ expect(result2.errors).toBeTruthy()
145
+ })
146
+ })
147
+
148
+ describe('State', () => {
149
+ it('returns text from single feature state', () => {
150
+ const state1 = getFormState(validSingleState)
151
+ const state2 = getFormState(null)
152
+
153
+ const answer1 = getAnswer(field, state1)
154
+ const answer2 = getAnswer(field, state2)
155
+
156
+ expect(answer1).toBe('Added 1 location')
157
+ expect(answer2).toBe('')
158
+ })
159
+
160
+ it('returns text from multiple features state', () => {
161
+ const state1 = getFormState(validState)
162
+ const state2 = getFormState(null)
163
+
164
+ const answer1 = getAnswer(field, state1)
165
+ const answer2 = getAnswer(field, state2)
166
+
167
+ expect(answer1).toBe('Added 4 locations')
168
+ expect(answer2).toBe('')
169
+ })
170
+
171
+ it('returns payload from state', () => {
172
+ const state1 = getFormState(validState)
173
+ const state2 = getFormState(null)
174
+
175
+ const payload1 = field.getFormDataFromState(state1)
176
+ const payload2 = field.getFormDataFromState(state2)
177
+
178
+ expect(payload1).toEqual(getFormData(validState))
179
+ expect(payload2).toEqual(getFormData())
180
+ })
181
+
182
+ it('returns value from state', () => {
183
+ const state1 = getFormState(validState)
184
+ const state2 = getFormState(null)
185
+
186
+ const value1 = field.getFormValueFromState(state1)
187
+ const value2 = field.getFormValueFromState(state2)
188
+
189
+ expect(value1).toBe(validState)
190
+ expect(value2).toBeUndefined()
191
+ })
192
+
193
+ it('returns context for conditions and form submission', () => {
194
+ const state1 = getFormState(validState)
195
+ const state2 = getFormState(null)
196
+
197
+ const value1 = field.getContextValueFromState(state1)
198
+ const value2 = field.getContextValueFromState(state2)
199
+
200
+ const { id: id1 } = validState[0]
201
+ const { id: id2 } = validState[1]
202
+ const { id: id3 } = validState[2]
203
+ const { id: id4 } = validState[3]
204
+
205
+ expect(value1).toEqual([id1, id2, id3, id4])
206
+ expect(value2).toBeNull()
207
+ })
208
+
209
+ it('returns state from payload', () => {
210
+ const payload1 = getFormData(validState)
211
+ const payload2 = getFormData()
212
+
213
+ const value1 = field.getStateFromValidForm(payload1)
214
+ const value2 = field.getStateFromValidForm(payload2)
215
+
216
+ expect(value1).toEqual(getFormState(validState))
217
+ expect(value2).toEqual(getFormState(null))
218
+ })
219
+ })
220
+
221
+ describe('View model', () => {
222
+ it('sets Nunjucks component defaults', () => {
223
+ const viewModel = field.getViewModel(getFormData('Geospatial'))
224
+
225
+ expect(viewModel).toEqual(
226
+ expect.objectContaining({
227
+ label: { text: def.title },
228
+ name: 'myComponent',
229
+ id: 'myComponent',
230
+ value: 'Geospatial'
231
+ })
232
+ )
233
+ })
234
+ })
235
+
236
+ describe('AllPossibleErrors', () => {
237
+ it('should return errors', () => {
238
+ const errors = field.getAllPossibleErrors()
239
+ expect(errors.baseErrors).not.toBeEmpty()
240
+ expect(errors.advancedSettingsErrors).toBeEmpty()
241
+ })
242
+ })
243
+ })
244
+
245
+ describe('Validation', () => {
246
+ describe.each([
247
+ {
248
+ description: 'Required',
249
+ component: {
250
+ title: 'Example geospatial field',
251
+ name: 'myComponent',
252
+ type: ComponentType.GeospatialField,
253
+ options: {
254
+ required: true
255
+ }
256
+ } satisfies GeospatialFieldComponent,
257
+ assertions: [
258
+ {
259
+ input: getFormData([]),
260
+ output: {
261
+ value: getFormData([]),
262
+ errors: [
263
+ expect.objectContaining({
264
+ text: 'Example geospatial field must contain at least 1 items'
265
+ })
266
+ ]
267
+ }
268
+ },
269
+ {
270
+ input: getFormData(),
271
+ output: {
272
+ value: getFormData(),
273
+ errors: [
274
+ expect.objectContaining({
275
+ text: 'Select example geospatial field'
276
+ })
277
+ ]
278
+ }
279
+ },
280
+ {
281
+ input: getFormData(validSingleState),
282
+ output: {
283
+ value: getFormData(validSingleState)
284
+ }
285
+ },
286
+ {
287
+ input: getFormData(validState),
288
+ output: {
289
+ value: getFormData(validState)
290
+ }
291
+ }
292
+ ]
293
+ },
294
+ {
295
+ description: 'Optional',
296
+ component: {
297
+ title: 'Example geospatial field',
298
+ name: 'myComponent',
299
+ type: ComponentType.GeospatialField,
300
+ options: {
301
+ required: false
302
+ }
303
+ } satisfies GeospatialFieldComponent,
304
+ assertions: [
305
+ {
306
+ input: getFormData([]),
307
+ output: {
308
+ value: getFormData([])
309
+ }
310
+ },
311
+ {
312
+ input: getFormData(),
313
+ output: {
314
+ value: getFormData(),
315
+ errors: [
316
+ expect.objectContaining({
317
+ text: 'Select example geospatial field'
318
+ })
319
+ ]
320
+ }
321
+ }
322
+ ]
323
+ }
324
+ ])('$description', ({ component: def, assertions }) => {
325
+ let collection: ComponentCollection
326
+
327
+ beforeEach(() => {
328
+ collection = new ComponentCollection([def], { model })
329
+ })
330
+
331
+ it.each([...assertions])(
332
+ 'validates custom example',
333
+ ({ input, output }) => {
334
+ const result = collection.validate(input)
335
+ expect(result).toEqual(output)
336
+ }
337
+ )
338
+ })
339
+
340
+ it('getErrors formats description errors', () => {
341
+ const component = {
342
+ title: 'Example geospatial field',
343
+ name: 'myComponent',
344
+ type: ComponentType.GeospatialField,
345
+ options: {
346
+ required: true
347
+ }
348
+ } satisfies GeospatialFieldComponent
349
+
350
+ const collection = new ComponentCollection([component], { model })
351
+ const invalidSingleState: GeospatialState = [
352
+ {
353
+ type: 'Feature',
354
+ properties: {
355
+ coordinateGridReference: 'ST 00001',
356
+ centroidGridReference: 'ST 00001',
357
+ description: '' // Missing description should trigger error with href to description field and custom text
358
+ },
359
+ geometry: {
360
+ coordinates: [-2.5723699109417737, 53.2380485215034],
361
+ type: 'Point'
362
+ },
363
+ id: 'a'
364
+ }
365
+ ]
366
+
367
+ const result = collection.validate(getFormData(invalidSingleState))
368
+ const geospatialField = collection.components.at(0) as GeospatialField
369
+
370
+ const errors = geospatialField.getErrors(result.errors)
371
+ expect(errors).toEqual([
372
+ expect.objectContaining({
373
+ name: 'description',
374
+ href: '#description_0',
375
+ text: 'Enter description for location 1'
376
+ })
377
+ ])
378
+ })
379
+ })
380
+ })
@@ -0,0 +1,145 @@
1
+ import { type GeospatialFieldComponent } from '@defra/forms-model'
2
+ import { type ArraySchema } from 'joi'
3
+
4
+ import { type ComponentBase } from '~/src/server/plugins/engine/components/ComponentBase.js'
5
+ import {
6
+ FormComponent,
7
+ isGeospatialState
8
+ } from '~/src/server/plugins/engine/components/FormComponent.js'
9
+ import { geospatialSchema } from '~/src/server/plugins/engine/components/helpers/geospatial.js'
10
+ import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js'
11
+ import {
12
+ type ErrorMessageTemplateList,
13
+ type FormPayload,
14
+ type FormState,
15
+ type FormStateValue,
16
+ type FormSubmissionError,
17
+ type FormSubmissionState,
18
+ type GeospatialState
19
+ } from '~/src/server/plugins/engine/types.js'
20
+
21
+ export class GeospatialField extends FormComponent {
22
+ declare options: GeospatialFieldComponent['options']
23
+ declare formSchema: ArraySchema<GeospatialState>
24
+ declare stateSchema: ArraySchema<GeospatialState>
25
+
26
+ constructor(
27
+ def: GeospatialFieldComponent,
28
+ props: ConstructorParameters<typeof ComponentBase>[1]
29
+ ) {
30
+ super(def, props)
31
+
32
+ const { options } = def
33
+
34
+ let formSchema = geospatialSchema.label(this.label).required()
35
+
36
+ if (options.required !== false) {
37
+ formSchema = formSchema.min(1)
38
+ }
39
+
40
+ this.formSchema = formSchema
41
+ this.stateSchema = formSchema.default(null)
42
+ this.options = options
43
+ }
44
+
45
+ getFormValueFromState(state: FormSubmissionState) {
46
+ const { name } = this
47
+ return this.getFormValue(state[name])
48
+ }
49
+
50
+ getFormValue(value?: FormStateValue | FormState) {
51
+ return this.isValue(value) ? value : undefined
52
+ }
53
+
54
+ getDisplayStringFromFormValue(features: GeospatialState | undefined): string {
55
+ if (!features?.length) {
56
+ return ''
57
+ }
58
+
59
+ const unit = features.length === 1 ? 'location' : 'locations'
60
+
61
+ return `Added ${features.length} ${unit}`
62
+ }
63
+
64
+ getDisplayStringFromState(state: FormSubmissionState) {
65
+ const features = this.getFormValueFromState(state)
66
+
67
+ return this.getDisplayStringFromFormValue(features)
68
+ }
69
+
70
+ getContextValueFromFormValue(
71
+ features: GeospatialState | undefined
72
+ ): string[] | null {
73
+ return features?.map(({ id }) => id) ?? null
74
+ }
75
+
76
+ getContextValueFromState(state: FormSubmissionState) {
77
+ const features = this.getFormValueFromState(state)
78
+
79
+ return this.getContextValueFromFormValue(features)
80
+ }
81
+
82
+ getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) {
83
+ const viewModel = super.getViewModel(payload, errors)
84
+ const value =
85
+ typeof viewModel.value === 'string'
86
+ ? viewModel.value
87
+ : JSON.stringify(viewModel.value, null, 2)
88
+
89
+ return {
90
+ ...viewModel,
91
+ value
92
+ }
93
+ }
94
+
95
+ getErrors(errors?: FormSubmissionError[]): FormSubmissionError[] | undefined {
96
+ const fieldErrors = super.getErrors(errors)
97
+
98
+ fieldErrors?.forEach((err) => {
99
+ if (err.name === 'description') {
100
+ err.href = `#description_${err.path[1]}`
101
+ err.text = `Enter description for location ${Number(err.path[1]) + 1}`
102
+ }
103
+ })
104
+
105
+ return fieldErrors
106
+ }
107
+
108
+ getViewErrors(
109
+ errors?: FormSubmissionError[]
110
+ ): FormSubmissionError[] | undefined {
111
+ return this.getErrors(errors)
112
+ }
113
+
114
+ isValue(value?: FormStateValue | FormState): value is GeospatialState {
115
+ return isGeospatialState(value)
116
+ }
117
+
118
+ /**
119
+ * For error preview page that shows all possible errors on a component
120
+ */
121
+ getAllPossibleErrors(): ErrorMessageTemplateList {
122
+ const staticErrors = GeospatialField.getAllPossibleErrors()
123
+ return {
124
+ ...staticErrors,
125
+ advancedSettingsErrors: [...staticErrors.advancedSettingsErrors]
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Static version of getAllPossibleErrors that doesn't require a component instance.
131
+ */
132
+ static getAllPossibleErrors(): ErrorMessageTemplateList {
133
+ return {
134
+ baseErrors: [
135
+ { type: 'required', template: messageTemplate.selectRequired },
136
+ {
137
+ type: 'array.min',
138
+ template: '{{#title}} must contain at least 1 items'
139
+ },
140
+ { type: 'object.invalidjson', template: messageTemplate.format }
141
+ ],
142
+ advancedSettingsErrors: []
143
+ }
144
+ }
145
+ }
@@ -0,0 +1,85 @@
1
+ import { type GeospatialState } from '~/src/server/plugins/engine/types.js'
2
+
3
+ export const validState: GeospatialState = [
4
+ {
5
+ type: 'Feature',
6
+ properties: {
7
+ description: 'My farm house',
8
+ coordinateGridReference: 'ST 00001',
9
+ centroidGridReference: 'ST 00001'
10
+ },
11
+ geometry: {
12
+ coordinates: [-2.5723699109417737, 53.2380485215034],
13
+ type: 'Point'
14
+ },
15
+ id: 'a'
16
+ },
17
+ {
18
+ type: 'Feature',
19
+ properties: {
20
+ description: 'Main gas line',
21
+ coordinateGridReference: 'ST 00001',
22
+ centroidGridReference: 'ST 00001'
23
+ },
24
+ geometry: {
25
+ coordinates: [
26
+ [-2.570496516462896, 53.239162468888566],
27
+ [-2.5722447488110447, 53.238174174285746]
28
+ ],
29
+ type: 'LineString'
30
+ },
31
+ id: 'b'
32
+ },
33
+ {
34
+ type: 'Feature',
35
+ properties: {
36
+ description: 'My Pony Paddock',
37
+ coordinateGridReference: 'ST 00001',
38
+ centroidGridReference: 'ST 00001'
39
+ },
40
+ geometry: {
41
+ coordinates: [
42
+ [
43
+ [-2.573552894955583, 53.238229751360706],
44
+ [-2.5738557065633643, 53.23812342993719],
45
+ [-2.5737507318720247, 53.23797119653088],
46
+ [-2.573411582871387, 53.23785037598134],
47
+ [-2.5727575097991178, 53.23787454011864],
48
+ [-2.572858447002119, 53.23825391528342],
49
+ [-2.573552894955583, 53.238229751360706]
50
+ ]
51
+ ],
52
+ type: 'Polygon'
53
+ },
54
+ id: 'c'
55
+ },
56
+ {
57
+ type: 'Feature',
58
+ properties: {
59
+ description: 'My farm house #2',
60
+ coordinateGridReference: 'ST 00001',
61
+ centroidGridReference: 'ST 00001'
62
+ },
63
+ geometry: {
64
+ coordinates: [-2.5724, 53.239],
65
+ type: 'Point'
66
+ },
67
+ id: 'd'
68
+ }
69
+ ]
70
+
71
+ export const validSingleState: GeospatialState = [
72
+ {
73
+ type: 'Feature',
74
+ properties: {
75
+ description: 'My farm house',
76
+ coordinateGridReference: 'ST 00001',
77
+ centroidGridReference: 'ST 00001'
78
+ },
79
+ geometry: {
80
+ coordinates: [-2.5723699109417737, 53.2380485215034],
81
+ type: 'Point'
82
+ },
83
+ id: 'a'
84
+ }
85
+ ]
@@ -2,11 +2,13 @@ import {
2
2
  ComponentType,
3
3
  type DeclarationFieldComponent,
4
4
  type EastingNorthingFieldComponent,
5
+ type GeospatialFieldComponent,
5
6
  type LatLongFieldComponent,
6
7
  type NationalGridFieldNumberFieldComponent,
7
8
  type OsGridRefFieldComponent
8
9
  } from '@defra/forms-model'
9
10
 
11
+ import { validState } from '~/src/server/plugins/engine/components/helpers/__stubs__/geospatial.js'
10
12
  import {
11
13
  getAnswer,
12
14
  getAnswerMarkdown
@@ -14,6 +16,7 @@ import {
14
16
  import {
15
17
  DeclarationField,
16
18
  EastingNorthingField,
19
+ GeospatialField,
17
20
  LatLongField,
18
21
  NationalGridFieldNumberField,
19
22
  OsGridRefField
@@ -218,6 +221,47 @@ describe('Location field formatting', () => {
218
221
  })
219
222
  })
220
223
 
224
+ describe('GeospatialField', () => {
225
+ let field: GeospatialField
226
+
227
+ beforeEach(() => {
228
+ const def: GeospatialFieldComponent = {
229
+ type: ComponentType.GeospatialField,
230
+ name: 'geoField',
231
+ title: 'Geospatial',
232
+ options: {}
233
+ }
234
+ field = new GeospatialField(def, { model })
235
+ })
236
+
237
+ it('formats for email output as single value', () => {
238
+ const state = {
239
+ geoField: validState
240
+ }
241
+
242
+ const answer = getAnswer(field, state, { format: 'email' })
243
+ expect(answer).toBe('Added 4 locations\n')
244
+ })
245
+
246
+ it('formats for data output', () => {
247
+ const state = {
248
+ geoField: validState
249
+ }
250
+
251
+ const answer = getAnswer(field, state, { format: 'data' })
252
+ expect(answer).toBe('a,b,c,d')
253
+ })
254
+
255
+ it('formats for summary display', () => {
256
+ const state = {
257
+ geoField: validState
258
+ }
259
+
260
+ const answer = getAnswer(field, state, { format: 'summary' })
261
+ expect(answer).toBe('Added 4 locations')
262
+ })
263
+ })
264
+
221
265
  describe('DeclarationField', () => {
222
266
  let field: DeclarationField
223
267
 
@@ -36,6 +36,7 @@ export type Field = InstanceType<
36
36
  | typeof Components.FileUploadField
37
37
  | typeof Components.HiddenField
38
38
  | typeof Components.PaymentField
39
+ | typeof Components.GeospatialField
39
40
  >
40
41
 
41
42
  // Guidance component instances only
@@ -196,6 +197,10 @@ export function createComponent(
196
197
  case ComponentType.PaymentField:
197
198
  component = new Components.PaymentField(def, options)
198
199
  break
200
+
201
+ case ComponentType.GeospatialField:
202
+ component = new Components.GeospatialField(def, options)
203
+ break
199
204
  }
200
205
 
201
206
  if (typeof component === 'undefined') {
@@ -332,6 +337,11 @@ export function getAnswerMarkdown(
332
337
  ) {
333
338
  const contextValue = field.getContextValueFromState(state)
334
339
  answerEscaped = contextValue ? `${contextValue}\n` : ''
340
+ } else if (field instanceof Components.GeospatialField) {
341
+ const features = field.getFormValueFromState(state)
342
+ const value = field.getDisplayStringFromFormValue(features)
343
+
344
+ answerEscaped = value ? `${value}\n` : ''
335
345
  }
336
346
 
337
347
  return answerEscaped