@defra/forms-engine-plugin 4.0.8 → 4.0.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@defra/forms-engine-plugin",
3
- "version": "4.0.8",
3
+ "version": "4.0.10",
4
4
  "description": "Defra forms engine",
5
5
  "type": "module",
6
6
  "files": [
@@ -70,7 +70,7 @@
70
70
  },
71
71
  "license": "SEE LICENSE IN LICENSE",
72
72
  "dependencies": {
73
- "@defra/forms-model": "^3.0.574",
73
+ "@defra/forms-model": "^3.0.575",
74
74
  "@defra/hapi-tracing": "^1.26.0",
75
75
  "@elastic/ecs-pino-format": "^1.5.0",
76
76
  "@hapi/boom": "^10.0.1",
@@ -556,10 +556,17 @@ describe('EastingNorthingField', () => {
556
556
  easting: 12345.5,
557
557
  northing: 1234567
558
558
  }),
559
+ // Two errors expected: decimal input triggers both integer validation
560
+ // and length validation ('12345.5' is 7 chars, max is 6)
559
561
  errors: [
560
562
  expect.objectContaining({
561
563
  text: expect.stringMatching(
562
- /Easting for .* must be between 1 and 5 digits/
564
+ /Easting for .* must be between 1 and 6 digits/
565
+ )
566
+ }),
567
+ expect.objectContaining({
568
+ text: expect.stringMatching(
569
+ /Easting for .* must be between 1 and 6 digits/
563
570
  )
564
571
  })
565
572
  ]
@@ -575,7 +582,14 @@ describe('EastingNorthingField', () => {
575
582
  easting: 12345,
576
583
  northing: 1234567.5
577
584
  }),
585
+ // Two errors expected: decimal input triggers both integer validation
586
+ // and length validation ('1234567.5' is 9 chars, max is 7)
578
587
  errors: [
588
+ expect.objectContaining({
589
+ text: expect.stringMatching(
590
+ /Northing for .* must be between 1 and 7 digits/
591
+ )
592
+ }),
579
593
  expect.objectContaining({
580
594
  text: expect.stringMatching(
581
595
  /Northing for .* must be between 1 and 7 digits/
@@ -28,10 +28,22 @@ import { convertToLanguageMessages } from '~/src/server/utils/type-utils.js'
28
28
 
29
29
  // British National Grid coordinate limits
30
30
  const DEFAULT_EASTING_MIN = 0
31
- const DEFAULT_EASTING_MAX = 70000
31
+ const DEFAULT_EASTING_MAX = 700000
32
32
  const DEFAULT_NORTHING_MIN = 0
33
33
  const DEFAULT_NORTHING_MAX = 1300000
34
34
 
35
+ // Easting length constraints (integer values only, no decimals)
36
+ // Min: 1 char for values like "0" or single digit values
37
+ // Max: 6 chars for values up to 700000 (British National Grid easting limit)
38
+ const EASTING_MIN_LENGTH = 1
39
+ const EASTING_MAX_LENGTH = 6
40
+
41
+ // Northing length constraints (integer values only, no decimals)
42
+ // Min: 1 char for values like "0" or single digit values
43
+ // Max: 7 chars for values up to 1300000 (British National Grid northing limit)
44
+ const NORTHING_MIN_LENGTH = 1
45
+ const NORTHING_MAX_LENGTH = 7
46
+
35
47
  export class EastingNorthingField extends FormComponent {
36
48
  declare options: EastingNorthingFieldComponent['options']
37
49
  declare formSchema: ObjectSchema<FormPayload>
@@ -59,9 +71,11 @@ export class EastingNorthingField extends FormComponent {
59
71
  'number.base': messageTemplate.objectMissing,
60
72
  'number.min': `{{#label}} for ${this.title} must be between {{#limit}} and ${eastingMax}`,
61
73
  'number.max': `{{#label}} for ${this.title} must be between ${eastingMin} and {{#limit}}`,
62
- 'number.precision': `{{#label}} for ${this.title} must be between 1 and 5 digits`,
63
- 'number.integer': `{{#label}} for ${this.title} must be between 1 and 5 digits`,
64
- 'number.unsafe': `{{#label}} for ${this.title} must be between 1 and 5 digits`
74
+ 'number.precision': `{{#label}} for ${this.title} must be between 1 and 6 digits`,
75
+ 'number.integer': `{{#label}} for ${this.title} must be between 1 and 6 digits`,
76
+ 'number.unsafe': `{{#label}} for ${this.title} must be between 1 and 6 digits`,
77
+ 'number.minLength': `{{#label}} for ${this.title} must be between 1 and 6 digits`,
78
+ 'number.maxLength': `{{#label}} for ${this.title} must be between 1 and 6 digits`
65
79
  })
66
80
 
67
81
  const northingValidationMessages: LanguageMessages =
@@ -72,7 +86,9 @@ export class EastingNorthingField extends FormComponent {
72
86
  'number.max': `{{#label}} for ${this.title} must be between ${northingMin} and {{#limit}}`,
73
87
  'number.precision': `{{#label}} for ${this.title} must be between 1 and 7 digits`,
74
88
  'number.integer': `{{#label}} for ${this.title} must be between 1 and 7 digits`,
75
- 'number.unsafe': `{{#label}} for ${this.title} must be between 1 and 7 digits`
89
+ 'number.unsafe': `{{#label}} for ${this.title} must be between 1 and 7 digits`,
90
+ 'number.minLength': `{{#label}} for ${this.title} must be between 1 and 7 digits`,
91
+ 'number.maxLength': `{{#label}} for ${this.title} must be between 1 and 7 digits`
76
92
  })
77
93
 
78
94
  this.collection = new ComponentCollection(
@@ -81,7 +97,13 @@ export class EastingNorthingField extends FormComponent {
81
97
  type: ComponentType.NumberField,
82
98
  name: `${name}__easting`,
83
99
  title: 'Easting',
84
- schema: { min: eastingMin, max: eastingMax, precision: 0 },
100
+ schema: {
101
+ min: eastingMin,
102
+ max: eastingMax,
103
+ precision: 0,
104
+ minLength: EASTING_MIN_LENGTH,
105
+ maxLength: EASTING_MAX_LENGTH
106
+ },
85
107
  options: {
86
108
  required: isRequired,
87
109
  optionalText: true,
@@ -93,7 +115,13 @@ export class EastingNorthingField extends FormComponent {
93
115
  type: ComponentType.NumberField,
94
116
  name: `${name}__northing`,
95
117
  title: 'Northing',
96
- schema: { min: northingMin, max: northingMax, precision: 0 },
118
+ schema: {
119
+ min: northingMin,
120
+ max: northingMax,
121
+ precision: 0,
122
+ minLength: NORTHING_MIN_LENGTH,
123
+ maxLength: NORTHING_MAX_LENGTH
124
+ },
97
125
  options: {
98
126
  required: isRequired,
99
127
  optionalText: true,
@@ -179,7 +207,7 @@ export class EastingNorthingField extends FormComponent {
179
207
  {
180
208
  type: 'eastingFormat',
181
209
  template:
182
- 'Easting for [short description] must be between 1 and 5 digits'
210
+ 'Easting for [short description] must be between 1 and 6 digits'
183
211
  },
184
212
  {
185
213
  type: 'northingFormat',
@@ -190,11 +218,11 @@ export class EastingNorthingField extends FormComponent {
190
218
  advancedSettingsErrors: [
191
219
  {
192
220
  type: 'eastingMin',
193
- template: `Easting for [short description] must be between ${DEFAULT_EASTING_MIN} and ${DEFAULT_EASTING_MAX}`
221
+ template: `Easting for [short description] must be between 0 and 700000`
194
222
  },
195
223
  {
196
224
  type: 'eastingMax',
197
- template: `Easting for [short description] must be between ${DEFAULT_EASTING_MIN} and ${DEFAULT_EASTING_MAX}`
225
+ template: `Easting for [short description] must be between 0 and 700000`
198
226
  },
199
227
  {
200
228
  type: 'northingMin',
@@ -149,8 +149,8 @@ describe('LatLongField', () => {
149
149
 
150
150
  const result2 = collection.validate(
151
151
  getFormData({
152
- latitude: '49',
153
- longitude: '-9'
152
+ latitude: '49.1',
153
+ longitude: '-8.9'
154
154
  })
155
155
  )
156
156
 
@@ -381,13 +381,7 @@ describe('LatLongField', () => {
381
381
  describe.each([
382
382
  {
383
383
  description: 'Trim empty spaces',
384
- component: {
385
- title: 'Example lat long',
386
- name: 'myComponent',
387
- type: ComponentType.LatLongField,
388
- options: {},
389
- schema: {}
390
- } satisfies LatLongFieldComponent,
384
+ component: createLatLongComponent(),
391
385
  assertions: [
392
386
  {
393
387
  input: getFormData({
@@ -571,15 +565,162 @@ describe('LatLongField', () => {
571
565
  }
572
566
  ]
573
567
  },
568
+ {
569
+ description: 'Minimum precision validation',
570
+ component: createLatLongComponent(),
571
+ assertions: [
572
+ {
573
+ input: getFormData({
574
+ latitude: '52',
575
+ longitude: '-1'
576
+ }),
577
+ output: {
578
+ value: getFormData({
579
+ latitude: 52,
580
+ longitude: -1
581
+ }),
582
+ errors: [
583
+ expect.objectContaining({
584
+ text: 'Latitude must have at least 1 decimal place'
585
+ }),
586
+ expect.objectContaining({
587
+ text: 'Longitude must have at least 1 decimal place'
588
+ })
589
+ ]
590
+ }
591
+ },
592
+ {
593
+ input: getFormData({
594
+ latitude: '52.1',
595
+ longitude: '-1.5'
596
+ }),
597
+ output: {
598
+ value: getFormData({
599
+ latitude: 52.1,
600
+ longitude: -1.5
601
+ })
602
+ }
603
+ },
604
+ {
605
+ input: getFormData({
606
+ latitude: '52.123456',
607
+ longitude: '-1.123456'
608
+ }),
609
+ output: {
610
+ value: getFormData({
611
+ latitude: 52.123456,
612
+ longitude: -1.123456
613
+ })
614
+ }
615
+ }
616
+ ]
617
+ },
618
+ {
619
+ description: 'Length and precision validation',
620
+ component: createLatLongComponent(),
621
+ assertions: [
622
+ // Latitude too short
623
+ {
624
+ input: getFormData({
625
+ latitude: '52',
626
+ longitude: '-1.5'
627
+ }),
628
+ output: {
629
+ value: getFormData({
630
+ latitude: 52,
631
+ longitude: -1.5
632
+ }),
633
+ errors: [
634
+ expect.objectContaining({
635
+ text: 'Latitude must have at least 1 decimal place'
636
+ })
637
+ ]
638
+ }
639
+ },
640
+ // Latitude too long
641
+ {
642
+ input: getFormData({
643
+ latitude: '52.12345678',
644
+ longitude: '-1.5'
645
+ }),
646
+ output: {
647
+ value: getFormData({
648
+ latitude: 52.12345678,
649
+ longitude: -1.5
650
+ }),
651
+ errors: [
652
+ expect.objectContaining({
653
+ text: 'Latitude must have no more than 7 decimal places'
654
+ })
655
+ ]
656
+ }
657
+ },
658
+ // Longitude too short
659
+ {
660
+ input: getFormData({
661
+ latitude: '52.1',
662
+ longitude: '-1'
663
+ }),
664
+ output: {
665
+ value: getFormData({
666
+ latitude: 52.1,
667
+ longitude: -1
668
+ }),
669
+ errors: [
670
+ expect.objectContaining({
671
+ text: 'Longitude must have at least 1 decimal place'
672
+ })
673
+ ]
674
+ }
675
+ },
676
+ // Longitude too long
677
+ {
678
+ input: getFormData({
679
+ latitude: '52.1',
680
+ longitude: '-1.12345678'
681
+ }),
682
+ output: {
683
+ value: getFormData({
684
+ latitude: 52.1,
685
+ longitude: -1.12345678
686
+ }),
687
+ errors: [
688
+ expect.objectContaining({
689
+ text: 'Longitude must have no more than 7 decimal places'
690
+ })
691
+ ]
692
+ }
693
+ },
694
+ // Valid values
695
+ {
696
+ input: getFormData({
697
+ latitude: '52.1',
698
+ longitude: '-1.5'
699
+ }),
700
+ output: {
701
+ value: getFormData({
702
+ latitude: 52.1,
703
+ longitude: -1.5
704
+ })
705
+ }
706
+ },
707
+ {
708
+ input: getFormData({
709
+ latitude: '52.1234',
710
+ longitude: '-1.123'
711
+ }),
712
+ output: {
713
+ value: getFormData({
714
+ latitude: 52.1234,
715
+ longitude: -1.123
716
+ })
717
+ }
718
+ }
719
+ ]
720
+ },
574
721
  {
575
722
  description: 'Invalid format',
576
- component: {
577
- title: 'Example lat long',
578
- name: 'myComponent',
579
- type: ComponentType.LatLongField,
580
- options: {},
581
- schema: {}
582
- } satisfies LatLongFieldComponent,
723
+ component: createLatLongComponent(),
583
724
  assertions: [
584
725
  {
585
726
  input: getFormData({
@@ -665,6 +806,22 @@ describe('LatLongField', () => {
665
806
  })
666
807
  })
667
808
 
809
+ /**
810
+ * Factory function to create a default LatLongField component with optional overrides
811
+ */
812
+ function createLatLongComponent(
813
+ overrides: Partial<LatLongFieldComponent> = {}
814
+ ): LatLongFieldComponent {
815
+ return {
816
+ title: 'Example lat long',
817
+ name: 'myComponent',
818
+ type: ComponentType.LatLongField,
819
+ options: {},
820
+ schema: {},
821
+ ...overrides
822
+ } satisfies LatLongFieldComponent
823
+ }
824
+
668
825
  function getFormData(
669
826
  value:
670
827
  | { latitude?: string | number; longitude?: string | number }
@@ -23,6 +23,23 @@ import {
23
23
  } from '~/src/server/plugins/engine/types.js'
24
24
  import { convertToLanguageMessages } from '~/src/server/utils/type-utils.js'
25
25
 
26
+ // Precision constants
27
+ // UK latitude/longitude requires high precision for accurate location (within ~11mm)
28
+ const DECIMAL_PRECISION = 7 // 7 decimal places
29
+ const MIN_DECIMAL_PLACES = 1 // At least 1 decimal place required
30
+
31
+ // Latitude length constraints
32
+ // Min: 3 chars for values like "52.1" (2 digits + decimal + 1 decimal place)
33
+ // Max: 10 chars for values like "59.1234567" (2 digits + decimal + 7 decimal places)
34
+ const LATITUDE_MIN_LENGTH = 3
35
+ const LATITUDE_MAX_LENGTH = 10
36
+
37
+ // Longitude length constraints
38
+ // Min: 2 chars for values like "-1" or single digit with decimal (needs min decimal places)
39
+ // Max: 10 chars for values like "-1.1234567" (minus + 1 digit + decimal + 7 decimal places)
40
+ const LONGITUDE_MIN_LENGTH = 2
41
+ const LONGITUDE_MAX_LENGTH = 10
42
+
26
43
  export class LatLongField extends FormComponent {
27
44
  declare options: LatLongFieldComponent['options']
28
45
  declare formSchema: ObjectSchema<FormPayload>
@@ -51,6 +68,8 @@ export class LatLongField extends FormComponent {
51
68
  'number.base': messageTemplate.objectMissing,
52
69
  'number.precision':
53
70
  '{{#label}} must have no more than 7 decimal places',
71
+ 'number.minPrecision':
72
+ '{{#label}} must have at least {{#minPrecision}} decimal place',
54
73
  'number.unsafe': '{{#label}} must be a valid number'
55
74
  })
56
75
 
@@ -58,14 +77,18 @@ export class LatLongField extends FormComponent {
58
77
  ...customValidationMessages,
59
78
  'number.base': `Enter a valid latitude for ${this.title} like 51.519450`,
60
79
  '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}`
80
+ 'number.max': `Latitude for ${this.title} must be between ${latitudeMin} and ${latitudeMax}`,
81
+ 'number.minLength': `Latitude for ${this.title} must be between 3 and 10 characters`,
82
+ 'number.maxLength': `Latitude for ${this.title} must be between 3 and 10 characters`
62
83
  })
63
84
 
64
85
  const longitudeMessages: LanguageMessages = convertToLanguageMessages({
65
86
  ...customValidationMessages,
66
87
  'number.base': `Enter a valid longitude for ${this.title} like -0.127758`,
67
88
  '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}`
89
+ 'number.max': `Longitude for ${this.title} must be between ${longitudeMin} and ${longitudeMax}`,
90
+ 'number.minLength': `Longitude for ${this.title} must be between 2 and 10 characters`,
91
+ 'number.maxLength': `Longitude for ${this.title} must be between 2 and 10 characters`
69
92
  })
70
93
 
71
94
  this.collection = new ComponentCollection(
@@ -74,7 +97,14 @@ export class LatLongField extends FormComponent {
74
97
  type: ComponentType.NumberField,
75
98
  name: `${name}__latitude`,
76
99
  title: 'Latitude',
77
- schema: { min: latitudeMin, max: latitudeMax, precision: 7 },
100
+ schema: {
101
+ min: latitudeMin,
102
+ max: latitudeMax,
103
+ precision: DECIMAL_PRECISION,
104
+ minPrecision: MIN_DECIMAL_PLACES,
105
+ minLength: LATITUDE_MIN_LENGTH,
106
+ maxLength: LATITUDE_MAX_LENGTH
107
+ },
78
108
  options: {
79
109
  required: isRequired,
80
110
  optionalText: true,
@@ -87,7 +117,14 @@ export class LatLongField extends FormComponent {
87
117
  type: ComponentType.NumberField,
88
118
  name: `${name}__longitude`,
89
119
  title: 'Longitude',
90
- schema: { min: longitudeMin, max: longitudeMax, precision: 7 },
120
+ schema: {
121
+ min: longitudeMin,
122
+ max: longitudeMax,
123
+ precision: DECIMAL_PRECISION,
124
+ minPrecision: MIN_DECIMAL_PLACES,
125
+ minLength: LONGITUDE_MIN_LENGTH,
126
+ maxLength: LONGITUDE_MAX_LENGTH
127
+ },
91
128
  options: {
92
129
  required: isRequired,
93
130
  optionalText: true,