@defra/forms-engine-plugin 5.0.0-alpha.11 → 5.0.0-alpha.13

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 (101) hide show
  1. package/.public/javascripts/shared.min.js +1 -1
  2. package/.public/javascripts/shared.min.js.map +1 -1
  3. package/.server/client/javascripts/geospatial-map.d.ts +153 -5
  4. package/.server/client/javascripts/geospatial-map.js +147 -46
  5. package/.server/client/javascripts/geospatial-map.js.map +1 -1
  6. package/.server/client/javascripts/location-map.d.ts +38 -0
  7. package/.server/client/javascripts/location-map.js +42 -2
  8. package/.server/client/javascripts/location-map.js.map +1 -1
  9. package/.server/client/javascripts/map-constants.d.ts +3 -0
  10. package/.server/client/javascripts/map-constants.js +4 -0
  11. package/.server/client/javascripts/map-constants.js.map +1 -0
  12. package/.server/client/javascripts/map.d.ts +4 -0
  13. package/.server/client/javascripts/map.js +5 -0
  14. package/.server/client/javascripts/map.js.map +1 -1
  15. package/.server/server/plugins/engine/__stubs__/definitions.d.ts +29 -0
  16. package/.server/server/plugins/engine/__stubs__/definitions.js +125 -0
  17. package/.server/server/plugins/engine/__stubs__/definitions.js.map +1 -0
  18. package/.server/server/plugins/engine/components/CheckboxesField.d.ts +20 -2
  19. package/.server/server/plugins/engine/components/CheckboxesField.js +24 -12
  20. package/.server/server/plugins/engine/components/CheckboxesField.js.map +1 -1
  21. package/.server/server/plugins/engine/components/EastingNorthingField.d.ts +4 -0
  22. package/.server/server/plugins/engine/components/EastingNorthingField.js +1 -1
  23. package/.server/server/plugins/engine/components/EastingNorthingField.js.map +1 -1
  24. package/.server/server/plugins/engine/components/FileUploadField.d.ts +10 -1
  25. package/.server/server/plugins/engine/components/FileUploadField.js +18 -6
  26. package/.server/server/plugins/engine/components/FileUploadField.js.map +1 -1
  27. package/.server/server/plugins/engine/components/GeospatialField.d.ts +22 -0
  28. package/.server/server/plugins/engine/components/GeospatialField.js +33 -9
  29. package/.server/server/plugins/engine/components/GeospatialField.js.map +1 -1
  30. package/.server/server/plugins/engine/components/LatLongField.d.ts +4 -0
  31. package/.server/server/plugins/engine/components/LocationFieldBase.js +7 -1
  32. package/.server/server/plugins/engine/components/LocationFieldBase.js.map +1 -1
  33. package/.server/server/plugins/engine/components/LocationFieldHelpers.d.ts +4 -0
  34. package/.server/server/plugins/engine/components/LocationFieldHelpers.js +9 -2
  35. package/.server/server/plugins/engine/components/LocationFieldHelpers.js.map +1 -1
  36. package/.server/server/plugins/engine/components/helpers/components.js +2 -1
  37. package/.server/server/plugins/engine/components/helpers/components.js.map +1 -1
  38. package/.server/server/plugins/engine/components/helpers/geospatial.d.ts +5 -0
  39. package/.server/server/plugins/engine/components/helpers/geospatial.js +25 -16
  40. package/.server/server/plugins/engine/components/helpers/geospatial.js.map +1 -1
  41. package/.server/server/plugins/engine/errors.d.ts +49 -0
  42. package/.server/server/plugins/engine/errors.js +75 -0
  43. package/.server/server/plugins/engine/errors.js.map +1 -0
  44. package/.server/server/plugins/engine/form-availability.d.ts +3 -2
  45. package/.server/server/plugins/engine/form-availability.js +3 -2
  46. package/.server/server/plugins/engine/form-availability.js.map +1 -1
  47. package/.server/server/plugins/engine/form-context.js +2 -2
  48. package/.server/server/plugins/engine/form-context.js.map +1 -1
  49. package/.server/server/plugins/engine/i18n/buildValidationMessages.d.ts +1 -1
  50. package/.server/server/plugins/engine/i18n/buildValidationMessages.js +30 -9
  51. package/.server/server/plugins/engine/i18n/buildValidationMessages.js.map +1 -1
  52. package/.server/server/plugins/engine/i18n/translations/cy.json +117 -11
  53. package/.server/server/plugins/engine/i18n/translations/en-GB.json +80 -11
  54. package/.server/server/plugins/engine/i18n/types.d.ts +1 -0
  55. package/.server/server/plugins/engine/i18n/types.js.map +1 -1
  56. package/.server/server/plugins/engine/models/FormModel.js +10 -2
  57. package/.server/server/plugins/engine/models/FormModel.js.map +1 -1
  58. package/.server/server/plugins/engine/models/unavailable-view-model.d.ts +11 -2
  59. package/.server/server/plugins/engine/models/unavailable-view-model.js +23 -4
  60. package/.server/server/plugins/engine/models/unavailable-view-model.js.map +1 -1
  61. package/.server/server/plugins/engine/pageControllers/helpers/pages.js +2 -1
  62. package/.server/server/plugins/engine/pageControllers/helpers/pages.js.map +1 -1
  63. package/.server/server/plugins/engine/pageControllers/validationOptions.d.ts +1 -1
  64. package/.server/server/plugins/engine/pageControllers/validationOptions.js +1 -1
  65. package/.server/server/plugins/engine/pageControllers/validationOptions.js.map +1 -1
  66. package/.server/server/plugins/engine/unavailable-response.js +5 -2
  67. package/.server/server/plugins/engine/unavailable-response.js.map +1 -1
  68. package/.server/server/plugins/engine/views/components/_location-field-base.html +2 -1
  69. package/.server/server/plugins/engine/views/components/geospatialfield.html +1 -0
  70. package/.server/server/plugins/engine/views/components/osgridreffield.html +3 -1
  71. package/.server/server/plugins/engine/views/unavailable.html +14 -11
  72. package/package.json +1 -1
  73. package/src/client/javascripts/geospatial-map.js +169 -47
  74. package/src/client/javascripts/location-map.js +50 -2
  75. package/src/client/javascripts/map-constants.js +3 -0
  76. package/src/client/javascripts/map.js +5 -0
  77. package/src/server/plugins/engine/__stubs__/definitions.ts +155 -0
  78. package/src/server/plugins/engine/components/CheckboxesField.ts +34 -13
  79. package/src/server/plugins/engine/components/EastingNorthingField.ts +1 -1
  80. package/src/server/plugins/engine/components/FileUploadField.ts +26 -6
  81. package/src/server/plugins/engine/components/GeospatialField.ts +44 -11
  82. package/src/server/plugins/engine/components/LocationFieldBase.ts +7 -1
  83. package/src/server/plugins/engine/components/LocationFieldHelpers.ts +9 -2
  84. package/src/server/plugins/engine/components/helpers/components.ts +2 -1
  85. package/src/server/plugins/engine/components/helpers/geospatial.ts +32 -22
  86. package/src/server/plugins/engine/errors.ts +78 -0
  87. package/src/server/plugins/engine/form-availability.ts +8 -2
  88. package/src/server/plugins/engine/form-context.ts +4 -2
  89. package/src/server/plugins/engine/i18n/buildValidationMessages.ts +15 -10
  90. package/src/server/plugins/engine/i18n/translations/cy.json +117 -11
  91. package/src/server/plugins/engine/i18n/translations/en-GB.json +80 -11
  92. package/src/server/plugins/engine/i18n/types.ts +1 -0
  93. package/src/server/plugins/engine/models/FormModel.ts +12 -2
  94. package/src/server/plugins/engine/models/unavailable-view-model.ts +26 -5
  95. package/src/server/plugins/engine/pageControllers/helpers/pages.ts +2 -1
  96. package/src/server/plugins/engine/pageControllers/validationOptions.ts +2 -2
  97. package/src/server/plugins/engine/unavailable-response.ts +6 -2
  98. package/src/server/plugins/engine/views/components/_location-field-base.html +2 -1
  99. package/src/server/plugins/engine/views/components/geospatialfield.html +1 -0
  100. package/src/server/plugins/engine/views/components/osgridreffield.html +3 -1
  101. package/src/server/plugins/engine/views/unavailable.html +14 -11
@@ -0,0 +1,155 @@
1
+ import {
2
+ ComponentType,
3
+ ConditionType,
4
+ ControllerType,
5
+ Engine,
6
+ OperatorName,
7
+ SchemaVersion,
8
+ type FormDefinition,
9
+ type PageQuestion
10
+ } from '@defra/forms-model'
11
+
12
+ const YES_NO_COMPONENT_ID = 'a7c0242f-2a31-45b2-8c71-ff2ac7f53288'
13
+ const USER_TYPE_LIST_ID = '4fa26e9c-07cf-47cd-a9dd-5cec0dd3f544'
14
+ const EXISTING_USER_ITEM_ID = '55fe0067-d011-4d33-886c-e1aa266637c3'
15
+
16
+ /**
17
+ * A minimal, schema-valid V2 definition: one YesNo question page and a
18
+ * summary page. Each call returns a fresh object, so tests can mutate their
19
+ * copy freely without affecting each other.
20
+ */
21
+ export function buildDefinition(): FormDefinition {
22
+ return {
23
+ name: 'Stub definition',
24
+ engine: Engine.V2,
25
+ schema: SchemaVersion.V2,
26
+ startPage: '/summary',
27
+ pages: [
28
+ {
29
+ id: '449c053b-9201-4312-9a75-187afc6ba48b',
30
+ path: '/licence',
31
+ title: 'Licence',
32
+ components: [
33
+ {
34
+ id: YES_NO_COMPONENT_ID,
35
+ name: 'xVrYaJ',
36
+ type: ComponentType.YesNoField,
37
+ title: 'Do you have a licence?',
38
+ shortDescription: 'Licence',
39
+ options: { required: true }
40
+ }
41
+ ],
42
+ next: []
43
+ },
44
+ {
45
+ id: '449c053b-9201-4312-9a75-187afc6ba48c',
46
+ path: '/summary',
47
+ title: 'Summary',
48
+ controller: ControllerType.Summary,
49
+ components: []
50
+ }
51
+ ],
52
+ lists: [],
53
+ sections: [],
54
+ conditions: []
55
+ }
56
+ }
57
+
58
+ /**
59
+ * A definition that passes schema validation but cannot be used by the
60
+ * engine: a ListItemRef condition points at the YesNoField (boolean), so the
61
+ * condition expression builder produces an unparseable expression. Distilled
62
+ * from a real production incident.
63
+ */
64
+ export function buildBrokenConditionDefinition(): FormDefinition {
65
+ const definition = buildDefinition()
66
+
67
+ definition.name = 'Broken condition fixture'
68
+
69
+ const questionPage = definition.pages[0] as PageQuestion
70
+ // A YesNoField cannot legitimately carry a custom list — that is the
71
+ // corruption this fixture models — so the TS component types (rightly)
72
+ // have no `list` property here and a cast is required.
73
+ ;(questionPage.components[0] as { list?: string }).list = USER_TYPE_LIST_ID
74
+ definition.lists = [
75
+ {
76
+ id: USER_TYPE_LIST_ID,
77
+ name: 'XtfRYR',
78
+ title: 'User type list',
79
+ type: 'string',
80
+ items: [
81
+ {
82
+ id: EXISTING_USER_ITEM_ID,
83
+ text: 'existing user',
84
+ value: 'existing user'
85
+ },
86
+ {
87
+ id: '2277c7e5-7fef-46c6-993b-d294116d6d6b',
88
+ text: 'new user',
89
+ value: 'new user'
90
+ }
91
+ ]
92
+ }
93
+ ]
94
+ definition.conditions = [
95
+ {
96
+ id: '3f9d3a35-6dee-4706-806c-3f776129f631',
97
+ displayName: 'Existing user',
98
+ items: [
99
+ {
100
+ id: '7d7f58ee-c860-4d24-8a13-de5cb9af53d8',
101
+ componentId: YES_NO_COMPONENT_ID,
102
+ operator: OperatorName.Is,
103
+ type: ConditionType.ListItemRef,
104
+ value: {
105
+ listId: USER_TYPE_LIST_ID,
106
+ itemId: [EXISTING_USER_ITEM_ID]
107
+ }
108
+ }
109
+ ]
110
+ }
111
+ ]
112
+
113
+ return definition
114
+ }
115
+
116
+ /**
117
+ * A schema-valid definition whose first page names a controller that is
118
+ * neither built in nor registered by the host application.
119
+ */
120
+ export function buildUnknownControllerDefinition(): FormDefinition {
121
+ const definition = buildDefinition()
122
+
123
+ definition.name = 'Unknown controller fixture'
124
+ definition.pages[0].controller =
125
+ 'NoSuchPageController' as unknown as ControllerType
126
+
127
+ return definition
128
+ }
129
+
130
+ /**
131
+ * Schema-valid (component types are free strings in the schema), but the
132
+ * question uses a type the engine has no component class for.
133
+ */
134
+ export function buildUnknownComponentDefinition(): FormDefinition {
135
+ const definition = buildDefinition()
136
+
137
+ definition.name = 'Unknown component fixture'
138
+ const questionPage = definition.pages[0] as PageQuestion
139
+ questionPage.components[0].type = 'MyUnknownField' as unknown as ComponentType
140
+
141
+ return definition
142
+ }
143
+
144
+ /**
145
+ * A definition that fails schema validation: the question page appears
146
+ * twice, violating the pages uniqueness rule.
147
+ */
148
+ export function buildSchemaInvalidDefinition(): FormDefinition {
149
+ const definition = buildDefinition()
150
+
151
+ definition.name = 'Schema invalid fixture'
152
+ definition.pages.push(buildDefinition().pages[0])
153
+
154
+ return definition
155
+ }
@@ -1,9 +1,10 @@
1
1
  import { type CheckboxesFieldComponent, type Item } from '@defra/forms-model'
2
- import joi, { type ArraySchema, type LanguageMessages } from 'joi'
2
+ import joi, { type ArraySchema } from 'joi'
3
3
 
4
+ import { EN_GB } from '../../../constants.js'
4
5
  import { isFormValue } from './FormComponent.js'
5
6
  import { SelectionControlField } from './SelectionControlField.js'
6
- import { buildValidationMessages } from '../i18n/buildValidationMessages.js'
7
+ import { t as tPlugin } from '../i18n/index.js'
7
8
  import { type Translator } from '../i18n/types.js'
8
9
  import { type FormModel } from '../models/FormModel.js'
9
10
  import { type QuestionPageController } from '../pageControllers/QuestionPageController.js'
@@ -20,6 +21,7 @@ export class CheckboxesField extends SelectionControlField {
20
21
  declare schema: CheckboxesFieldComponent['schema']
21
22
  declare formSchema: ArraySchema<string> | ArraySchema<number>
22
23
  declare stateSchema: ArraySchema<string> | ArraySchema<number>
24
+ declare limits: { min?: number; max?: number; length?: number }
23
25
 
24
26
  constructor(
25
27
  def: CheckboxesFieldComponent,
@@ -42,11 +44,8 @@ export class CheckboxesField extends SelectionControlField {
42
44
  .single()
43
45
  .label(this.label)
44
46
  .required()
45
- .messages({
46
- 'array.min': messageTemplate.arrayMin,
47
- 'array.max': messageTemplate.arrayMax,
48
- 'array.length': messageTemplate.arrayLength
49
- })
47
+
48
+ const limits: { min?: number; max?: number; length?: number } = {}
50
49
 
51
50
  if (options.required === false) {
52
51
  formSchema = formSchema.optional()
@@ -54,16 +53,25 @@ export class CheckboxesField extends SelectionControlField {
54
53
 
55
54
  if (typeof schema?.length === 'number') {
56
55
  formSchema = formSchema.length(schema.length)
56
+ limits.length = schema.length
57
57
  } else {
58
58
  if (typeof schema?.min === 'number') {
59
59
  formSchema = formSchema.min(schema.min)
60
+ limits.min = schema.min
60
61
  }
61
62
 
62
63
  if (typeof schema?.max === 'number') {
63
64
  formSchema = formSchema.max(schema.max)
65
+ limits.max = schema.max
64
66
  }
65
67
  }
66
68
 
69
+ this.limits = limits
70
+
71
+ formSchema = formSchema.messages(
72
+ CheckboxesField.buildErrorMessages(EN_GB, limits)
73
+ )
74
+
67
75
  this.formSchema = formSchema.default([])
68
76
  this.stateSchema = formSchema.default(null).allow(null)
69
77
  this.options = options
@@ -135,13 +143,9 @@ export class CheckboxesField extends SelectionControlField {
135
143
  }
136
144
 
137
145
  getValidationMessagesOverride(translator: Translator) {
138
- const { t } = translator
146
+ const { language } = translator
139
147
  return {
140
- [this.name]: {
141
- 'array.min': buildValidationMessages(t).arrayMin,
142
- 'array.max': buildValidationMessages(t).arrayMax,
143
- 'array.length': buildValidationMessages(t).arrayLength
144
- } as LanguageMessages
148
+ [this.name]: CheckboxesField.buildErrorMessages(language, this.limits)
145
149
  }
146
150
  }
147
151
 
@@ -181,4 +185,21 @@ export class CheckboxesField extends SelectionControlField {
181
185
 
182
186
  return value.every(isFormValue)
183
187
  }
188
+
189
+ static buildErrorMessages(
190
+ language: string,
191
+ limits: { min?: number; max?: number; length?: number } = {}
192
+ ) {
193
+ return {
194
+ 'array.min': tPlugin('validation.arrayMin', language, {
195
+ count: limits.min ?? 1
196
+ }),
197
+ 'array.max': tPlugin('validation.arrayMax', language, {
198
+ count: limits.max ?? 1
199
+ }),
200
+ 'array.length': tPlugin('validation.arrayLength', language, {
201
+ count: limits.length ?? 1
202
+ })
203
+ }
204
+ }
184
205
  }
@@ -187,7 +187,7 @@ export class EastingNorthingField extends FormComponent {
187
187
  if (err.context?.country) {
188
188
  err.text = translator.t(
189
189
  'components.eastingNorthingField.wrongCountry',
190
- { country: err.context.country }
190
+ { country: translator.t(`common.${err.context.country}`) }
191
191
  )
192
192
  }
193
193
  })
@@ -113,6 +113,7 @@ export class FileUploadField extends FormComponent {
113
113
  declare schema: FileUploadFieldComponent['schema']
114
114
  declare formSchema: ArraySchema<FileState>
115
115
  declare stateSchema: ArraySchema<FileState>
116
+ declare limits: { min?: number; max?: number; length?: number }
116
117
 
117
118
  constructor(
118
119
  def: FileUploadFieldComponent,
@@ -132,21 +133,31 @@ export class FileUploadField extends FormComponent {
132
133
  formSchema = formSchema.optional()
133
134
  }
134
135
 
136
+ const limits: { min?: number; max?: number; length?: number } = {}
137
+
135
138
  if (typeof schema.length !== 'number') {
136
139
  if (typeof schema.max === 'number') {
137
140
  formSchema = formSchema.max(schema.max)
141
+ limits.max = schema.max
138
142
  }
139
143
 
140
144
  if (typeof schema.min === 'number') {
141
145
  formSchema = formSchema.min(schema.min)
146
+ limits.min = schema.min
142
147
  } else if (options.required !== false) {
143
148
  formSchema = formSchema.min(1)
149
+ limits.min = 1
144
150
  }
145
151
  } else {
146
152
  formSchema = formSchema.length(schema.length)
153
+ limits.length = schema.length
147
154
  }
148
155
 
149
- formSchema = formSchema.messages(FileUploadField.buildErrorMessages(EN_GB))
156
+ this.limits = limits
157
+
158
+ formSchema = formSchema.messages(
159
+ FileUploadField.buildErrorMessages(EN_GB, limits)
160
+ )
150
161
 
151
162
  this.formSchema = formSchema.items(formItemSchema)
152
163
  this.stateSchema = formSchema
@@ -309,7 +320,7 @@ export class FileUploadField extends FormComponent {
309
320
  getValidationMessagesOverride(translator: Translator) {
310
321
  const { language } = translator
311
322
  return {
312
- [this.name]: FileUploadField.buildErrorMessages(language)
323
+ [this.name]: FileUploadField.buildErrorMessages(language, this.limits)
313
324
  }
314
325
  }
315
326
 
@@ -417,11 +428,20 @@ export class FileUploadField extends FormComponent {
417
428
  }
418
429
  }
419
430
 
420
- static buildErrorMessages(language: string) {
431
+ static buildErrorMessages(
432
+ language: string,
433
+ limits: { min?: number; max?: number; length?: number } = {}
434
+ ) {
421
435
  return {
422
- 'array.min': tPlugin('validation.filesMin', language),
423
- 'array.max': tPlugin('validation.filesMax', language),
424
- 'array.length': tPlugin('validation.filesLength', language)
436
+ 'array.min': tPlugin('validation.filesMin', language, {
437
+ count: limits.min ?? 1
438
+ }),
439
+ 'array.max': tPlugin('validation.filesMax', language, {
440
+ count: limits.max ?? 1
441
+ }),
442
+ 'array.length': tPlugin('validation.filesLength', language, {
443
+ count: limits.length ?? 1
444
+ })
425
445
  }
426
446
  }
427
447
  }
@@ -1,14 +1,19 @@
1
1
  import { type GeospatialFieldComponent } from '@defra/forms-model'
2
2
  import { type ArraySchema } from 'joi'
3
3
 
4
+ import { EN_GB } from '../../../constants.js'
4
5
  import { type ComponentBase } from './ComponentBase.js'
5
6
  import {
6
7
  FormComponent,
7
8
  isGeospatialState
8
9
  } from './FormComponent.js'
9
10
  import { getMapLayers } from './LocationFieldHelpers.js'
10
- import { getGeospatialSchema } from './helpers/geospatial.js'
11
+ import {
12
+ determineLimits,
13
+ getGeospatialSchema
14
+ } from './helpers/geospatial.js'
11
15
  import { type RenderContext } from './types.js'
16
+ import { t as tPlugin } from '../i18n/index.js'
12
17
  import { type Translator } from '../i18n/types.js'
13
18
  import { messageTemplate } from '../pageControllers/validationOptions.js'
14
19
  import {
@@ -24,6 +29,7 @@ export class GeospatialField extends FormComponent {
24
29
  declare options: GeospatialFieldComponent['options']
25
30
  declare formSchema: ArraySchema<GeospatialState>
26
31
  declare stateSchema: ArraySchema<GeospatialState>
32
+ declare limits: { min?: number; max?: number; length?: number }
27
33
 
28
34
  constructor(
29
35
  def: GeospatialFieldComponent,
@@ -33,20 +39,24 @@ export class GeospatialField extends FormComponent {
33
39
 
34
40
  const { options } = def
35
41
 
36
- const formSchema = getGeospatialSchema(def).label(this.label).messages({
37
- 'array.min': messageTemplate.featuresMin,
38
- 'array.max': messageTemplate.featuresMax,
39
- 'array.length': messageTemplate.featuresLength
40
- })
42
+ let formSchema = getGeospatialSchema(def).label(this.label)
43
+
44
+ const isOptional = options.required === false
45
+ const limits = determineLimits(def, isOptional)
46
+
47
+ formSchema = formSchema.messages(
48
+ GeospatialField.buildErrorMessages(EN_GB, limits)
49
+ )
41
50
 
42
51
  this.formSchema = formSchema
43
52
  this.stateSchema = formSchema.default(null)
44
53
 
45
- if (options.required === false) {
54
+ if (isOptional) {
46
55
  this.stateSchema = this.stateSchema.allow(null)
47
56
  }
48
57
 
49
58
  this.options = options
59
+ this.limits = limits
50
60
  }
51
61
 
52
62
  getFormValueFromState(state: FormSubmissionState) {
@@ -101,6 +111,7 @@ export class GeospatialField extends FormComponent {
101
111
 
102
112
  return {
103
113
  ...viewModel,
114
+ lang: context.translator.language,
104
115
  country: this.options.countries?.at(0),
105
116
  geometryTypes: this.options.geometryTypes,
106
117
  mapLayers: getMapLayers(this),
@@ -127,9 +138,7 @@ export class GeospatialField extends FormComponent {
127
138
  'components.geospatialField.validation.wrongCountry',
128
139
  {
129
140
  count: Number(err.path[1]) + 1,
130
- country: translator.t(
131
- `common.${(err.context.country as string).toLowerCase()}`
132
- )
141
+ country: translator.t(`common.${err.context.country}`)
133
142
  }
134
143
  )
135
144
  }
@@ -149,6 +158,13 @@ export class GeospatialField extends FormComponent {
149
158
  return isGeospatialState(value)
150
159
  }
151
160
 
161
+ getValidationMessagesOverride(translator: Translator) {
162
+ const { language } = translator
163
+ return {
164
+ [this.name]: GeospatialField.buildErrorMessages(language, this.limits)
165
+ }
166
+ }
167
+
152
168
  /**
153
169
  * For error preview page that shows all possible errors on a component
154
170
  */
@@ -169,11 +185,28 @@ export class GeospatialField extends FormComponent {
169
185
  { type: 'required', template: messageTemplate.selectRequired },
170
186
  {
171
187
  type: 'array.min',
172
- template: '{{#title}} must contain at least 1 items'
188
+ template: '{{#title}} must contain at least 1 item'
173
189
  },
174
190
  { type: 'object.invalidjson', template: messageTemplate.format }
175
191
  ],
176
192
  advancedSettingsErrors: []
177
193
  }
178
194
  }
195
+
196
+ static buildErrorMessages(
197
+ language: string,
198
+ limits: { min?: number; max?: number; length?: number } = {}
199
+ ) {
200
+ return {
201
+ 'array.min': tPlugin('validation.featuresMin', language, {
202
+ count: limits.min ?? 1
203
+ }),
204
+ 'array.max': tPlugin('validation.featuresMax', language, {
205
+ count: limits.max ?? 1
206
+ }),
207
+ 'array.length': tPlugin('validation.featuresLength', language, {
208
+ count: limits.length ?? 1
209
+ })
210
+ }
211
+ }
179
212
  }
@@ -124,10 +124,16 @@ export abstract class LocationFieldBase extends FormComponent {
124
124
  getViewModel(context: RenderContext) {
125
125
  const viewModel = super.getViewModel(context)
126
126
 
127
+ Object.assign(viewModel, {
128
+ lang: context.translator.language
129
+ })
130
+
127
131
  if (this.instructionText) {
132
+ const { tComponent } = context.translator
128
133
  return {
129
134
  ...viewModel,
130
- instructionText: this.instructionText
135
+ instructionText:
136
+ tComponent(this.def, 'instructionText') || this.instructionText
131
137
  }
132
138
  }
133
139
 
@@ -194,13 +194,20 @@ export function getLocationFieldViewModel(
194
194
  country:
195
195
  component instanceof EastingNorthingField
196
196
  ? component.options.countries?.at(0)
197
- : undefined
197
+ : undefined,
198
+ lang: context.translator.language
198
199
  }
199
200
 
200
201
  if (component.options.instructionText) {
202
+ const { t, tComponent } = context.translator
203
+
201
204
  return {
202
205
  ...result,
203
- instructionText: component.options.instructionText
206
+ instructionSummary: t('components.locationFieldBase.howToFind'),
207
+ instructionText:
208
+ tComponent(component.def, 'instructionText') ||
209
+ component.options.instructionText,
210
+ t
204
211
  }
205
212
  }
206
213
 
@@ -6,6 +6,7 @@ import { ListFormComponent } from '../ListFormComponent.js'
6
6
  import { escapeMarkdown } from './index.js'
7
7
  import * as Components from '../index.js'
8
8
  import { markdown } from '../markdownParser.js'
9
+ import { UnknownComponentTypeError } from '../../errors.js'
9
10
  import { type Translator } from '../../i18n/types.js'
10
11
  import { type FormState } from '../../types.js'
11
12
 
@@ -210,7 +211,7 @@ export function createComponent(
210
211
  }
211
212
 
212
213
  if (typeof component === 'undefined') {
213
- throw new Error(`Component type ${def.type} does not exist`)
214
+ throw new UnknownComponentTypeError(def.type)
214
215
  }
215
216
 
216
217
  return component
@@ -1,5 +1,4 @@
1
1
  import {
2
- GeospatialFieldOptionsCountryEnum,
3
2
  type GeospatialFieldComponent,
4
3
  type GeospatialFieldOptionsCountry
5
4
  } from '@defra/forms-model'
@@ -20,13 +19,6 @@ import {
20
19
  } from '../../types.js'
21
20
  import { countries } from '../../../map/routes/index.js'
22
21
 
23
- const countriesDesc: Record<GeospatialFieldOptionsCountryEnum, string> = {
24
- [GeospatialFieldOptionsCountryEnum.England]: 'England',
25
- [GeospatialFieldOptionsCountryEnum.NorthernIreland]: 'Northern Ireland',
26
- [GeospatialFieldOptionsCountryEnum.Scotland]: 'Scotland',
27
- [GeospatialFieldOptionsCountryEnum.Wales]: 'Wales'
28
- }
29
-
30
22
  const Joi = JoiBase.extend({
31
23
  type: 'array',
32
24
  base: JoiBase.array(),
@@ -103,25 +95,43 @@ const featureSchema = Joi.object<Feature>().keys({
103
95
  geometry: featureGeometrySchema
104
96
  })
105
97
 
106
- function applySchemaConstraints(
107
- schema: JoiBase.ArraySchema<Feature[]>,
108
- def: GeospatialFieldComponent
98
+ export function determineLimits(
99
+ def: GeospatialFieldComponent,
100
+ isOptional: boolean
109
101
  ) {
110
- const { options, schema: constraints } = def
111
- const isOptional = options.required === false
112
-
102
+ const { schema: constraints } = def
103
+ const limits: { min?: number; max?: number; length?: number } = {}
113
104
  if (typeof constraints?.length === 'number') {
114
- schema = schema.length(constraints.length)
105
+ limits.length = constraints.length
115
106
  } else {
116
107
  if (typeof constraints?.min === 'number') {
117
- schema = schema.min(constraints.min)
108
+ limits.min = constraints.min
118
109
  } else if (!isOptional) {
119
- schema = schema.min(1)
110
+ limits.min = 1
120
111
  }
121
112
 
122
- schema = schema.max(
123
- typeof constraints?.max === 'number' ? constraints.max : 50
124
- )
113
+ limits.max = typeof constraints?.max === 'number' ? constraints.max : 50
114
+ }
115
+ return limits
116
+ }
117
+
118
+ function applySchemaConstraints(
119
+ schema: JoiBase.ArraySchema<Feature[]>,
120
+ def: GeospatialFieldComponent
121
+ ) {
122
+ const { options } = def
123
+ const isOptional = options.required === false
124
+
125
+ const limits = determineLimits(def, isOptional)
126
+
127
+ if (limits.length !== undefined) {
128
+ schema = schema.length(limits.length)
129
+ }
130
+ if (limits.min !== undefined) {
131
+ schema = schema.min(limits.min)
132
+ }
133
+ if (limits.max !== undefined) {
134
+ schema = schema.max(limits.max)
125
135
  }
126
136
 
127
137
  if (isOptional) {
@@ -161,7 +171,7 @@ export function getGeospatialSchema(
161
171
 
162
172
  if (!result) {
163
173
  return helpers.error('any.custom', {
164
- country: countriesDesc[country as GeospatialFieldOptionsCountryEnum]
174
+ country
165
175
  })
166
176
  }
167
177
 
@@ -212,7 +222,7 @@ export function getEastingNorthingCountryValidator(
212
222
  return helpers.error(
213
223
  'any.custom',
214
224
  {
215
- country: countriesDesc[country as GeospatialFieldOptionsCountryEnum]
225
+ country
216
226
  },
217
227
  { path: [field.name] }
218
228
  )
@@ -0,0 +1,78 @@
1
+ import { type ValidationError } from 'joi'
2
+
3
+ /**
4
+ * Base class for errors caused by an invalid form definition — whether it
5
+ * fails schema validation or passes the schema but cannot be used by the
6
+ * engine. Never thrown directly — throw a subclass. Consumers (e.g.
7
+ * forms-runner error pages) detect the family with
8
+ * `instanceof InvalidFormDefinitionError`.
9
+ *
10
+ * When subclassing, remember that the message and the messages of any
11
+ * `cause` chain are shown to form authors on preview error pages. Only
12
+ * describe the problem with the form itself — never include sensitive
13
+ * details such as file paths, configuration values or secrets.
14
+ */
15
+ export class InvalidFormDefinitionError extends Error {
16
+ constructor(message: string, options?: ErrorOptions) {
17
+ super(message, options)
18
+ this.name = 'InvalidFormDefinitionError'
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Thrown when a condition in the definition cannot be compiled into an
24
+ * evaluatable expression (e.g. it references the wrong component or list).
25
+ */
26
+ export class ConditionBuildError extends InvalidFormDefinitionError {
27
+ public readonly conditionName: string
28
+
29
+ constructor(conditionName: string, options?: ErrorOptions) {
30
+ super(`Failed to build condition '${conditionName}'`, options)
31
+ this.name = 'ConditionBuildError'
32
+ this.conditionName = conditionName
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Thrown when a page names a controller that is neither built in nor
38
+ * registered by the host application.
39
+ */
40
+ export class UnknownPageControllerError extends InvalidFormDefinitionError {
41
+ public readonly controllerName: string
42
+
43
+ constructor(controllerName: string) {
44
+ super(`Page controller ${controllerName} does not exist`)
45
+ this.name = 'UnknownPageControllerError'
46
+ this.controllerName = controllerName
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Thrown when a component declares a type with no registered implementation.
52
+ */
53
+ export class UnknownComponentTypeError extends InvalidFormDefinitionError {
54
+ public readonly componentType: string
55
+
56
+ constructor(componentType: string) {
57
+ super(`Component type ${componentType} does not exist`)
58
+ this.name = 'UnknownComponentTypeError'
59
+ this.componentType = componentType
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Thrown when a form definition fails Joi schema validation. The raw
65
+ * ValidationError is preserved as `cause` so consumers can reach the
66
+ * per-field details; the message carries Joi's own summary so log lines
67
+ * remain diagnostic for consumers that only read `error.message`.
68
+ */
69
+ export class SchemaValidationError extends InvalidFormDefinitionError {
70
+ // Error types `cause` as unknown; this constructor only accepts a Joi
71
+ // ValidationError, so narrow the declaration for consumers (type-only).
72
+ declare cause: ValidationError
73
+
74
+ constructor(cause: ValidationError) {
75
+ super(`Invalid form definition: ${cause.message}`, { cause })
76
+ this.name = 'SchemaValidationError'
77
+ }
78
+ }