@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.
- package/.public/stylesheets/application.min.css +2 -2
- package/.public/stylesheets/application.min.css.map +1 -1
- package/.server/client/stylesheets/_location-input.scss +60 -0
- package/.server/client/stylesheets/application.scss +1 -6
- package/.server/client/stylesheets/shared.scss +8 -0
- package/.server/server/forms/register-as-a-unicorn-breeder.yaml +28 -0
- package/.server/server/plugins/engine/components/ComponentBase.d.ts +1 -1
- package/.server/server/plugins/engine/components/ComponentBase.js.map +1 -1
- package/.server/server/plugins/engine/components/EastingNorthingField.d.ts +121 -0
- package/.server/server/plugins/engine/components/EastingNorthingField.js +166 -0
- package/.server/server/plugins/engine/components/EastingNorthingField.js.map +1 -0
- package/.server/server/plugins/engine/components/LatLongField.d.ts +121 -0
- package/.server/server/plugins/engine/components/LatLongField.js +164 -0
- package/.server/server/plugins/engine/components/LatLongField.js.map +1 -0
- package/.server/server/plugins/engine/components/LocationFieldBase.d.ts +134 -0
- package/.server/server/plugins/engine/components/LocationFieldBase.js +85 -0
- package/.server/server/plugins/engine/components/LocationFieldBase.js.map +1 -0
- package/.server/server/plugins/engine/components/LocationFieldHelpers.d.ts +108 -0
- package/.server/server/plugins/engine/components/LocationFieldHelpers.js +96 -0
- package/.server/server/plugins/engine/components/LocationFieldHelpers.js.map +1 -0
- package/.server/server/plugins/engine/components/NationalGridFieldNumberField.d.ts +19 -0
- package/.server/server/plugins/engine/components/NationalGridFieldNumberField.js +40 -0
- package/.server/server/plugins/engine/components/NationalGridFieldNumberField.js.map +1 -0
- package/.server/server/plugins/engine/components/OsGridRefField.d.ts +19 -0
- package/.server/server/plugins/engine/components/OsGridRefField.js +56 -0
- package/.server/server/plugins/engine/components/OsGridRefField.js.map +1 -0
- package/.server/server/plugins/engine/components/helpers/components.d.ts +3 -4
- package/.server/server/plugins/engine/components/helpers/components.js +21 -29
- package/.server/server/plugins/engine/components/helpers/components.js.map +1 -1
- package/.server/server/plugins/engine/components/index.d.ts +4 -0
- package/.server/server/plugins/engine/components/index.js +4 -0
- package/.server/server/plugins/engine/components/index.js.map +1 -1
- package/.server/server/plugins/engine/components/markdownParser.d.ts +2 -0
- package/.server/server/plugins/engine/components/markdownParser.js +28 -0
- package/.server/server/plugins/engine/components/markdownParser.js.map +1 -0
- package/.server/server/plugins/engine/components/types.d.ts +10 -0
- package/.server/server/plugins/engine/components/types.js.map +1 -1
- package/.server/server/plugins/engine/pageControllers/helpers/pages.js +7 -0
- package/.server/server/plugins/engine/pageControllers/helpers/pages.js.map +1 -1
- package/.server/server/plugins/engine/types/index.d.ts +1 -1
- package/.server/server/plugins/engine/types/index.js.map +1 -1
- package/.server/server/plugins/engine/types.d.ts +2 -2
- package/.server/server/plugins/engine/types.js.map +1 -1
- package/.server/server/plugins/engine/views/components/_location-field-base.html +53 -0
- package/.server/server/plugins/engine/views/components/eastingnorthingfield.html +5 -0
- package/.server/server/plugins/engine/views/components/latlongfield.html +5 -0
- package/.server/server/plugins/engine/views/components/nationalgridfieldnumberfield.html +13 -0
- package/.server/server/plugins/engine/views/components/osgridreffield.html +13 -0
- package/.server/server/plugins/nunjucks/filters/field.d.ts +1 -1
- package/package.json +3 -3
- package/src/client/stylesheets/_location-input.scss +60 -0
- package/src/client/stylesheets/application.scss +1 -6
- package/src/client/stylesheets/shared.scss +8 -0
- package/src/server/forms/register-as-a-unicorn-breeder.yaml +28 -0
- package/src/server/plugins/engine/components/ComponentBase.ts +1 -1
- package/src/server/plugins/engine/components/EastingNorthingField.test.ts +665 -0
- package/src/server/plugins/engine/components/EastingNorthingField.ts +224 -0
- package/src/server/plugins/engine/components/LatLongField.test.ts +700 -0
- package/src/server/plugins/engine/components/LatLongField.ts +213 -0
- package/src/server/plugins/engine/components/LocationFieldBase.test.ts +253 -0
- package/src/server/plugins/engine/components/LocationFieldBase.ts +152 -0
- package/src/server/plugins/engine/components/LocationFieldHelpers.test.ts +338 -0
- package/src/server/plugins/engine/components/LocationFieldHelpers.ts +123 -0
- package/src/server/plugins/engine/components/NationalGridFieldNumberField.test.ts +438 -0
- package/src/server/plugins/engine/components/NationalGridFieldNumberField.ts +52 -0
- package/src/server/plugins/engine/components/OsGridRefField.test.ts +469 -0
- package/src/server/plugins/engine/components/OsGridRefField.ts +71 -0
- package/src/server/plugins/engine/components/helpers/components.test.ts +270 -0
- package/src/server/plugins/engine/components/helpers/components.ts +39 -47
- package/src/server/plugins/engine/components/helpers/helpers.test.ts +71 -1
- package/src/server/plugins/engine/components/index.ts +4 -0
- package/src/server/plugins/engine/components/markdownParser.ts +40 -0
- package/src/server/plugins/engine/components/types.ts +14 -0
- package/src/server/plugins/engine/outputFormatters/adapter/v1.location.test.ts +356 -0
- package/src/server/plugins/engine/pageControllers/helpers/helpers.test.ts +4 -0
- package/src/server/plugins/engine/pageControllers/helpers/pages.ts +8 -0
- package/src/server/plugins/engine/types/index.ts +2 -0
- package/src/server/plugins/engine/types.ts +4 -0
- package/src/server/plugins/engine/views/components/_location-field-base.html +53 -0
- package/src/server/plugins/engine/views/components/eastingnorthingfield.html +5 -0
- package/src/server/plugins/engine/views/components/latlongfield.html +5 -0
- package/src/server/plugins/engine/views/components/nationalgridfieldnumberfield.html +13 -0
- 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
|
+
}
|