@defra/forms-model 3.0.685 → 3.0.687

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 (39) hide show
  1. package/dist/module/components/types.js.map +1 -1
  2. package/dist/module/conditions/migration.js +74 -19
  3. package/dist/module/conditions/migration.js.map +1 -1
  4. package/dist/module/conditions/types.js.map +1 -1
  5. package/dist/module/form/form-definition/helpers.js +14 -0
  6. package/dist/module/form/form-definition/helpers.js.map +1 -1
  7. package/dist/module/form/form-definition/index.js +14 -3
  8. package/dist/module/form/form-definition/index.js.map +1 -1
  9. package/dist/module/form/form-editor/index.js +2 -0
  10. package/dist/module/form/form-editor/index.js.map +1 -1
  11. package/dist/module/form/form-editor/types.js.map +1 -1
  12. package/dist/types/components/types.d.ts +7 -0
  13. package/dist/types/components/types.d.ts.map +1 -1
  14. package/dist/types/conditions/migration.d.ts.map +1 -1
  15. package/dist/types/conditions/types.d.ts +13 -1
  16. package/dist/types/conditions/types.d.ts.map +1 -1
  17. package/dist/types/form/form-definition/helpers.d.ts +8 -0
  18. package/dist/types/form/form-definition/helpers.d.ts.map +1 -1
  19. package/dist/types/form/form-definition/index.d.ts.map +1 -1
  20. package/dist/types/form/form-editor/index.d.ts +2 -0
  21. package/dist/types/form/form-editor/index.d.ts.map +1 -1
  22. package/dist/types/form/form-editor/types.d.ts +5 -1
  23. package/dist/types/form/form-editor/types.d.ts.map +1 -1
  24. package/package.json +1 -1
  25. package/schemas/component-schema-v2.json +13 -0
  26. package/schemas/component-schema.json +13 -0
  27. package/schemas/form-definition-schema.json +39 -0
  28. package/schemas/form-definition-v2-schema.json +65 -0
  29. package/schemas/list-schema-v2.json +26 -0
  30. package/schemas/list-schema.json +26 -0
  31. package/schemas/page-schema-v2.json +39 -0
  32. package/schemas/page-schema.json +13 -0
  33. package/src/components/types.ts +8 -0
  34. package/src/conditions/migration.ts +111 -25
  35. package/src/conditions/types.ts +13 -1
  36. package/src/form/form-definition/helpers.ts +17 -0
  37. package/src/form/form-definition/index.ts +40 -6
  38. package/src/form/form-editor/index.ts +6 -0
  39. package/src/form/form-editor/types.ts +6 -0
@@ -1,12 +1,15 @@
1
1
  import { ComponentType } from '~/src/components/enums.js'
2
+ import { isConditionalType } from '~/src/components/helpers.js'
3
+ import { type ComponentDef } from '~/src/components/types.js'
2
4
  import {
3
- type ComponentDef,
4
- type ConditionalComponentType
5
- } from '~/src/components/types.js'
6
- import { ConditionType, type Coordinator } from '~/src/conditions/enums.js'
5
+ ConditionType,
6
+ Coordinator,
7
+ OperatorName
8
+ } from '~/src/conditions/enums.js'
7
9
  import {
8
10
  type ConditionData,
9
11
  type ConditionDataV2,
12
+ type ConditionGroupData,
10
13
  type ConditionListItemRefValueDataV2,
11
14
  type ConditionRefData,
12
15
  type ConditionRefDataV2,
@@ -14,6 +17,7 @@ import {
14
17
  type RelativeDateValueData,
15
18
  type RelativeDateValueDataV2
16
19
  } from '~/src/conditions/types.js'
20
+ import { getConditionListItemIds } from '~/src/form/form-definition/helpers.js'
17
21
  import {
18
22
  type ConditionWrapper,
19
23
  type ConditionWrapperV2,
@@ -57,29 +61,97 @@ function getListItem(model: RuntimeFormModel, listId: string, itemId: string) {
57
61
  const foundList = model.getListById(listId)
58
62
 
59
63
  if (!foundList) {
60
- throw Error(`List ${listId} not found`)
64
+ throw new Error(`List ${listId} not found`)
61
65
  }
62
66
 
63
67
  const item = foundList.items.find((item) => item.id === itemId)
64
68
 
65
69
  if (!item) {
66
- throw Error(`List item ${itemId} not found`)
70
+ throw new Error(`List item ${itemId} not found`)
67
71
  }
68
72
 
69
73
  return item
70
74
  }
71
75
 
72
- function createConditionValueDataFromListItemRefV2(
76
+ function createConditionValueDataListFromListItemRefV2(
73
77
  condition: ConditionDataV2,
74
78
  model: RuntimeFormModel
75
- ): ConditionValueData {
79
+ ): ConditionValueData[] {
76
80
  const value = condition.value as ConditionListItemRefValueDataV2
77
- const refValue = getListItem(model, value.listId, value.itemId)
81
+ const itemIds = getConditionListItemIds(value)
82
+
83
+ return itemIds.map((itemId) => {
84
+ const refValue = getListItem(model, value.listId, itemId)
85
+
86
+ return {
87
+ display: refValue.text,
88
+ type: ConditionType.Value,
89
+ value: refValue.value.toString()
90
+ }
91
+ })
92
+ }
93
+
94
+ /**
95
+ * Determine how multiple selected list items are combined within a single
96
+ * V2 condition once expanded into individual V1 conditions.
97
+ * - Checkbox questions use the author-chosen coordinator (default OR).
98
+ * - Single-answer questions (radio/autocomplete/select) combine with OR for a
99
+ * positive match and AND for a negative ("is not any of these") match.
100
+ */
101
+ function getListItemsInnerCoordinator(
102
+ componentType: ComponentDef['type'],
103
+ operator: OperatorName,
104
+ itemsCoordinator: Coordinator | undefined
105
+ ): Coordinator {
106
+ if (componentType === ComponentType.CheckboxesField) {
107
+ return itemsCoordinator ?? Coordinator.OR
108
+ }
109
+
110
+ return operator === OperatorName.IsNot ? Coordinator.AND : Coordinator.OR
111
+ }
112
+
113
+ /**
114
+ * Convert a V2 list item ref condition to V1. A single selected item produces
115
+ * a single condition (unchanged from legacy behaviour); multiple selected
116
+ * items produce a nested condition group so they are correctly parenthesised
117
+ * relative to sibling conditions.
118
+ */
119
+ function convertListItemRefConditionV2(
120
+ model: RuntimeFormModel,
121
+ condition: ConditionDataV2,
122
+ component: ComponentDef,
123
+ field: ConditionData['field'],
124
+ coordinator: Coordinator | undefined
125
+ ): ConditionData | ConditionGroupData {
126
+ const value = condition.value as ConditionListItemRefValueDataV2
127
+ const values = createConditionValueDataListFromListItemRefV2(condition, model)
128
+
129
+ if (!values.length) {
130
+ throw new Error('List item ref condition has no selected items')
131
+ }
132
+
133
+ if (values.length === 1) {
134
+ return {
135
+ field,
136
+ operator: condition.operator,
137
+ value: values[0],
138
+ coordinator
139
+ }
140
+ }
141
+
142
+ const innerCoordinator = getListItemsInnerCoordinator(
143
+ component.type,
144
+ condition.operator,
145
+ value.itemsCoordinator
146
+ )
78
147
 
79
148
  return {
80
- display: refValue.text,
81
- type: ConditionType.Value,
82
- value: refValue.value.toString()
149
+ conditions: values.map((itemValue, index) => ({
150
+ field,
151
+ operator: condition.operator,
152
+ value: itemValue,
153
+ coordinator: index === 0 ? coordinator : innerCoordinator
154
+ }))
83
155
  }
84
156
  }
85
157
 
@@ -157,17 +229,35 @@ function convertConditionDataV2(
157
229
  model: RuntimeFormModel,
158
230
  condition: ConditionDataV2,
159
231
  coordinator: Coordinator | undefined
160
- ): ConditionData {
232
+ ): ConditionData | ConditionGroupData {
161
233
  const component = model.getComponentById(condition.componentId)
162
234
 
163
235
  if (!component) {
164
- throw Error('Component not found')
236
+ throw new Error('Component not found')
237
+ }
238
+
239
+ if (!isConditionalType(component.type)) {
240
+ throw new Error(`Component ${component.name} does not support conditions`)
241
+ }
242
+
243
+ const field = {
244
+ name: component.name,
245
+ type: component.type,
246
+ display: component.title
165
247
  }
166
248
 
167
- let newValue
168
249
  if (isConditionListItemRefValueDataV2(condition)) {
169
- newValue = createConditionValueDataFromListItemRefV2(condition, model)
170
- } else if (isConditionStringValueDataV2(condition)) {
250
+ return convertListItemRefConditionV2(
251
+ model,
252
+ condition,
253
+ component,
254
+ field,
255
+ coordinator
256
+ )
257
+ }
258
+
259
+ let newValue
260
+ if (isConditionStringValueDataV2(condition)) {
171
261
  newValue = createConditionValueDataFromStringValueDataV2(condition)
172
262
  } else if (isConditionBooleanValueDataV2(condition)) {
173
263
  newValue =
@@ -181,15 +271,11 @@ function convertConditionDataV2(
181
271
  } else if (isConditionRelativeDateValueDataV2(condition)) {
182
272
  newValue = createConditionValueDataFromRelativeDateValueDataV2(condition)
183
273
  } else {
184
- throw Error('Unsupported condition type')
274
+ throw new Error('Unsupported condition type')
185
275
  }
186
276
 
187
277
  return {
188
- field: {
189
- name: component.name,
190
- type: component.type as ConditionalComponentType /** @todo fix this */,
191
- display: component.title
192
- },
278
+ field,
193
279
  operator: condition.operator,
194
280
  value: newValue,
195
281
  coordinator
@@ -204,7 +290,7 @@ function convertConditionRefDataFromV2(
204
290
  const refCondition = model.getConditionById(condition.conditionId)
205
291
 
206
292
  if (!refCondition) {
207
- throw Error('Component not found')
293
+ throw new Error('Component not found')
208
294
  }
209
295
 
210
296
  return {
@@ -244,7 +330,7 @@ export function convertConditionWrapperFromV2(
244
330
  value: {
245
331
  name: conditionWrapper.id,
246
332
  conditions: conditionWrapper.items.map((condition, index) => {
247
- let newCondition: ConditionData | ConditionRefData
333
+ let newCondition: ConditionData | ConditionRefData | ConditionGroupData
248
334
 
249
335
  if (isConditionDataV2(condition)) {
250
336
  newCondition = convertConditionDataV2(
@@ -26,7 +26,19 @@ export interface RelativeDateValueData {
26
26
 
27
27
  export interface ConditionListItemRefValueDataV2 {
28
28
  listId: string
29
- itemId: string
29
+ /**
30
+ * One or more referenced list item ids (radio, checkbox, autocomplete,
31
+ * select). New conditions are always written as an array, even for a single
32
+ * selection. A bare `string` is only retained for backwards compatibility so
33
+ * conditions saved before multi-select support continue to load, display and
34
+ * evaluate.
35
+ */
36
+ itemId: string[] | string
37
+ /**
38
+ * How multiple checkbox selections are combined (AND/OR). Only set for
39
+ * checkbox questions; radio/autocomplete/select combine implicitly.
40
+ */
41
+ itemsCoordinator?: Coordinator
30
42
  }
31
43
 
32
44
  export interface RelativeDateValueDataV2 {
@@ -44,6 +44,23 @@ export function isConditionListItemRefValueData(
44
44
  )
45
45
  }
46
46
 
47
+ /**
48
+ * Normalises a list item ref condition value to an array of item ids. `itemId`
49
+ * is normally an array, but may be a bare string for legacy conditions saved
50
+ * before multi-select support.
51
+ * @param { ConditionListItemRefValueDataV2 | undefined } value
52
+ * @returns { string[] }
53
+ */
54
+ export function getConditionListItemIds(
55
+ value: ConditionListItemRefValueDataV2 | undefined
56
+ ): string[] {
57
+ if (!value?.itemId) {
58
+ return []
59
+ }
60
+
61
+ return Array.isArray(value.itemId) ? value.itemId : [value.itemId]
62
+ }
63
+
47
64
  /**
48
65
  * Returns an array of all hidden fields in a form
49
66
  * @param definition - form definition
@@ -15,7 +15,11 @@ import {
15
15
  type ContentComponentsDef,
16
16
  type FileUploadFieldComponent
17
17
  } from '~/src/components/types.js'
18
- import { ConditionType, OperatorName } from '~/src/conditions/enums.js'
18
+ import {
19
+ ConditionType,
20
+ Coordinator,
21
+ OperatorName
22
+ } from '~/src/conditions/enums.js'
19
23
  import {
20
24
  type ConditionData,
21
25
  type ConditionDataV2,
@@ -112,7 +116,12 @@ export const listItemIdValidator = (
112
116
  return value
113
117
  }
114
118
 
115
- const conditionValue = helpers.state.ancestors[0]
119
+ // `itemId` is normally an array, so the condition value is one level up from
120
+ // each entry being validated. For a legacy bare-string `itemId` it is the
121
+ // nearest ancestor instead, so locate it by shape rather than fixed position.
122
+ const conditionValue = helpers.state.ancestors.find(
123
+ isConditionListItemRefValueData
124
+ )
116
125
 
117
126
  if (!isConditionListItemRefValueData(conditionValue)) {
118
127
  return value
@@ -254,11 +263,17 @@ const conditionListItemRefDataSchemaV2 =
254
263
  })
255
264
  .description('The id of the list')
256
265
  .error(checkErrors(FormDefinitionError.RefConditionListId)),
257
- itemId: Joi.string()
258
- .trim()
266
+ itemId: Joi.array()
267
+ .single()
268
+ .items(Joi.string().trim().custom(listItemIdValidator))
269
+ .min(1)
259
270
  .required()
260
- .description('The id of the list item')
261
- .custom(listItemIdValidator)
271
+ .description('The ids of the selected list items.'),
272
+ itemsCoordinator: Joi.string()
273
+ .trim()
274
+ .valid(...Object.values(Coordinator))
275
+ .optional()
276
+ .description('How multiple checkbox selections are combined (and/or)')
262
277
  })
263
278
 
264
279
  const relativeDateValueDataSchemaV2 = Joi.object<RelativeDateValueDataV2>()
@@ -665,6 +680,25 @@ export const componentSchema = Joi.object<ComponentDef>()
665
680
  'Geometry types for geospatial data - for GeospatialField only'
666
681
  )
667
682
  }).description('Geometry types - for GeospatialField only'),
683
+ mapLayers: Joi.when('type', {
684
+ is: Joi.string()
685
+ .trim()
686
+ .valid(
687
+ ComponentType.GeospatialField,
688
+ ComponentType.EastingNorthingField,
689
+ ComponentType.LatLongField,
690
+ ComponentType.OsGridRefField
691
+ )
692
+ .required(),
693
+ then: Joi.object()
694
+ .keys({
695
+ sssi: Joi.boolean()
696
+ .optional()
697
+ .description('Sites of Special Scientific Interest layer')
698
+ })
699
+ .optional()
700
+ .description('Supported map layers')
701
+ }).description('Map layers'),
668
702
  format: Joi.when('type', {
669
703
  is: Joi.string()
670
704
  .trim()
@@ -471,6 +471,11 @@ export const countriesSchema = Joi.array()
471
471
  .single()
472
472
  .description('The country to be included in a geospatial field')
473
473
 
474
+ export const mapLayersSchema = Joi.array()
475
+ .items(Joi.string().valid('sssi'))
476
+ .single()
477
+ .description('The map layers to be displayed')
478
+
474
479
  export const exactFeaturesSchema = Joi.number()
475
480
  .empty('')
476
481
  .integer()
@@ -693,6 +698,7 @@ export const questionDetailsFullSchema = {
693
698
  maxChecksSchema,
694
699
  exactChecksSchema,
695
700
  countriesSchema,
701
+ mapLayersSchema,
696
702
  minFeaturesSchema,
697
703
  maxFeaturesSchema,
698
704
  exactFeaturesSchema,
@@ -423,6 +423,11 @@ export interface FormEditor {
423
423
  */
424
424
  geometryTypes?: GeospatialFieldGeometryTypesEnum[]
425
425
 
426
+ /**
427
+ * The layers to include for map-based geospatial questions
428
+ */
429
+ mapLayers?: string[]
430
+
426
431
  /**
427
432
  * The format restriction for telephone number questions
428
433
  */
@@ -519,6 +524,7 @@ export type FormEditorInputQuestion = Pick<
519
524
  | 'minFeatures'
520
525
  | 'maxFeatures'
521
526
  | 'telephoneNumberFormat'
527
+ | 'mapLayers'
522
528
  >
523
529
 
524
530
  export type FormEditorInputPageSettings = Pick<