@defra/forms-engine-plugin 4.13.0 → 4.14.0

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 (32) 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 +14 -0
  4. package/.server/client/javascripts/geospatial-map.js +161 -70
  5. package/.server/client/javascripts/geospatial-map.js.map +1 -1
  6. package/.server/client/javascripts/map.d.ts +6 -0
  7. package/.server/client/javascripts/map.js +5 -0
  8. package/.server/client/javascripts/map.js.map +1 -1
  9. package/.server/client/javascripts/utils.d.ts +7 -0
  10. package/.server/client/javascripts/utils.js +21 -0
  11. package/.server/client/javascripts/utils.js.map +1 -0
  12. package/.server/server/forms/simple-form.yaml +9 -0
  13. package/.server/server/plugins/engine/components/GeospatialField.d.ts +1 -0
  14. package/.server/server/plugins/engine/components/GeospatialField.js +9 -5
  15. package/.server/server/plugins/engine/components/GeospatialField.js.map +1 -1
  16. package/.server/server/plugins/engine/components/helpers/geospatial.d.ts +2 -2
  17. package/.server/server/plugins/engine/components/helpers/geospatial.js +32 -5
  18. package/.server/server/plugins/engine/components/helpers/geospatial.js.map +1 -1
  19. package/.server/server/plugins/engine/components/helpers/geospatial.test.js +37 -6
  20. package/.server/server/plugins/engine/components/helpers/geospatial.test.js.map +1 -1
  21. package/.server/server/plugins/engine/pageControllers/validationOptions.js +4 -1
  22. package/.server/server/plugins/engine/pageControllers/validationOptions.js.map +1 -1
  23. package/.server/server/plugins/engine/views/components/geospatialfield.html +1 -1
  24. package/package.json +2 -2
  25. package/src/client/javascripts/geospatial-map.js +159 -53
  26. package/src/client/javascripts/map.js +5 -0
  27. package/src/client/javascripts/utils.js +23 -0
  28. package/src/server/forms/simple-form.yaml +9 -0
  29. package/src/server/plugins/engine/components/GeospatialField.ts +11 -7
  30. package/src/server/plugins/engine/components/helpers/geospatial.ts +49 -11
  31. package/src/server/plugins/engine/pageControllers/validationOptions.ts +4 -1
  32. package/src/server/plugins/engine/views/components/geospatialfield.html +1 -1
@@ -7,6 +7,7 @@ import {
7
7
  getCentroidGridRef,
8
8
  getCoordinateGridRef
9
9
  } from './map.js'
10
+ import { formatDelimtedList } from './utils.js'
10
11
 
11
12
  const helpPanelConfig = {
12
13
  showLabel: true,
@@ -28,8 +29,63 @@ const helpPanelConfig = {
28
29
  open: true,
29
30
  dismissible: true,
30
31
  modal: false
31
- },
32
- html: '<p class="govuk-body-s govuk-!-margin-bottom-2">You can add points, shapes or lines to the map.</p><ul class="govuk-list govuk-list--number govuk-body-s"><li>Search for a county, place or postcode</li><li>Use the + and - icons to zoom in and out</li><li>Double‑click, or select \'Done\', when you have finished drawing a line or shape</li><li>Give the location a name</li></ul>'
32
+ }
33
+ }
34
+
35
+ /**
36
+ * @param {boolean} allowLine
37
+ * @param {boolean} allowShape
38
+ */
39
+ function getLineOrShapeText(allowLine, allowShape) {
40
+ if (allowLine && allowShape) {
41
+ return 'a line or shape'
42
+ }
43
+ if (allowLine) {
44
+ return 'a line'
45
+ }
46
+ if (allowShape) {
47
+ return 'a shape'
48
+ }
49
+ return ''
50
+ }
51
+
52
+ /**
53
+ * @param {boolean} allowPoint
54
+ * @param {boolean} allowLine
55
+ * @param {boolean} allowShape
56
+ */
57
+ function getAllowedTypesPhrase(allowPoint, allowLine, allowShape) {
58
+ const items = []
59
+
60
+ if (allowPoint) {
61
+ items.push('points')
62
+ }
63
+ if (allowLine) {
64
+ items.push('lines')
65
+ }
66
+ if (allowShape) {
67
+ items.push('shapes')
68
+ }
69
+
70
+ return formatDelimtedList(items, ',', 'or')
71
+ }
72
+
73
+ /**
74
+ * @param {boolean} allowPoint
75
+ * @param {boolean} allowLine
76
+ * @param {boolean} allowShape
77
+ */
78
+ export function getHelpPanelHtml(allowPoint, allowLine, allowShape) {
79
+ const lineOrShapeText = getLineOrShapeText(allowLine, allowShape)
80
+ const doneExtra = lineOrShapeText
81
+ ? `<li>Double‑click, or select 'Done', when you have finished drawing ${lineOrShapeText}</li>`
82
+ : ''
83
+ const allowedTypesText = getAllowedTypesPhrase(
84
+ allowPoint,
85
+ allowLine,
86
+ allowShape
87
+ )
88
+ return `<p class="govuk-body-s govuk-!-margin-bottom-2">You can add ${allowedTypesText} to the map.</p><ul class="govuk-list govuk-list--number govuk-body-s"><li>Search for a county, place or postcode</li><li>Use the + and - icons to zoom in and out</li>${doneExtra}<li>Give the location a name</li></ul>`
33
89
  }
34
90
 
35
91
  const lineFeatureProperties = {
@@ -157,7 +213,18 @@ export function processGeospatial(config, geospatial, index) {
157
213
  const { map, interactPlugin } = createMap(mapId, initConfig, config)
158
214
  const featuresManager = getFeaturesManager(geojson)
159
215
  const activeFeatureManager = getActiveFeatureManager()
160
- const uiManager = getUIManager(geojson, map, mapId, listEl, geospatialInput)
216
+ const geometryTypes = geospatial.dataset.geometrytypes ?? 'point,line,shape'
217
+ const options = {
218
+ geometryTypes
219
+ }
220
+ const uiManager = getUIManager(
221
+ geojson,
222
+ map,
223
+ mapId,
224
+ listEl,
225
+ geospatialInput,
226
+ options
227
+ )
161
228
 
162
229
  /**
163
230
  * @type {Context}
@@ -492,16 +559,32 @@ function getValueRenderer(geojson, geospatialInput) {
492
559
  * @param {string} mapId - the ID of the map
493
560
  * @param {HTMLDivElement} listEl - where to render the feature list
494
561
  * @param {HTMLTextAreaElement} geospatialInput - the geospatial textarea
562
+ * @param {UIManagerOptions} options - extra options such as allowable geometry types
495
563
  */
496
- function getUIManager(geojson, map, mapId, listEl, geospatialInput) {
564
+ function getUIManager(geojson, map, mapId, listEl, geospatialInput, options) {
565
+ /**
566
+ * Get a CSV list of geometry types the user can create
567
+ * @returns {string[]}
568
+ */
569
+ function getAllowableGeometryTypes() {
570
+ return options.geometryTypes ? options.geometryTypes.split(',') : []
571
+ }
572
+
497
573
  /**
498
574
  * Toggle the hidden state of the action buttons
499
575
  * @type {ToggleActionButtons}
500
576
  */
501
577
  function toggleActionButtons(hidden) {
502
- map.toggleButtonState('btnAddPoint', 'hidden', hidden)
503
- map.toggleButtonState('btnAddPolygon', 'hidden', hidden)
504
- map.toggleButtonState('btnAddLine', 'hidden', hidden)
578
+ const types = getAllowableGeometryTypes()
579
+ if (types.includes('point')) {
580
+ map.toggleButtonState('btnAddPoint', 'hidden', hidden)
581
+ }
582
+ if (types.includes('shape')) {
583
+ map.toggleButtonState('btnAddPolygon', 'hidden', hidden)
584
+ }
585
+ if (types.includes('line')) {
586
+ map.toggleButtonState('btnAddLine', 'hidden', hidden)
587
+ }
505
588
  }
506
589
 
507
590
  /**
@@ -528,7 +611,8 @@ function getUIManager(geojson, map, mapId, listEl, geospatialInput) {
528
611
  renderValue,
529
612
  listEl,
530
613
  toggleActionButtons,
531
- focusDescriptionInput
614
+ focusDescriptionInput,
615
+ getAllowableGeometryTypes
532
616
  }
533
617
  }
534
618
 
@@ -572,7 +656,8 @@ function createContainers(geospatialInput, index) {
572
656
  function onMapReadyFactory(context) {
573
657
  const { map, activeFeatureManager, uiManager, interactPlugin, drawPlugin } =
574
658
  context
575
- const { toggleActionButtons, renderList } = uiManager
659
+ const { toggleActionButtons, renderList, getAllowableGeometryTypes } =
660
+ uiManager
576
661
  const { resetActiveFeature } = activeFeatureManager
577
662
 
578
663
  /**
@@ -581,53 +666,67 @@ function onMapReadyFactory(context) {
581
666
  * @param {MapLibreMap} e.map - the map provider instance
582
667
  */
583
668
  return function onMapReady(e) {
669
+ const types = getAllowableGeometryTypes()
670
+ const allowPoint = types.includes('point')
671
+ const allowLine = types.includes('line')
672
+ const allowShape = types.includes('shape')
673
+
584
674
  // Add info panel
585
- map.addPanel('info', helpPanelConfig)
586
-
587
- map.addButton('btnAddPoint', {
588
- variant: 'tertiary',
589
- label: 'Add point',
590
- iconSvgContent: POINT_SVG,
591
- onClick: () => {
592
- resetActiveFeature()
593
- toggleActionButtons(true)
594
- renderList(true)
595
- interactPlugin.enable()
596
- },
597
- mobile: { slot: 'actions' },
598
- tablet: { slot: 'actions' },
599
- desktop: { slot: 'actions' }
675
+ map.addPanel('info', {
676
+ ...helpPanelConfig,
677
+ html: getHelpPanelHtml(allowPoint, allowLine, allowShape)
600
678
  })
601
679
 
602
- map.addButton('btnAddPolygon', {
603
- variant: 'tertiary',
604
- label: 'Add shape',
605
- iconSvgContent: POLYGON_SVG,
606
- onClick: () => {
607
- resetActiveFeature()
608
- toggleActionButtons(true)
609
- renderList(true)
610
- drawPlugin.newPolygon(generateID(), polygonFeatureProperties)
611
- },
612
- mobile: { slot: 'actions' },
613
- tablet: { slot: 'actions' },
614
- desktop: { slot: 'actions' }
615
- })
680
+ if (allowPoint) {
681
+ map.addButton('btnAddPoint', {
682
+ variant: 'tertiary',
683
+ label: 'Add point',
684
+ iconSvgContent: POINT_SVG,
685
+ onClick: () => {
686
+ resetActiveFeature()
687
+ toggleActionButtons(true)
688
+ renderList(true)
689
+ interactPlugin.enable()
690
+ },
691
+ mobile: { slot: 'actions' },
692
+ tablet: { slot: 'actions' },
693
+ desktop: { slot: 'actions' }
694
+ })
695
+ }
616
696
 
617
- map.addButton('btnAddLine', {
618
- variant: 'tertiary',
619
- label: 'Add line',
620
- iconSvgContent: LINE_SVG,
621
- onClick: () => {
622
- resetActiveFeature()
623
- toggleActionButtons(true)
624
- renderList(true)
625
- drawPlugin.newLine(generateID(), lineFeatureProperties)
626
- },
627
- mobile: { slot: 'actions' },
628
- tablet: { slot: 'actions' },
629
- desktop: { slot: 'actions' }
630
- })
697
+ if (allowShape) {
698
+ map.addButton('btnAddPolygon', {
699
+ variant: 'tertiary',
700
+ label: 'Add shape',
701
+ iconSvgContent: POLYGON_SVG,
702
+ onClick: () => {
703
+ resetActiveFeature()
704
+ toggleActionButtons(true)
705
+ renderList(true)
706
+ drawPlugin.newPolygon(generateID(), polygonFeatureProperties)
707
+ },
708
+ mobile: { slot: 'actions' },
709
+ tablet: { slot: 'actions' },
710
+ desktop: { slot: 'actions' }
711
+ })
712
+ }
713
+
714
+ if (allowLine) {
715
+ map.addButton('btnAddLine', {
716
+ variant: 'tertiary',
717
+ label: 'Add line',
718
+ iconSvgContent: LINE_SVG,
719
+ onClick: () => {
720
+ resetActiveFeature()
721
+ toggleActionButtons(true)
722
+ renderList(true)
723
+ drawPlugin.newLine(generateID(), lineFeatureProperties)
724
+ },
725
+ mobile: { slot: 'actions' },
726
+ tablet: { slot: 'actions' },
727
+ desktop: { slot: 'actions' }
728
+ })
729
+ }
631
730
 
632
731
  // Set the map provider on the context
633
732
  context.mapProvider = e.map
@@ -1055,6 +1154,12 @@ function onListElKeydownFactory() {
1055
1154
  * @returns {void}
1056
1155
  */
1057
1156
 
1157
+ /**
1158
+ * Returns the list of geometry types a user can create
1159
+ * @callback GetAllowableGeometryTypes
1160
+ * @returns {string[]}
1161
+ */
1162
+
1058
1163
  /**
1059
1164
  * Set focus to the last description input
1060
1165
  * @callback FocusDescriptionInput
@@ -1084,6 +1189,7 @@ function onListElKeydownFactory() {
1084
1189
  * @property {HTMLDivElement} listEl - the summary list of features
1085
1190
  * @property {ToggleActionButtons} toggleActionButtons - function that toggles the action buttons
1086
1191
  * @property {FocusDescriptionInput} focusDescriptionInput - function that sets focus to a description input element
1192
+ * @property {GetAllowableGeometryTypes} getAllowableGeometryTypes - function that returns the array of geometry types a user can create
1087
1193
  */
1088
1194
 
1089
1195
  /**
@@ -1098,5 +1204,5 @@ function onListElKeydownFactory() {
1098
1204
  */
1099
1205
 
1100
1206
  /**
1101
- * @import { MapLibreMap } from './map.js'
1207
+ * @import { MapLibreMap, UIManagerOptions } from './map.js'
1102
1208
  */
@@ -395,6 +395,11 @@ export function centerMap(map, mapProvider, center) {
395
395
  * @property {TileData} data - the tile data config
396
396
  */
397
397
 
398
+ /**
399
+ * @typedef {object} UIManagerOptions
400
+ * @property {string} [geometryTypes] - the CSV list of geometry types that a user can create
401
+ */
402
+
398
403
  /**
399
404
  * @import { Feature } from '../../server/plugins/engine/types.js'
400
405
  */
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Builds a text representation of a list in the form 'a, b, c, d or e'
3
+ * @param {string[]} items
4
+ * @param {string} separator
5
+ * @param {string} lastSpearator
6
+ */
7
+ export function formatDelimtedList(items, separator, lastSpearator) {
8
+ if (items.length === 0) {
9
+ return ''
10
+ }
11
+
12
+ if (items.length === 1) {
13
+ return items[0]
14
+ }
15
+
16
+ if (items.length === 2) {
17
+ return `${items[0]} ${lastSpearator} ${items[1]}`
18
+ }
19
+
20
+ const last = items.pop()
21
+ const separatorAndSpace = `${separator} `
22
+ return `${items.join(separatorAndSpace)} ${lastSpearator} ${last}`
23
+ }
@@ -25,6 +25,15 @@ pages:
25
25
  required: true
26
26
  schema: {}
27
27
  id: b68df7f1-d4f4-4c17-83c8-402f584906c9
28
+ - type: GeospatialField
29
+ title: Where do you live?
30
+ name: applicantLocation
31
+ shortDescription: Your location
32
+ hint: ''
33
+ options:
34
+ required: true
35
+ schema: {}
36
+ id: e18116e0-7c3e-416a-af42-6f229017c5b1
28
37
  next: []
29
38
  id: 622a35ec-3795-418a-81f3-a45746959045
30
39
  - title: Upload a copy of your passport
@@ -31,18 +31,21 @@ export class GeospatialField extends FormComponent {
31
31
 
32
32
  const { options } = def
33
33
 
34
- let formSchema = getGeospatialSchema(options.countries?.at(0))
34
+ const formSchema = getGeospatialSchema(def)
35
35
  .label(this.label)
36
- .required()
36
+ .messages({
37
+ 'array.min': messageTemplate.featuresMin as string,
38
+ 'array.max': messageTemplate.featuresMax as string,
39
+ 'array.length': messageTemplate.featuresLength as string
40
+ })
37
41
 
38
- formSchema = formSchema.max(50)
42
+ this.formSchema = formSchema
43
+ this.stateSchema = formSchema.default(null)
39
44
 
40
- if (options.required !== false) {
41
- formSchema = formSchema.min(1)
45
+ if (options.required === false) {
46
+ this.stateSchema = this.stateSchema.allow(null)
42
47
  }
43
48
 
44
- this.formSchema = formSchema
45
- this.stateSchema = formSchema.default(null)
46
49
  this.options = options
47
50
  }
48
51
 
@@ -93,6 +96,7 @@ export class GeospatialField extends FormComponent {
93
96
  return {
94
97
  ...viewModel,
95
98
  country: this.options.countries?.at(0),
99
+ geometryTypes: this.options.geometryTypes,
96
100
  value
97
101
  }
98
102
  }
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  GeospatialFieldOptionsCountryEnum,
3
+ type GeospatialFieldComponent,
3
4
  type GeospatialFieldOptionsCountry
4
5
  } from '@defra/forms-model'
5
6
  import Bourne from '@hapi/bourne'
@@ -31,7 +32,8 @@ const Joi = JoiBase.extend({
31
32
  from: 'string',
32
33
  method(value, helpers) {
33
34
  if (typeof value === 'string') {
34
- if (value.trim() === '') {
35
+ const trimmed = value.trim()
36
+ if (trimmed === '' || trimmed === '[]') {
35
37
  return {
36
38
  value: undefined
37
39
  }
@@ -96,14 +98,48 @@ const featureSchema = Joi.object<Feature>().keys({
96
98
  geometry: featureGeometrySchema
97
99
  })
98
100
 
99
- const geospatialSchema = Joi.array<Feature[]>()
100
- .items(featureSchema)
101
- .unique('id')
102
- .required()
101
+ function applySchemaConstraints(
102
+ schema: JoiBase.ArraySchema<Feature[]>,
103
+ def: GeospatialFieldComponent
104
+ ) {
105
+ const { options, schema: constraints } = def
106
+ const isOptional = options.required === false
107
+
108
+ if (typeof constraints?.length === 'number') {
109
+ schema = schema.length(constraints.length)
110
+ } else {
111
+ if (typeof constraints?.min === 'number') {
112
+ schema = schema.min(constraints.min)
113
+ } else if (!isOptional) {
114
+ schema = schema.min(1)
115
+ }
116
+
117
+ schema = schema.max(
118
+ typeof constraints?.max === 'number' ? constraints.max : 50
119
+ )
120
+ }
121
+
122
+ if (isOptional) {
123
+ schema = schema.optional()
124
+ } else {
125
+ schema = schema.required()
126
+ }
127
+
128
+ return schema
129
+ }
130
+
131
+ export function getGeospatialSchema(
132
+ def: GeospatialFieldComponent
133
+ ): JoiBase.ArraySchema<Feature[]> {
134
+ const { options = {} } = def
135
+ const country: GeospatialFieldOptionsCountry | undefined =
136
+ options.countries?.at(0)
103
137
 
104
- export function getGeospatialSchema(country?: GeospatialFieldOptionsCountry) {
105
138
  if (!country) {
106
- return geospatialSchema
139
+ return applySchemaConstraints(
140
+ Joi.array<Feature[]>().items(featureSchema).unique('id'),
141
+ def
142
+ )
107
143
  }
108
144
 
109
145
  const validateCountryBounds: CustomValidator = (value, helpers) => {
@@ -128,10 +164,12 @@ export function getGeospatialSchema(country?: GeospatialFieldOptionsCountry) {
128
164
  return value
129
165
  }
130
166
 
131
- return Joi.array<Feature[]>()
132
- .items(featureSchema.custom(validateCountryBounds))
133
- .unique('id')
134
- .required()
167
+ return applySchemaConstraints(
168
+ Joi.array<Feature[]>()
169
+ .items(featureSchema.custom(validateCountryBounds))
170
+ .unique('id'),
171
+ def
172
+ )
135
173
  }
136
174
 
137
175
  /**
@@ -64,7 +64,10 @@ export const messageTemplate: Record<string, JoiExpression> = {
64
64
  dateMax: '{{#title}} must be the same as or before {{#limit}}',
65
65
  arrayMin: 'Select at least {{#limit}} options from the list',
66
66
  arrayMax: 'Only {{#limit}} can be selected from the list',
67
- arrayLength: 'Select only {{#limit}} options from the list'
67
+ arrayLength: 'Select only {{#limit}} options from the list',
68
+ featuresMin: 'Define at least {{#limit}} features',
69
+ featuresMax: 'Only {{#limit}} features can be defined',
70
+ featuresLength: 'Define exactly {{#limit}} features'
68
71
  }
69
72
 
70
73
  export const messages: LanguageMessagesExt = {
@@ -1,7 +1,7 @@
1
1
  {% from "govuk/components/textarea/macro.njk" import govukTextarea %}
2
2
 
3
3
  {% macro GeospatialField(component) %}
4
- <div class="app-geospatial-field" data-country="{{component.model.country}}">
4
+ <div class="app-geospatial-field" data-country="{{component.model.country}}" data-geometryTypes="{{component.model.geometryTypes}}">
5
5
  {{ govukTextarea(component.model) }}
6
6
  </div>
7
7
  {% endmacro %}