@defra/forms-engine-plugin 5.0.0-alpha.12 → 5.0.0-alpha.14

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 (59) 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 +44 -0
  7. package/.server/client/javascripts/location-map.js +43 -3
  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/EastingNorthingField.d.ts +2 -0
  19. package/.server/server/plugins/engine/components/GeospatialField.d.ts +1 -0
  20. package/.server/server/plugins/engine/components/GeospatialField.js +1 -0
  21. package/.server/server/plugins/engine/components/GeospatialField.js.map +1 -1
  22. package/.server/server/plugins/engine/components/LatLongField.d.ts +2 -0
  23. package/.server/server/plugins/engine/components/LocationFieldBase.js +3 -0
  24. package/.server/server/plugins/engine/components/LocationFieldBase.js.map +1 -1
  25. package/.server/server/plugins/engine/components/LocationFieldHelpers.d.ts +2 -0
  26. package/.server/server/plugins/engine/components/LocationFieldHelpers.js +2 -1
  27. package/.server/server/plugins/engine/components/LocationFieldHelpers.js.map +1 -1
  28. package/.server/server/plugins/engine/components/helpers/components.js +2 -1
  29. package/.server/server/plugins/engine/components/helpers/components.js.map +1 -1
  30. package/.server/server/plugins/engine/errors.d.ts +49 -0
  31. package/.server/server/plugins/engine/errors.js +75 -0
  32. package/.server/server/plugins/engine/errors.js.map +1 -0
  33. package/.server/server/plugins/engine/i18n/translations/cy.json +51 -1
  34. package/.server/server/plugins/engine/i18n/translations/en-GB.json +51 -1
  35. package/.server/server/plugins/engine/models/FormModel.js +10 -2
  36. package/.server/server/plugins/engine/models/FormModel.js.map +1 -1
  37. package/.server/server/plugins/engine/pageControllers/helpers/pages.js +2 -1
  38. package/.server/server/plugins/engine/pageControllers/helpers/pages.js.map +1 -1
  39. package/.server/server/plugins/engine/views/components/_location-field-base.html +1 -0
  40. package/.server/server/plugins/engine/views/components/geospatialfield.html +1 -0
  41. package/.server/server/plugins/engine/views/components/osgridreffield.html +3 -1
  42. package/package.json +1 -1
  43. package/src/client/javascripts/geospatial-map.js +169 -47
  44. package/src/client/javascripts/location-map.js +51 -3
  45. package/src/client/javascripts/map-constants.js +3 -0
  46. package/src/client/javascripts/map.js +5 -0
  47. package/src/server/plugins/engine/__stubs__/definitions.ts +155 -0
  48. package/src/server/plugins/engine/components/GeospatialField.ts +1 -0
  49. package/src/server/plugins/engine/components/LocationFieldBase.ts +4 -0
  50. package/src/server/plugins/engine/components/LocationFieldHelpers.ts +2 -1
  51. package/src/server/plugins/engine/components/helpers/components.ts +2 -1
  52. package/src/server/plugins/engine/errors.ts +78 -0
  53. package/src/server/plugins/engine/i18n/translations/cy.json +51 -1
  54. package/src/server/plugins/engine/i18n/translations/en-GB.json +51 -1
  55. package/src/server/plugins/engine/models/FormModel.ts +12 -2
  56. package/src/server/plugins/engine/pageControllers/helpers/pages.ts +2 -1
  57. package/src/server/plugins/engine/views/components/_location-field-base.html +1 -0
  58. package/src/server/plugins/engine/views/components/geospatialfield.html +1 -0
  59. package/src/server/plugins/engine/views/components/osgridreffield.html +3 -1
@@ -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
+ }
@@ -111,6 +111,7 @@ export class GeospatialField extends FormComponent {
111
111
 
112
112
  return {
113
113
  ...viewModel,
114
+ lang: context.translator.language,
114
115
  country: this.options.countries?.at(0),
115
116
  geometryTypes: this.options.geometryTypes,
116
117
  mapLayers: getMapLayers(this),
@@ -124,6 +124,10 @@ 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) {
128
132
  const { tComponent } = context.translator
129
133
  return {
@@ -194,7 +194,8 @@ 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) {
@@ -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
@@ -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
+ }
@@ -323,7 +323,16 @@
323
323
  },
324
324
 
325
325
  "locationFieldBase": {
326
- "howToFind": "Sut i ddod o hyd i fanylion lleoliad"
326
+ "howToFind": "Sut i ddod o hyd i fanylion lleoliad",
327
+ "map": {
328
+ "helpPanel": {
329
+ "label": "Sut i ddefnyddio'r map hwn",
330
+ "point1": "Chwilio am sir, lle neu god post",
331
+ "point2": "Defnyddiwch yr eiconau + a - i chwyddo i mewn ac allan",
332
+ "point3": "Defnyddiwch lyfliwr neu allweddlon i ganolbwyntio'r pwynt yn y lleoliad",
333
+ "point4": "Cliciwch i ychwanegu'r lleoliad i'r map"
334
+ }
335
+ }
327
336
  },
328
337
 
329
338
  "yesNoField": {
@@ -350,6 +359,47 @@
350
359
  "added_few": "Ychwanegwyd [[count]] lleoliad",
351
360
  "added_many": "Ychwanegwyd [[count]] o leoliadau",
352
361
  "added_other": "Ychwanegwyd [[count]] lleoliad",
362
+ "map": {
363
+ "helpPanel": {
364
+ "label": "Sut i ddefnyddio'r map hwn",
365
+ "ledeIntro": "Gallwch ychwanegu",
366
+ "ledeOutro": "i'r map.",
367
+ "lineText": "llinell",
368
+ "shapeText": "siâp",
369
+ "lineOrShapeText": "llinell neu siâp",
370
+ "typesPhrasePoint": "pwyntiau",
371
+ "typesPhraseLine": "lliniau",
372
+ "typesPhraseShape": "siâpiau",
373
+ "typesPhraseOr": "neu",
374
+ "point1": "Chwilio am sir, lle neu god post",
375
+ "point2": "Defnyddiwch yr eiconau + a - i chwyddo i mewn ac allan",
376
+ "point3": "Cliciwch ddwywaith, neu dewiswch 'Wedi gorffen', pan fyddwch wedi",
377
+ "point4": "Rhowch enw i'r lleoliad"
378
+ },
379
+ "typeDescriptions": {
380
+ "point": "Pwynt",
381
+ "line": "Llinell",
382
+ "polygon": "Siâp"
383
+ },
384
+ "buttons": {
385
+ "point": "Ychwanegu pwynt",
386
+ "line": "Ychwanegu llinell",
387
+ "polygon": "Ychwanegu siâp"
388
+ },
389
+ "itemLabel1": "Lleoliad",
390
+ "itemLabel2": "disgrifiad",
391
+ "itemUpdateLink": "Diweddaru",
392
+ "itemDeleteLink": "Dileu",
393
+ "itemFocusLink": "Dangos",
394
+ "itemVisuallyHidden": "lleoliad",
395
+ "details": {
396
+ "summary": "Cyfesurynnau",
397
+ "type": "Math",
398
+ "centroidGridReference": "Canol cyfeirnod grid",
399
+ "firstPointGridReference": "Cyfeirnod grid y pwynt cyntaf",
400
+ "detailedCoordinates": "Cyfesurynnau manwl"
401
+ }
402
+ },
353
403
  "validation": {
354
404
  "descriptionRequired": "Rhowch ddisgrifiad ar gyfer lleoliad [[count]]",
355
405
  "wrongCountry": "Rhaid i leoliad [[count]] fod y tu mewn i [[country]]"
@@ -266,7 +266,16 @@
266
266
  },
267
267
 
268
268
  "locationFieldBase": {
269
- "howToFind": "How to find location details"
269
+ "howToFind": "How to find location details",
270
+ "map": {
271
+ "helpPanel": {
272
+ "label": "How to use this map",
273
+ "point1": "Search for a place or postcode",
274
+ "point2": "Use the + and - icons to zoom in and out",
275
+ "point3": "Use a mouse or keyboard to centre the point at the location",
276
+ "point4": "Click to add the location to the map"
277
+ }
278
+ }
270
279
  },
271
280
 
272
281
  "yesNoField": {
@@ -289,6 +298,47 @@
289
298
  "geospatialField": {
290
299
  "added_one": "Added [[count]] location",
291
300
  "added_other": "Added [[count]] locations",
301
+ "map": {
302
+ "helpPanel": {
303
+ "label": "How to use this map",
304
+ "ledeIntro": "You can add",
305
+ "ledeOutro": "to the map.",
306
+ "lineText": "a line",
307
+ "shapeText": "a shape",
308
+ "lineOrShapeText": "a line or shape",
309
+ "typesPhrasePoint": "points",
310
+ "typesPhraseLine": "lines",
311
+ "typesPhraseShape": "shapes",
312
+ "typesPhraseOr": "or",
313
+ "point1": "Search for a county, place or postcode",
314
+ "point2": "Use the + and - icons to zoom in and out",
315
+ "point3": "Double‑click, or select 'Done', when you have finished drawing",
316
+ "point4": "Give the location a name"
317
+ },
318
+ "typeDescriptions": {
319
+ "point": "Point",
320
+ "line": "Line",
321
+ "polygon": "Shape"
322
+ },
323
+ "buttons": {
324
+ "point": "Add point",
325
+ "line": "Add line",
326
+ "polygon": "Add shape"
327
+ },
328
+ "itemLabel1": "Location",
329
+ "itemLabel2": "description",
330
+ "itemUpdateLink": "Update",
331
+ "itemDeleteLink": "Delete",
332
+ "itemFocusLink": "Show",
333
+ "itemVisuallyHidden": "location",
334
+ "details": {
335
+ "summary": "Coordinates",
336
+ "type": "Type",
337
+ "centroidGridReference": "Centre grid reference",
338
+ "firstPointGridReference": "First point grid reference",
339
+ "detailedCoordinates": "Detailed coordinates"
340
+ }
341
+ },
292
342
  "validation": {
293
343
  "descriptionRequired": "Enter description for location [[count]]",
294
344
  "wrongCountry": "Location [[count]] must be within [[country]]"
@@ -39,6 +39,10 @@ import {
39
39
  type Component
40
40
  } from '../components/helpers/components.js'
41
41
  import { todayAsDateOnly } from '../date-helper.js'
42
+ import {
43
+ ConditionBuildError,
44
+ SchemaValidationError
45
+ } from '../errors.js'
42
46
  import {
43
47
  findPage,
44
48
  getError,
@@ -124,7 +128,7 @@ export class FormModel {
124
128
  const result = schema.validate(def, { abortEarly: false })
125
129
 
126
130
  if (result.error) {
127
- throw result.error
131
+ throw new SchemaValidationError(result.error)
128
132
  }
129
133
 
130
134
  // Make a clone of the shallow copy returned
@@ -292,7 +296,13 @@ export class FormModel {
292
296
  })
293
297
 
294
298
  const { name, displayName, value } = condition
295
- const expr = this.toConditionExpression(value, parser)
299
+
300
+ let expr
301
+ try {
302
+ expr = this.toConditionExpression(value, parser)
303
+ } catch (cause) {
304
+ throw new ConditionBuildError(displayName, { cause })
305
+ }
296
306
 
297
307
  const fn = (evaluationState: FormState) => {
298
308
  const ctx = this.toConditionContext(evaluationState, this.conditions)
@@ -4,6 +4,7 @@ import {
4
4
  type Page
5
5
  } from '@defra/forms-model'
6
6
 
7
+ import { UnknownPageControllerError } from '../../errors.js'
7
8
  import { type FormModel } from '../../models/index.js'
8
9
  import * as PageControllers from '../index.js'
9
10
 
@@ -66,7 +67,7 @@ export function createPage(model: FormModel, pageDef: Page) {
66
67
  }
67
68
 
68
69
  if (typeof controller === 'undefined') {
69
- throw new Error(`Page controller ${pageDef.controller} does not exist`)
70
+ throw new UnknownPageControllerError(pageDef.controller)
70
71
  }
71
72
 
72
73
  return controller
@@ -73,6 +73,7 @@
73
73
  <div id="{{component.model.name}}" class="govuk-form-group app-location-field {{ "govuk-form-group--error" if hasErrors }}"
74
74
  data-locationtype="{{component.type | lower}}"
75
75
  data-country="{{component.model.country}}"
76
+ data-lang="{{component.model.lang}}"
76
77
  data-maplayers="{{component.model.mapLayers}}">
77
78
  {{ govukFieldset({
78
79
  legend: {
@@ -2,6 +2,7 @@
2
2
 
3
3
  {% macro GeospatialField(component) %}
4
4
  <div class="app-geospatial-field"
5
+ data-lang="{{component.model.lang}}"
5
6
  data-country="{{component.model.country}}"
6
7
  data-geometryTypes="{{component.model.geometryTypes}}"
7
8
  data-maplayers="{{component.model.mapLayers}}">
@@ -3,7 +3,9 @@
3
3
 
4
4
  {% macro OsGridRefField(component) %}
5
5
  {% set hasErrors = component.model.errorMessage %}
6
- <div class="govuk-form-group app-location-field {{ "govuk-form-group--error" if hasErrors }}" data-locationtype="{{component.type | lower}}">
6
+ <div class="govuk-form-group app-location-field {{ "govuk-form-group--error" if hasErrors }}"
7
+ data-locationtype="{{component.type | lower}}"
8
+ data-lang="{{component.model.lang}}">
7
9
  <div class="app-location-field-inputs">
8
10
  {{ TextField(component) }}
9
11
  </div>