@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,213 @@
1
+ import { ComponentType, type LatLongFieldComponent } from '@defra/forms-model'
2
+ import { type LanguageMessages, type ObjectSchema } from 'joi'
3
+
4
+ import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js'
5
+ import {
6
+ FormComponent,
7
+ isFormState
8
+ } from '~/src/server/plugins/engine/components/FormComponent.js'
9
+ import {
10
+ createLocationFieldValidator,
11
+ getLocationFieldViewModel
12
+ } from '~/src/server/plugins/engine/components/LocationFieldHelpers.js'
13
+ import { NumberField } from '~/src/server/plugins/engine/components/NumberField.js'
14
+ import { type LatLongState } from '~/src/server/plugins/engine/components/types.js'
15
+ import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js'
16
+ import {
17
+ type ErrorMessageTemplateList,
18
+ type FormPayload,
19
+ type FormState,
20
+ type FormStateValue,
21
+ type FormSubmissionError,
22
+ type FormSubmissionState
23
+ } from '~/src/server/plugins/engine/types.js'
24
+ import { convertToLanguageMessages } from '~/src/server/utils/type-utils.js'
25
+
26
+ export class LatLongField extends FormComponent {
27
+ declare options: LatLongFieldComponent['options']
28
+ declare formSchema: ObjectSchema<FormPayload>
29
+ declare stateSchema: ObjectSchema<FormState>
30
+ declare collection: ComponentCollection
31
+
32
+ constructor(
33
+ def: LatLongFieldComponent,
34
+ props: ConstructorParameters<typeof FormComponent>[1]
35
+ ) {
36
+ super(def, props)
37
+
38
+ const { name, options, schema } = def
39
+
40
+ const isRequired = options.required !== false
41
+
42
+ // Read schema values from def.schema with fallback defaults
43
+ const latitudeMin = schema?.latitude?.min ?? 49
44
+ const latitudeMax = schema?.latitude?.max ?? 60
45
+ const longitudeMin = schema?.longitude?.min ?? -9
46
+ const longitudeMax = schema?.longitude?.max ?? 2
47
+
48
+ const customValidationMessages: LanguageMessages =
49
+ convertToLanguageMessages({
50
+ 'any.required': messageTemplate.objectMissing,
51
+ 'number.base': messageTemplate.objectMissing,
52
+ 'number.precision':
53
+ '{{#label}} must have no more than 7 decimal places',
54
+ 'number.unsafe': '{{#label}} must be a valid number'
55
+ })
56
+
57
+ const latitudeMessages: LanguageMessages = convertToLanguageMessages({
58
+ ...customValidationMessages,
59
+ 'number.base': `Enter a valid latitude for ${this.title} like 51.519450`,
60
+ 'number.min': `Latitude for ${this.title} must be between ${latitudeMin} and ${latitudeMax}`,
61
+ 'number.max': `Latitude for ${this.title} must be between ${latitudeMin} and ${latitudeMax}`
62
+ })
63
+
64
+ const longitudeMessages: LanguageMessages = convertToLanguageMessages({
65
+ ...customValidationMessages,
66
+ 'number.base': `Enter a valid longitude for ${this.title} like -0.127758`,
67
+ 'number.min': `Longitude for ${this.title} must be between ${longitudeMin} and ${longitudeMax}`,
68
+ 'number.max': `Longitude for ${this.title} must be between ${longitudeMin} and ${longitudeMax}`
69
+ })
70
+
71
+ this.collection = new ComponentCollection(
72
+ [
73
+ {
74
+ type: ComponentType.NumberField,
75
+ name: `${name}__latitude`,
76
+ title: 'Latitude',
77
+ schema: { min: latitudeMin, max: latitudeMax, precision: 7 },
78
+ options: {
79
+ required: isRequired,
80
+ optionalText: true,
81
+ classes: 'govuk-input--width-10',
82
+ suffix: '°',
83
+ customValidationMessages: latitudeMessages
84
+ }
85
+ },
86
+ {
87
+ type: ComponentType.NumberField,
88
+ name: `${name}__longitude`,
89
+ title: 'Longitude',
90
+ schema: { min: longitudeMin, max: longitudeMax, precision: 7 },
91
+ options: {
92
+ required: isRequired,
93
+ optionalText: true,
94
+ classes: 'govuk-input--width-10',
95
+ suffix: '°',
96
+ customValidationMessages: longitudeMessages
97
+ }
98
+ }
99
+ ],
100
+ { ...props, parent: this },
101
+ {
102
+ custom: getValidatorLatLong(this),
103
+ peers: [`${name}__latitude`, `${name}__longitude`]
104
+ }
105
+ )
106
+
107
+ this.options = options
108
+ this.formSchema = this.collection.formSchema
109
+ this.stateSchema = this.collection.stateSchema
110
+ }
111
+
112
+ getFormValueFromState(state: FormSubmissionState) {
113
+ const value = super.getFormValueFromState(state)
114
+ return LatLongField.isLatLong(value) ? value : undefined
115
+ }
116
+
117
+ getDisplayStringFromFormValue(value: LatLongState | undefined): string {
118
+ if (!value) {
119
+ return ''
120
+ }
121
+
122
+ // CYA page format: <<latvalue, langvalue>>
123
+ return `${value.latitude}, ${value.longitude}`
124
+ }
125
+
126
+ getDisplayStringFromState(state: FormSubmissionState) {
127
+ const value = this.getFormValueFromState(state)
128
+
129
+ return this.getDisplayStringFromFormValue(value)
130
+ }
131
+
132
+ getContextValueFromFormValue(value: LatLongState | undefined): string | null {
133
+ if (!value) {
134
+ return null
135
+ }
136
+
137
+ // Output format: Lat: <<entry>>\nLong: <<entry>>
138
+ return `Lat: ${value.latitude}\nLong: ${value.longitude}`
139
+ }
140
+
141
+ getContextValueFromState(state: FormSubmissionState) {
142
+ const value = this.getFormValueFromState(state)
143
+
144
+ return this.getContextValueFromFormValue(value)
145
+ }
146
+
147
+ getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) {
148
+ const viewModel = super.getViewModel(payload, errors)
149
+ return getLocationFieldViewModel(this, viewModel, payload, errors)
150
+ }
151
+
152
+ isState(value?: FormStateValue | FormState) {
153
+ return LatLongField.isLatLong(value)
154
+ }
155
+
156
+ /**
157
+ * For error preview page that shows all possible errors on a component
158
+ */
159
+ getAllPossibleErrors(): ErrorMessageTemplateList {
160
+ return LatLongField.getAllPossibleErrors()
161
+ }
162
+
163
+ /**
164
+ * Static version of getAllPossibleErrors that doesn't require a component instance.
165
+ */
166
+ static getAllPossibleErrors(): ErrorMessageTemplateList {
167
+ return {
168
+ baseErrors: [
169
+ { type: 'required', template: messageTemplate.required },
170
+ {
171
+ type: 'latitudeFormat',
172
+ template:
173
+ 'Enter a valid latitude for [short description] like 51.519450'
174
+ },
175
+ {
176
+ type: 'longitudeFormat',
177
+ template:
178
+ 'Enter a valid longitude for [short description] like -0.127758'
179
+ }
180
+ ],
181
+ advancedSettingsErrors: [
182
+ {
183
+ type: 'latitudeMin',
184
+ template: 'Latitude for [short description] must be between 49 and 60'
185
+ },
186
+ {
187
+ type: 'latitudeMax',
188
+ template: 'Latitude for [short description] must be between 49 and 60'
189
+ },
190
+ {
191
+ type: 'longitudeMin',
192
+ template: 'Longitude for [short description] must be between -9 and 2'
193
+ },
194
+ {
195
+ type: 'longitudeMax',
196
+ template: 'Longitude for [short description] must be between -9 and 2'
197
+ }
198
+ ]
199
+ }
200
+ }
201
+
202
+ static isLatLong(value?: FormStateValue | FormState): value is LatLongState {
203
+ return (
204
+ isFormState(value) &&
205
+ NumberField.isNumber(value.latitude) &&
206
+ NumberField.isNumber(value.longitude)
207
+ )
208
+ }
209
+ }
210
+
211
+ export function getValidatorLatLong(component: LatLongField) {
212
+ return createLocationFieldValidator(component)
213
+ }
@@ -0,0 +1,253 @@
1
+ import { ComponentType } from '@defra/forms-model'
2
+ import type joi from 'joi'
3
+ import { type LanguageMessages } from 'joi'
4
+
5
+ import { LocationFieldBase } from '~/src/server/plugins/engine/components/LocationFieldBase.js'
6
+ import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js'
7
+ import definition from '~/test/form/definitions/blank.js'
8
+ import { getFormData } from '~/test/helpers/component-helpers.js'
9
+
10
+ class TestLocationField extends LocationFieldBase {
11
+ protected getValidationConfig() {
12
+ return {
13
+ pattern: /^TEST\d{4}$/i,
14
+ patternErrorMessage: 'Enter a valid test code like TEST1234',
15
+ additionalMessages: {
16
+ 'string.custom': 'This is a custom error from additional messages'
17
+ } as LanguageMessages,
18
+ customValidation: (value: string, helpers: joi.CustomHelpers) => {
19
+ if (value === 'FAIL0000') {
20
+ return helpers.error('string.custom')
21
+ }
22
+ return value
23
+ }
24
+ }
25
+ }
26
+
27
+ protected getErrorTemplates() {
28
+ return [
29
+ {
30
+ type: 'pattern',
31
+ template:
32
+ 'Enter a valid test code for [short description] like TEST1234'
33
+ },
34
+ {
35
+ type: 'custom',
36
+ template: 'This is a custom error template'
37
+ }
38
+ ]
39
+ }
40
+ }
41
+
42
+ describe('LocationFieldBase', () => {
43
+ let model: FormModel
44
+
45
+ beforeEach(() => {
46
+ model = new FormModel(definition, {
47
+ basePath: 'test'
48
+ })
49
+ })
50
+
51
+ describe('customValidationMessage with additionalMessages', () => {
52
+ it('should merge custom validation message with additional message keys', () => {
53
+ const def = {
54
+ title: 'Test location field',
55
+ name: 'myComponent',
56
+ type: ComponentType.TextField,
57
+ options: {
58
+ customValidationMessage: 'This is a unified custom error'
59
+ },
60
+ schema: {}
61
+ } as ConstructorParameters<typeof TestLocationField>[0]
62
+
63
+ const field = new TestLocationField(def, { model })
64
+
65
+ const result2 = field.formSchema.validate('INVALID')
66
+ const result3 = field.formSchema.validate('FAIL0000')
67
+
68
+ expect(result2.error?.message).toBe('This is a unified custom error')
69
+ expect(result3.error?.message).toBe('This is a unified custom error')
70
+ })
71
+ })
72
+
73
+ describe('getViewModel with instructionText', () => {
74
+ it('should include parsed markdown instruction text', () => {
75
+ const def = {
76
+ title: 'Test location field',
77
+ name: 'myComponent',
78
+ type: ComponentType.TextField,
79
+ options: {
80
+ instructionText: 'This is **bold** text'
81
+ },
82
+ schema: {}
83
+ } as ConstructorParameters<typeof TestLocationField>[0]
84
+
85
+ const field = new TestLocationField(def, { model })
86
+ const viewModel = field.getViewModel(getFormData('TEST1234'))
87
+
88
+ const instructionText =
89
+ 'instructionText' in viewModel ? viewModel.instructionText : undefined
90
+ expect(instructionText).toBeTruthy()
91
+ expect(instructionText).toContain('bold')
92
+ })
93
+
94
+ it('should not include instructionText when not provided', () => {
95
+ const def = {
96
+ title: 'Test location field',
97
+ name: 'myComponent',
98
+ type: ComponentType.TextField,
99
+ options: {},
100
+ schema: {}
101
+ } as ConstructorParameters<typeof TestLocationField>[0]
102
+
103
+ const field = new TestLocationField(def, { model })
104
+ const viewModel = field.getViewModel(getFormData('TEST1234'))
105
+
106
+ expect(
107
+ 'instructionText' in viewModel ? viewModel.instructionText : undefined
108
+ ).toBeUndefined()
109
+ })
110
+ })
111
+
112
+ describe('getAllPossibleErrors', () => {
113
+ it('should return base errors with custom error templates', () => {
114
+ const def = {
115
+ title: 'Test location field',
116
+ name: 'myComponent',
117
+ type: ComponentType.TextField,
118
+ options: {},
119
+ schema: {}
120
+ } as ConstructorParameters<typeof TestLocationField>[0]
121
+
122
+ const field = new TestLocationField(def, { model })
123
+ const errors = field.getAllPossibleErrors()
124
+
125
+ expect(errors.baseErrors).toHaveLength(3)
126
+ expect(errors.baseErrors).toEqual(
127
+ expect.arrayContaining([
128
+ expect.objectContaining({ type: 'required' }),
129
+ expect.objectContaining({ type: 'pattern' }),
130
+ expect.objectContaining({ type: 'custom' })
131
+ ])
132
+ )
133
+ expect(errors.advancedSettingsErrors).toEqual([])
134
+ })
135
+ })
136
+
137
+ describe('isValue and getFormValue', () => {
138
+ it('should correctly identify string values', () => {
139
+ const def = {
140
+ title: 'Test location field',
141
+ name: 'myComponent',
142
+ type: ComponentType.TextField,
143
+ options: {},
144
+ schema: {}
145
+ } as ConstructorParameters<typeof TestLocationField>[0]
146
+
147
+ const field = new TestLocationField(def, { model })
148
+
149
+ expect(field.isValue('TEST1234')).toBe(true)
150
+ expect(field.isValue('')).toBe(false)
151
+ expect(field.isValue(null)).toBe(false)
152
+ expect(field.isValue(undefined)).toBe(false)
153
+ expect(field.isValue(123)).toBe(false)
154
+ expect(field.isValue({ test: 'value' })).toBe(false)
155
+ })
156
+
157
+ it('should return value when it is a non-empty string', () => {
158
+ const def = {
159
+ title: 'Test location field',
160
+ name: 'myComponent',
161
+ type: ComponentType.TextField,
162
+ options: {},
163
+ schema: {}
164
+ } as ConstructorParameters<typeof TestLocationField>[0]
165
+
166
+ const field = new TestLocationField(def, { model })
167
+
168
+ expect(field.getFormValue('TEST1234')).toBe('TEST1234')
169
+ expect(field.getFormValue('')).toBeUndefined()
170
+ expect(field.getFormValue(null)).toBeUndefined()
171
+ expect(field.getFormValue(undefined)).toBeUndefined()
172
+ })
173
+
174
+ it('should get value from state', () => {
175
+ const def = {
176
+ title: 'Test location field',
177
+ name: 'myComponent',
178
+ type: ComponentType.TextField,
179
+ options: {},
180
+ schema: {}
181
+ } as ConstructorParameters<typeof TestLocationField>[0]
182
+
183
+ const field = new TestLocationField(def, { model })
184
+
185
+ const state1 = { myComponent: 'TEST1234' }
186
+ const state2 = { myComponent: null }
187
+ const state3 = { myComponent: '' }
188
+
189
+ expect(field.getFormValueFromState(state1)).toBe('TEST1234')
190
+ expect(field.getFormValueFromState(state2)).toBeUndefined()
191
+ expect(field.getFormValueFromState(state3)).toBeUndefined()
192
+ })
193
+ })
194
+
195
+ describe('optional field validation', () => {
196
+ it('should allow empty values when required is false', () => {
197
+ const def = {
198
+ title: 'Test location field',
199
+ name: 'myComponent',
200
+ type: ComponentType.TextField,
201
+ options: {
202
+ required: false
203
+ },
204
+ schema: {}
205
+ } as ConstructorParameters<typeof TestLocationField>[0]
206
+
207
+ const field = new TestLocationField(def, { model })
208
+ const result = field.formSchema.validate('')
209
+
210
+ expect(result.error).toBeUndefined()
211
+ expect(result.value).toBe('')
212
+ })
213
+
214
+ it('should validate pattern even for optional fields when value is provided', () => {
215
+ const def = {
216
+ title: 'Test location field',
217
+ name: 'myComponent',
218
+ type: ComponentType.TextField,
219
+ options: {
220
+ required: false
221
+ },
222
+ schema: {}
223
+ } as ConstructorParameters<typeof TestLocationField>[0]
224
+
225
+ const field = new TestLocationField(def, { model })
226
+ const result = field.formSchema.validate('INVALID')
227
+
228
+ expect(result.error).toBeDefined()
229
+ })
230
+ })
231
+
232
+ describe('customValidationMessages', () => {
233
+ it('should use custom validation messages when provided', () => {
234
+ const def = {
235
+ title: 'Test location field',
236
+ name: 'myComponent',
237
+ type: ComponentType.TextField,
238
+ options: {
239
+ customValidationMessages: {
240
+ 'string.pattern.base': 'Custom pattern error message',
241
+ 'string.custom': 'Custom error message'
242
+ }
243
+ },
244
+ schema: {}
245
+ } as ConstructorParameters<typeof TestLocationField>[0]
246
+
247
+ const field = new TestLocationField(def, { model })
248
+ const result = field.formSchema.validate('INVALID')
249
+
250
+ expect(result.error?.message).toBe('Custom pattern error message')
251
+ })
252
+ })
253
+ })
@@ -0,0 +1,152 @@
1
+ import { type FormComponentsDef } from '@defra/forms-model'
2
+ import joi, { type LanguageMessages, type StringSchema } from 'joi'
3
+
4
+ import {
5
+ FormComponent,
6
+ isFormValue
7
+ } from '~/src/server/plugins/engine/components/FormComponent.js'
8
+ import { addClassOptionIfNone } from '~/src/server/plugins/engine/components/helpers/index.js'
9
+ import { markdown } from '~/src/server/plugins/engine/components/markdownParser.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
+ } from '~/src/server/plugins/engine/types.js'
19
+
20
+ interface LocationFieldOptions {
21
+ instructionText?: string
22
+ required?: boolean
23
+ customValidationMessage?: string
24
+ customValidationMessages?: LanguageMessages
25
+ classes?: string
26
+ }
27
+
28
+ interface ValidationConfig {
29
+ pattern: RegExp
30
+ patternErrorMessage: string
31
+ customValidation?: (
32
+ value: string,
33
+ helpers: joi.CustomHelpers
34
+ ) => string | joi.ErrorReport
35
+ additionalMessages?: LanguageMessages
36
+ }
37
+
38
+ /**
39
+ * Abstract base class for location-based field components
40
+ */
41
+ export abstract class LocationFieldBase extends FormComponent {
42
+ declare options: LocationFieldOptions
43
+ declare formSchema: StringSchema
44
+ declare stateSchema: StringSchema
45
+ instructionText?: string
46
+
47
+ protected abstract getValidationConfig(): ValidationConfig
48
+ protected abstract getErrorTemplates(): {
49
+ type: string
50
+ template: string
51
+ }[]
52
+
53
+ constructor(
54
+ def: FormComponentsDef,
55
+ props: ConstructorParameters<typeof FormComponent>[1]
56
+ ) {
57
+ super(def, props)
58
+
59
+ const { options } = def
60
+ const locationOptions = options as LocationFieldOptions
61
+ this.instructionText = locationOptions.instructionText
62
+
63
+ addClassOptionIfNone(locationOptions, 'govuk-input--width-10')
64
+
65
+ const config = this.getValidationConfig()
66
+
67
+ let formSchema = joi
68
+ .string()
69
+ .trim()
70
+ .label(this.label)
71
+ .required()
72
+ .pattern(config.pattern)
73
+ .messages({
74
+ 'string.pattern.base': config.patternErrorMessage,
75
+ ...config.additionalMessages
76
+ })
77
+
78
+ if (config.customValidation) {
79
+ formSchema = formSchema.custom(config.customValidation)
80
+ }
81
+
82
+ if (locationOptions.required === false) {
83
+ formSchema = formSchema.allow('')
84
+ }
85
+
86
+ if (locationOptions.customValidationMessage) {
87
+ const message = locationOptions.customValidationMessage
88
+ const messageKeys = [
89
+ 'any.required',
90
+ 'string.empty',
91
+ 'string.pattern.base'
92
+ ]
93
+
94
+ if (config.additionalMessages) {
95
+ messageKeys.push(...Object.keys(config.additionalMessages))
96
+ }
97
+
98
+ const messages = messageKeys.reduce<LanguageMessages>((acc, key) => {
99
+ acc[key] = message
100
+ return acc
101
+ }, {})
102
+
103
+ formSchema = formSchema.messages(messages)
104
+ } else if (locationOptions.customValidationMessages) {
105
+ formSchema = formSchema.messages(locationOptions.customValidationMessages)
106
+ }
107
+
108
+ this.formSchema = formSchema.default('')
109
+ this.stateSchema = formSchema.default(null).allow(null)
110
+ this.options = locationOptions
111
+ }
112
+
113
+ getFormValueFromState(state: FormSubmissionState) {
114
+ const { name } = this
115
+ return this.getFormValue(state[name])
116
+ }
117
+
118
+ getFormValue(value?: FormStateValue | FormState) {
119
+ return this.isValue(value) ? value : undefined
120
+ }
121
+
122
+ isValue(value?: FormStateValue | FormState): value is string {
123
+ return LocationFieldBase.isText(value)
124
+ }
125
+
126
+ getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) {
127
+ const viewModel = super.getViewModel(payload, errors)
128
+
129
+ if (this.instructionText) {
130
+ return {
131
+ ...viewModel,
132
+ instructionText: markdown.parse(this.instructionText, { async: false })
133
+ }
134
+ }
135
+
136
+ return viewModel
137
+ }
138
+
139
+ getAllPossibleErrors(): ErrorMessageTemplateList {
140
+ return {
141
+ baseErrors: [
142
+ { type: 'required', template: messageTemplate.required },
143
+ ...this.getErrorTemplates()
144
+ ],
145
+ advancedSettingsErrors: []
146
+ }
147
+ }
148
+
149
+ static isText(value?: FormStateValue | FormState): value is string {
150
+ return isFormValue(value) && typeof value === 'string'
151
+ }
152
+ }