@defra/forms-engine-plugin 4.0.5 → 4.0.7

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/stylesheets/application.min.css +2 -2
  2. package/.public/stylesheets/application.min.css.map +1 -1
  3. package/.server/client/stylesheets/_location-input.scss +60 -0
  4. package/.server/client/stylesheets/application.scss +1 -6
  5. package/.server/client/stylesheets/shared.scss +8 -0
  6. package/.server/server/forms/register-as-a-unicorn-breeder.yaml +28 -0
  7. package/.server/server/plugins/engine/components/ComponentBase.d.ts +1 -1
  8. package/.server/server/plugins/engine/components/ComponentBase.js.map +1 -1
  9. package/.server/server/plugins/engine/components/EastingNorthingField.d.ts +121 -0
  10. package/.server/server/plugins/engine/components/EastingNorthingField.js +166 -0
  11. package/.server/server/plugins/engine/components/EastingNorthingField.js.map +1 -0
  12. package/.server/server/plugins/engine/components/LatLongField.d.ts +121 -0
  13. package/.server/server/plugins/engine/components/LatLongField.js +164 -0
  14. package/.server/server/plugins/engine/components/LatLongField.js.map +1 -0
  15. package/.server/server/plugins/engine/components/LocationFieldBase.d.ts +134 -0
  16. package/.server/server/plugins/engine/components/LocationFieldBase.js +85 -0
  17. package/.server/server/plugins/engine/components/LocationFieldBase.js.map +1 -0
  18. package/.server/server/plugins/engine/components/LocationFieldHelpers.d.ts +108 -0
  19. package/.server/server/plugins/engine/components/LocationFieldHelpers.js +96 -0
  20. package/.server/server/plugins/engine/components/LocationFieldHelpers.js.map +1 -0
  21. package/.server/server/plugins/engine/components/NationalGridFieldNumberField.d.ts +19 -0
  22. package/.server/server/plugins/engine/components/NationalGridFieldNumberField.js +40 -0
  23. package/.server/server/plugins/engine/components/NationalGridFieldNumberField.js.map +1 -0
  24. package/.server/server/plugins/engine/components/OsGridRefField.d.ts +19 -0
  25. package/.server/server/plugins/engine/components/OsGridRefField.js +56 -0
  26. package/.server/server/plugins/engine/components/OsGridRefField.js.map +1 -0
  27. package/.server/server/plugins/engine/components/helpers/components.d.ts +3 -4
  28. package/.server/server/plugins/engine/components/helpers/components.js +21 -29
  29. package/.server/server/plugins/engine/components/helpers/components.js.map +1 -1
  30. package/.server/server/plugins/engine/components/index.d.ts +4 -0
  31. package/.server/server/plugins/engine/components/index.js +4 -0
  32. package/.server/server/plugins/engine/components/index.js.map +1 -1
  33. package/.server/server/plugins/engine/components/markdownParser.d.ts +2 -0
  34. package/.server/server/plugins/engine/components/markdownParser.js +28 -0
  35. package/.server/server/plugins/engine/components/markdownParser.js.map +1 -0
  36. package/.server/server/plugins/engine/components/types.d.ts +10 -0
  37. package/.server/server/plugins/engine/components/types.js.map +1 -1
  38. package/.server/server/plugins/engine/pageControllers/helpers/pages.js +7 -0
  39. package/.server/server/plugins/engine/pageControllers/helpers/pages.js.map +1 -1
  40. package/.server/server/plugins/engine/types/index.d.ts +1 -1
  41. package/.server/server/plugins/engine/types/index.js.map +1 -1
  42. package/.server/server/plugins/engine/types.d.ts +2 -2
  43. package/.server/server/plugins/engine/types.js.map +1 -1
  44. package/.server/server/plugins/engine/views/components/_location-field-base.html +53 -0
  45. package/.server/server/plugins/engine/views/components/eastingnorthingfield.html +5 -0
  46. package/.server/server/plugins/engine/views/components/latlongfield.html +5 -0
  47. package/.server/server/plugins/engine/views/components/nationalgridfieldnumberfield.html +13 -0
  48. package/.server/server/plugins/engine/views/components/osgridreffield.html +13 -0
  49. package/.server/server/plugins/nunjucks/filters/field.d.ts +1 -1
  50. package/package.json +3 -3
  51. package/src/client/stylesheets/_location-input.scss +60 -0
  52. package/src/client/stylesheets/application.scss +1 -6
  53. package/src/client/stylesheets/shared.scss +8 -0
  54. package/src/server/forms/register-as-a-unicorn-breeder.yaml +28 -0
  55. package/src/server/plugins/engine/components/ComponentBase.ts +1 -1
  56. package/src/server/plugins/engine/components/EastingNorthingField.test.ts +665 -0
  57. package/src/server/plugins/engine/components/EastingNorthingField.ts +224 -0
  58. package/src/server/plugins/engine/components/LatLongField.test.ts +700 -0
  59. package/src/server/plugins/engine/components/LatLongField.ts +213 -0
  60. package/src/server/plugins/engine/components/LocationFieldBase.test.ts +253 -0
  61. package/src/server/plugins/engine/components/LocationFieldBase.ts +152 -0
  62. package/src/server/plugins/engine/components/LocationFieldHelpers.test.ts +338 -0
  63. package/src/server/plugins/engine/components/LocationFieldHelpers.ts +123 -0
  64. package/src/server/plugins/engine/components/NationalGridFieldNumberField.test.ts +438 -0
  65. package/src/server/plugins/engine/components/NationalGridFieldNumberField.ts +52 -0
  66. package/src/server/plugins/engine/components/OsGridRefField.test.ts +469 -0
  67. package/src/server/plugins/engine/components/OsGridRefField.ts +71 -0
  68. package/src/server/plugins/engine/components/helpers/components.test.ts +270 -0
  69. package/src/server/plugins/engine/components/helpers/components.ts +39 -47
  70. package/src/server/plugins/engine/components/helpers/helpers.test.ts +71 -1
  71. package/src/server/plugins/engine/components/index.ts +4 -0
  72. package/src/server/plugins/engine/components/markdownParser.ts +40 -0
  73. package/src/server/plugins/engine/components/types.ts +14 -0
  74. package/src/server/plugins/engine/outputFormatters/adapter/v1.location.test.ts +356 -0
  75. package/src/server/plugins/engine/pageControllers/helpers/helpers.test.ts +4 -0
  76. package/src/server/plugins/engine/pageControllers/helpers/pages.ts +8 -0
  77. package/src/server/plugins/engine/types/index.ts +2 -0
  78. package/src/server/plugins/engine/types.ts +4 -0
  79. package/src/server/plugins/engine/views/components/_location-field-base.html +53 -0
  80. package/src/server/plugins/engine/views/components/eastingnorthingfield.html +5 -0
  81. package/src/server/plugins/engine/views/components/latlongfield.html +5 -0
  82. package/src/server/plugins/engine/views/components/nationalgridfieldnumberfield.html +13 -0
  83. package/src/server/plugins/engine/views/components/osgridreffield.html +13 -0
@@ -0,0 +1,338 @@
1
+ import { ComponentType, type LatLongFieldComponent } from '@defra/forms-model'
2
+
3
+ import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js'
4
+ import { type LatLongField } from '~/src/server/plugins/engine/components/LatLongField.js'
5
+ import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js'
6
+ import definition from '~/test/form/definitions/blank.js'
7
+
8
+ describe('LocationFieldHelpers', () => {
9
+ let model: FormModel
10
+
11
+ beforeEach(() => {
12
+ model = new FormModel(definition, {
13
+ basePath: 'test'
14
+ })
15
+ })
16
+
17
+ describe('getLocationFieldViewModel', () => {
18
+ it('should return view model with fieldset', () => {
19
+ const def: LatLongFieldComponent = {
20
+ title: 'Example lat long',
21
+ name: 'myComponent',
22
+ type: ComponentType.LatLongField,
23
+ options: {},
24
+ schema: {}
25
+ }
26
+
27
+ const collection = new ComponentCollection([def], { model })
28
+ const field = collection.fields[0] as LatLongField
29
+
30
+ const payload = {
31
+ myComponent__latitude: 51.5,
32
+ myComponent__longitude: -0.1
33
+ }
34
+
35
+ const viewModel = field.getViewModel(payload)
36
+
37
+ expect(viewModel.fieldset).toEqual({
38
+ legend: {
39
+ text: def.title,
40
+ classes: 'govuk-fieldset__legend--m'
41
+ }
42
+ })
43
+
44
+ expect(viewModel.items).toHaveLength(2)
45
+ })
46
+
47
+ it('should include instruction text in view model when provided', () => {
48
+ const def: LatLongFieldComponent = {
49
+ title: 'Example lat long',
50
+ name: 'myComponent',
51
+ type: ComponentType.LatLongField,
52
+ options: {
53
+ instructionText: 'Enter coordinates in decimal format'
54
+ },
55
+ schema: {}
56
+ }
57
+
58
+ const collection = new ComponentCollection([def], { model })
59
+ const field = collection.fields[0] as LatLongField
60
+
61
+ const payload = {
62
+ myComponent__latitude: 51.5,
63
+ myComponent__longitude: -0.1
64
+ }
65
+
66
+ const viewModel = field.getViewModel(payload)
67
+
68
+ const instructionText =
69
+ 'instructionText' in viewModel ? viewModel.instructionText : undefined
70
+ expect(instructionText).toBeTruthy()
71
+ expect(instructionText).toContain('decimal format')
72
+ })
73
+
74
+ it('should add error classes to items when component has errors', () => {
75
+ const def: LatLongFieldComponent = {
76
+ title: 'Example lat long',
77
+ name: 'myComponent',
78
+ type: ComponentType.LatLongField,
79
+ options: {},
80
+ schema: {}
81
+ }
82
+
83
+ const collection = new ComponentCollection([def], { model })
84
+ const field = collection.fields[0] as LatLongField
85
+
86
+ const payload = {
87
+ myComponent__latitude: '',
88
+ myComponent__longitude: ''
89
+ }
90
+
91
+ const errors = [
92
+ {
93
+ name: 'myComponent',
94
+ text: 'Error message',
95
+ path: ['myComponent'],
96
+ href: '#myComponent'
97
+ }
98
+ ]
99
+
100
+ const viewModel = field.getViewModel(payload, errors)
101
+
102
+ expect(viewModel.items[0]).toEqual(
103
+ expect.objectContaining({
104
+ classes: expect.stringContaining('govuk-input--error')
105
+ })
106
+ )
107
+
108
+ expect(viewModel.items[1]).toEqual(
109
+ expect.objectContaining({
110
+ classes: expect.stringContaining('govuk-input--error')
111
+ })
112
+ )
113
+ })
114
+
115
+ it('should add error classes to items when subfield has errors', () => {
116
+ const def: LatLongFieldComponent = {
117
+ title: 'Example lat long',
118
+ name: 'myComponent',
119
+ type: ComponentType.LatLongField,
120
+ options: {},
121
+ schema: {}
122
+ }
123
+
124
+ const collection = new ComponentCollection([def], { model })
125
+ const field = collection.fields[0] as LatLongField
126
+
127
+ const payload = {
128
+ myComponent__latitude: 'invalid',
129
+ myComponent__longitude: '-0.1'
130
+ }
131
+
132
+ const errors = [
133
+ {
134
+ name: 'myComponent__latitude',
135
+ text: 'Invalid latitude',
136
+ path: ['myComponent__latitude'],
137
+ href: '#myComponent__latitude'
138
+ }
139
+ ]
140
+
141
+ const viewModel = field.getViewModel(payload, errors)
142
+
143
+ expect(viewModel.items[0]).toEqual(
144
+ expect.objectContaining({
145
+ classes: expect.stringContaining('govuk-input--error')
146
+ })
147
+ )
148
+ })
149
+
150
+ it('should handle labels correctly in view model items', () => {
151
+ const def: LatLongFieldComponent = {
152
+ title: 'Example lat long',
153
+ name: 'myComponent',
154
+ type: ComponentType.LatLongField,
155
+ options: {},
156
+ schema: {}
157
+ }
158
+
159
+ const collection = new ComponentCollection([def], { model })
160
+ const field = collection.fields[0] as LatLongField
161
+
162
+ const payload = {
163
+ myComponent__latitude: '51.5',
164
+ myComponent__longitude: '-0.1'
165
+ }
166
+
167
+ const viewModel = field.getViewModel(payload)
168
+
169
+ const label = viewModel.items[0].label
170
+ expect(label).toBeDefined()
171
+ expect(label?.text).toBe('Latitude')
172
+
173
+ const labelString =
174
+ label && 'toString' in label && typeof label.toString === 'function'
175
+ ? (label as { toString: () => string }).toString()
176
+ : ''
177
+ expect(labelString).toBe('Latitude')
178
+ })
179
+
180
+ it('should use existing fieldset if provided', () => {
181
+ const def: LatLongFieldComponent = {
182
+ title: 'Example lat long',
183
+ name: 'myComponent',
184
+ type: ComponentType.LatLongField,
185
+ options: {},
186
+ schema: {}
187
+ }
188
+
189
+ const collection = new ComponentCollection([def], { model })
190
+ const field = collection.fields[0] as LatLongField
191
+
192
+ const payload = {
193
+ myComponent__latitude: 51.5,
194
+ myComponent__longitude: -0.1
195
+ }
196
+
197
+ const viewModel = field.getViewModel(payload)
198
+
199
+ expect(viewModel.fieldset).toBeDefined()
200
+ })
201
+ })
202
+
203
+ describe('createLocationFieldValidator', () => {
204
+ it('should return error when required field is empty', () => {
205
+ const def: LatLongFieldComponent = {
206
+ title: 'Example lat long',
207
+ name: 'myComponent',
208
+ type: ComponentType.LatLongField,
209
+ options: {},
210
+ schema: {}
211
+ }
212
+
213
+ const collection = new ComponentCollection([def], { model })
214
+
215
+ const payload = {
216
+ myComponent__latitude: '',
217
+ myComponent__longitude: ''
218
+ }
219
+
220
+ const result = collection.validate(payload)
221
+
222
+ expect(result.errors).toBeTruthy()
223
+ expect(result.errors?.length).toBeGreaterThan(0)
224
+ })
225
+
226
+ it('should return error when required field has invalid state', () => {
227
+ const def: LatLongFieldComponent = {
228
+ title: 'Example lat long',
229
+ name: 'myComponent',
230
+ type: ComponentType.LatLongField,
231
+ options: {
232
+ required: true
233
+ },
234
+ schema: {}
235
+ }
236
+
237
+ const collection = new ComponentCollection([def], { model })
238
+
239
+ const payload = {
240
+ myComponent__latitude: 'not_a_number',
241
+ myComponent__longitude: 'also_not_a_number'
242
+ }
243
+
244
+ const result = collection.validate(payload)
245
+
246
+ expect(result.errors).toBeTruthy()
247
+ })
248
+
249
+ it('should not return error when optional field is empty', () => {
250
+ const def: LatLongFieldComponent = {
251
+ title: 'Example lat long',
252
+ name: 'myComponent',
253
+ type: ComponentType.LatLongField,
254
+ options: {
255
+ required: false
256
+ },
257
+ schema: {}
258
+ }
259
+
260
+ const collection = new ComponentCollection([def], { model })
261
+
262
+ const payload = {
263
+ myComponent__latitude: '',
264
+ myComponent__longitude: ''
265
+ }
266
+
267
+ const result = collection.validate(payload)
268
+
269
+ expect(result.errors).toBeUndefined()
270
+ })
271
+
272
+ it('should return error when required field is partially filled', () => {
273
+ const def: LatLongFieldComponent = {
274
+ title: 'Example lat long',
275
+ name: 'myComponent',
276
+ type: ComponentType.LatLongField,
277
+ options: {},
278
+ schema: {}
279
+ }
280
+
281
+ const collection = new ComponentCollection([def], { model })
282
+
283
+ const payload = {
284
+ myComponent__latitude: '51.5',
285
+ myComponent__longitude: ''
286
+ }
287
+
288
+ const result = collection.validate(payload)
289
+
290
+ expect(result.errors).toBeTruthy()
291
+ })
292
+
293
+ it('should not return error when all required fields are filled', () => {
294
+ const def: LatLongFieldComponent = {
295
+ title: 'Example lat long',
296
+ name: 'myComponent',
297
+ type: ComponentType.LatLongField,
298
+ options: {},
299
+ schema: {}
300
+ }
301
+
302
+ const collection = new ComponentCollection([def], { model })
303
+
304
+ const payload = {
305
+ myComponent__latitude: '51.5',
306
+ myComponent__longitude: '-0.1'
307
+ }
308
+
309
+ const result = collection.validate(payload)
310
+
311
+ expect(result.errors).toBeUndefined()
312
+ })
313
+
314
+ it('should validate optional fields correctly when partially filled', () => {
315
+ const def: LatLongFieldComponent = {
316
+ title: 'Example lat long',
317
+ name: 'myComponent',
318
+ type: ComponentType.LatLongField,
319
+ options: {
320
+ required: false
321
+ },
322
+ schema: {}
323
+ }
324
+
325
+ const collection = new ComponentCollection([def], { model })
326
+
327
+ const payload = {
328
+ myComponent__latitude: '51.5',
329
+ myComponent__longitude: ''
330
+ }
331
+
332
+ const result = collection.validate(payload)
333
+
334
+ expect(result.errors).toBeTruthy()
335
+ expect(result.errors?.length).toBeGreaterThan(0)
336
+ })
337
+ })
338
+ })
@@ -0,0 +1,123 @@
1
+ import { type Context, type CustomValidator } from 'joi'
2
+
3
+ import { type EastingNorthingField } from '~/src/server/plugins/engine/components/EastingNorthingField.js'
4
+ import { isFormValue } from '~/src/server/plugins/engine/components/FormComponent.js'
5
+ import { type LatLongField } from '~/src/server/plugins/engine/components/LatLongField.js'
6
+ import { markdown } from '~/src/server/plugins/engine/components/markdownParser.js'
7
+ import {
8
+ type DateInputItem,
9
+ type Label,
10
+ type ViewModel
11
+ } from '~/src/server/plugins/engine/components/types.js'
12
+ import {
13
+ type FormPayload,
14
+ type FormSubmissionError,
15
+ type FormValue
16
+ } from '~/src/server/plugins/engine/types.js'
17
+
18
+ export type LocationField =
19
+ | InstanceType<typeof EastingNorthingField>
20
+ | InstanceType<typeof LatLongField>
21
+
22
+ export function getLocationFieldViewModel(
23
+ component: LocationField,
24
+ viewModel: ViewModel & {
25
+ label: Label
26
+ id: string
27
+ name: string
28
+ value: FormValue
29
+ },
30
+ payload: FormPayload,
31
+ errors?: FormSubmissionError[]
32
+ ) {
33
+ const { collection, name } = component
34
+ const { fieldset: existingFieldset, label } = viewModel
35
+
36
+ // Check for component errors only
37
+ const hasError = errors?.some((error) => error.name === name)
38
+
39
+ // Use the component collection to generate the subitems
40
+ const items: DateInputItem[] = collection
41
+ .getViewModel(payload, errors)
42
+ .map(({ model }): DateInputItem => {
43
+ let { label, type, value, classes, prefix, suffix, errorMessage } = model
44
+
45
+ if (label) {
46
+ label.toString = () => label.text // Use string labels
47
+ }
48
+
49
+ if (hasError || errorMessage) {
50
+ classes = `${classes ?? ''} govuk-input--error`.trim()
51
+ }
52
+
53
+ // Allow any `toString()`-able value so non-numeric
54
+ // values are shown alongside their error messages
55
+ if (!isFormValue(value)) {
56
+ value = undefined
57
+ }
58
+
59
+ return {
60
+ label,
61
+ id: model.id,
62
+ name: model.name,
63
+ type,
64
+ value,
65
+ classes,
66
+ prefix,
67
+ suffix
68
+ }
69
+ })
70
+
71
+ const fieldset = existingFieldset ?? {
72
+ legend: {
73
+ text: label.text,
74
+ classes: 'govuk-fieldset__legend--m'
75
+ }
76
+ }
77
+
78
+ const result = {
79
+ ...viewModel,
80
+ fieldset,
81
+ items
82
+ }
83
+
84
+ if (component.options.instructionText) {
85
+ return {
86
+ ...result,
87
+ instructionText: markdown.parse(component.options.instructionText, {
88
+ async: false
89
+ })
90
+ }
91
+ }
92
+
93
+ return result
94
+ }
95
+
96
+ /**
97
+ * Validator factory for location-based fields.
98
+ * This creates a validator that ensures all required fields are present.
99
+ */
100
+ export function createLocationFieldValidator(
101
+ component: LocationField
102
+ ): CustomValidator {
103
+ return (payload: FormPayload, helpers) => {
104
+ const { collection, name, options } = component
105
+
106
+ const values = component.getFormValueFromState(
107
+ component.getStateFromValidForm(payload)
108
+ )
109
+
110
+ const context: Context = {
111
+ missing: collection.keys,
112
+ key: name
113
+ }
114
+
115
+ if (!component.isState(values)) {
116
+ return options.required !== false
117
+ ? helpers.error('object.required', context)
118
+ : payload
119
+ }
120
+
121
+ return payload
122
+ }
123
+ }