mcp-eregistrations-bpa 0.8.5__py3-none-any.whl
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.
Potentially problematic release.
This version of mcp-eregistrations-bpa might be problematic. Click here for more details.
- mcp_eregistrations_bpa/__init__.py +121 -0
- mcp_eregistrations_bpa/__main__.py +6 -0
- mcp_eregistrations_bpa/arazzo/__init__.py +21 -0
- mcp_eregistrations_bpa/arazzo/expression.py +379 -0
- mcp_eregistrations_bpa/audit/__init__.py +56 -0
- mcp_eregistrations_bpa/audit/context.py +66 -0
- mcp_eregistrations_bpa/audit/logger.py +236 -0
- mcp_eregistrations_bpa/audit/models.py +131 -0
- mcp_eregistrations_bpa/auth/__init__.py +64 -0
- mcp_eregistrations_bpa/auth/callback.py +391 -0
- mcp_eregistrations_bpa/auth/cas.py +409 -0
- mcp_eregistrations_bpa/auth/oidc.py +252 -0
- mcp_eregistrations_bpa/auth/permissions.py +162 -0
- mcp_eregistrations_bpa/auth/token_manager.py +348 -0
- mcp_eregistrations_bpa/bpa_client/__init__.py +84 -0
- mcp_eregistrations_bpa/bpa_client/client.py +740 -0
- mcp_eregistrations_bpa/bpa_client/endpoints.py +193 -0
- mcp_eregistrations_bpa/bpa_client/errors.py +276 -0
- mcp_eregistrations_bpa/bpa_client/models.py +203 -0
- mcp_eregistrations_bpa/config.py +349 -0
- mcp_eregistrations_bpa/db/__init__.py +21 -0
- mcp_eregistrations_bpa/db/connection.py +64 -0
- mcp_eregistrations_bpa/db/migrations.py +168 -0
- mcp_eregistrations_bpa/exceptions.py +39 -0
- mcp_eregistrations_bpa/py.typed +0 -0
- mcp_eregistrations_bpa/rollback/__init__.py +19 -0
- mcp_eregistrations_bpa/rollback/manager.py +616 -0
- mcp_eregistrations_bpa/server.py +152 -0
- mcp_eregistrations_bpa/tools/__init__.py +372 -0
- mcp_eregistrations_bpa/tools/actions.py +155 -0
- mcp_eregistrations_bpa/tools/analysis.py +352 -0
- mcp_eregistrations_bpa/tools/audit.py +399 -0
- mcp_eregistrations_bpa/tools/behaviours.py +1042 -0
- mcp_eregistrations_bpa/tools/bots.py +627 -0
- mcp_eregistrations_bpa/tools/classifications.py +575 -0
- mcp_eregistrations_bpa/tools/costs.py +765 -0
- mcp_eregistrations_bpa/tools/debug_strategies.py +351 -0
- mcp_eregistrations_bpa/tools/debugger.py +1230 -0
- mcp_eregistrations_bpa/tools/determinants.py +2235 -0
- mcp_eregistrations_bpa/tools/document_requirements.py +670 -0
- mcp_eregistrations_bpa/tools/export.py +899 -0
- mcp_eregistrations_bpa/tools/fields.py +162 -0
- mcp_eregistrations_bpa/tools/form_errors.py +36 -0
- mcp_eregistrations_bpa/tools/formio_helpers.py +971 -0
- mcp_eregistrations_bpa/tools/forms.py +1269 -0
- mcp_eregistrations_bpa/tools/jsonlogic_builder.py +466 -0
- mcp_eregistrations_bpa/tools/large_response.py +163 -0
- mcp_eregistrations_bpa/tools/messages.py +523 -0
- mcp_eregistrations_bpa/tools/notifications.py +241 -0
- mcp_eregistrations_bpa/tools/registration_institutions.py +680 -0
- mcp_eregistrations_bpa/tools/registrations.py +897 -0
- mcp_eregistrations_bpa/tools/role_status.py +447 -0
- mcp_eregistrations_bpa/tools/role_units.py +400 -0
- mcp_eregistrations_bpa/tools/roles.py +1236 -0
- mcp_eregistrations_bpa/tools/rollback.py +335 -0
- mcp_eregistrations_bpa/tools/services.py +674 -0
- mcp_eregistrations_bpa/tools/workflows.py +2487 -0
- mcp_eregistrations_bpa/tools/yaml_transformer.py +991 -0
- mcp_eregistrations_bpa/workflows/__init__.py +28 -0
- mcp_eregistrations_bpa/workflows/loader.py +440 -0
- mcp_eregistrations_bpa/workflows/models.py +336 -0
- mcp_eregistrations_bpa-0.8.5.dist-info/METADATA +965 -0
- mcp_eregistrations_bpa-0.8.5.dist-info/RECORD +66 -0
- mcp_eregistrations_bpa-0.8.5.dist-info/WHEEL +4 -0
- mcp_eregistrations_bpa-0.8.5.dist-info/entry_points.txt +2 -0
- mcp_eregistrations_bpa-0.8.5.dist-info/licenses/LICENSE +86 -0
|
@@ -0,0 +1,971 @@
|
|
|
1
|
+
"""Form.io schema manipulation helpers.
|
|
2
|
+
|
|
3
|
+
This module provides utility functions for manipulating Form.io component trees.
|
|
4
|
+
It supports finding, inserting, removing, and updating components within the
|
|
5
|
+
nested structure (panels, columns, tabs, etc.).
|
|
6
|
+
|
|
7
|
+
All functions operate on copies to avoid mutating original data.
|
|
8
|
+
|
|
9
|
+
Container Type Hierarchy
|
|
10
|
+
------------------------
|
|
11
|
+
Form.io uses container components to organize form layouts. Each container type
|
|
12
|
+
stores its children in a specific property:
|
|
13
|
+
|
|
14
|
+
+-------------+---------------------------+----------------------------------------+
|
|
15
|
+
| Type | Children Accessor | Notes |
|
|
16
|
+
+-------------+---------------------------+----------------------------------------+
|
|
17
|
+
| tabs | components[] | Tab panes have null type; each pane |
|
|
18
|
+
| | | contains its own components[] |
|
|
19
|
+
+-------------+---------------------------+----------------------------------------+
|
|
20
|
+
| panel | components[] | Standard collapsible container |
|
|
21
|
+
+-------------+---------------------------+----------------------------------------+
|
|
22
|
+
| columns | columns[].components[] | 2-level nesting: columns array with |
|
|
23
|
+
| | | each column having a components array |
|
|
24
|
+
+-------------+---------------------------+----------------------------------------+
|
|
25
|
+
| fieldset | components[] | HTML fieldset grouping |
|
|
26
|
+
+-------------+---------------------------+----------------------------------------+
|
|
27
|
+
| editgrid | components[] | Repeatable rows with inline editing |
|
|
28
|
+
+-------------+---------------------------+----------------------------------------+
|
|
29
|
+
| datagrid | components[] | Repeatable rows in table format |
|
|
30
|
+
+-------------+---------------------------+----------------------------------------+
|
|
31
|
+
| table | rows[][] | HTML table structure: rows array |
|
|
32
|
+
| | | containing arrays of cells |
|
|
33
|
+
+-------------+---------------------------+----------------------------------------+
|
|
34
|
+
| well | components[] | Visual grouping container |
|
|
35
|
+
+-------------+---------------------------+----------------------------------------+
|
|
36
|
+
| container | components[] | Data grouping without visual styling |
|
|
37
|
+
+-------------+---------------------------+----------------------------------------+
|
|
38
|
+
|
|
39
|
+
Path Examples
|
|
40
|
+
-------------
|
|
41
|
+
When traversing nested components, paths represent the hierarchy from root to
|
|
42
|
+
the target component:
|
|
43
|
+
|
|
44
|
+
# Example: component inside tabs > tab_pane > panel
|
|
45
|
+
path = ["applicantWelcome", "applicantWelcomepersonalInformation", "applicantBlock"]
|
|
46
|
+
# Meaning: tabs("applicantWelcome") > panel("applicantWelcomepersonalInformation")
|
|
47
|
+
# > panel("applicantBlock") > [target component]
|
|
48
|
+
|
|
49
|
+
# Example: component inside columns
|
|
50
|
+
path = ["myPanel", "twoColumnLayout"]
|
|
51
|
+
# Meaning: panel("myPanel") > columns("twoColumnLayout") > [target component]
|
|
52
|
+
|
|
53
|
+
The path array is returned by find_component() and used in API responses
|
|
54
|
+
to help identify the nesting structure of each component.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
from __future__ import annotations
|
|
58
|
+
|
|
59
|
+
import copy
|
|
60
|
+
import re
|
|
61
|
+
from typing import Any
|
|
62
|
+
|
|
63
|
+
# Container component types that can hold nested components
|
|
64
|
+
CONTAINER_TYPES = {
|
|
65
|
+
"panel",
|
|
66
|
+
"columns",
|
|
67
|
+
"tabs",
|
|
68
|
+
"well",
|
|
69
|
+
"fieldset",
|
|
70
|
+
"container",
|
|
71
|
+
"editgrid",
|
|
72
|
+
"datagrid",
|
|
73
|
+
"table",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# Maximum recursion depth for tree traversal (prevent stack overflow)
|
|
77
|
+
MAX_RECURSION_DEPTH = 100
|
|
78
|
+
|
|
79
|
+
# BPA-specific default properties for new components
|
|
80
|
+
BPA_COMPONENT_DEFAULTS = {
|
|
81
|
+
"registrations": {},
|
|
82
|
+
"determinantIds": [],
|
|
83
|
+
"componentActionId": "",
|
|
84
|
+
"componentFormulaId": "",
|
|
85
|
+
"behaviourId": "",
|
|
86
|
+
"componentValidationId": "",
|
|
87
|
+
"version": "201905",
|
|
88
|
+
"input": True,
|
|
89
|
+
"tableView": True,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def find_component(
|
|
94
|
+
components: list[dict[str, Any]],
|
|
95
|
+
key: str,
|
|
96
|
+
path: list[str] | None = None,
|
|
97
|
+
_depth: int = 0,
|
|
98
|
+
) -> tuple[dict[str, Any], list[str]] | None:
|
|
99
|
+
"""Find a component by key in the component tree.
|
|
100
|
+
|
|
101
|
+
Searches recursively through nested components (panels, columns, tabs).
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
components: List of Form.io components to search.
|
|
105
|
+
key: The component key to find.
|
|
106
|
+
path: Current path (used internally for recursion).
|
|
107
|
+
_depth: Current recursion depth (internal use).
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Tuple of (component, path) if found, None otherwise.
|
|
111
|
+
The path is a list of parent keys leading to the component.
|
|
112
|
+
|
|
113
|
+
Raises:
|
|
114
|
+
RecursionError: If nesting exceeds MAX_RECURSION_DEPTH.
|
|
115
|
+
|
|
116
|
+
Example:
|
|
117
|
+
>>> components = [{"key": "panel1", "type": "panel", "components": [
|
|
118
|
+
... {"key": "name", "type": "textfield"}
|
|
119
|
+
... ]}]
|
|
120
|
+
>>> result = find_component(components, "name")
|
|
121
|
+
>>> result[0]["key"]
|
|
122
|
+
'name'
|
|
123
|
+
>>> result[1]
|
|
124
|
+
['panel1']
|
|
125
|
+
"""
|
|
126
|
+
if _depth > MAX_RECURSION_DEPTH:
|
|
127
|
+
raise RecursionError(
|
|
128
|
+
f"Component tree exceeds maximum depth of {MAX_RECURSION_DEPTH}"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
if path is None:
|
|
132
|
+
path = []
|
|
133
|
+
|
|
134
|
+
for comp in components:
|
|
135
|
+
if not isinstance(comp, dict):
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
comp_key = comp.get("key")
|
|
139
|
+
if comp_key == key:
|
|
140
|
+
return (comp, path)
|
|
141
|
+
|
|
142
|
+
new_path = path + [comp_key] if comp_key else path
|
|
143
|
+
|
|
144
|
+
# Search in nested components
|
|
145
|
+
nested = comp.get("components", [])
|
|
146
|
+
if nested:
|
|
147
|
+
result = find_component(nested, key, new_path, _depth + 1)
|
|
148
|
+
if result:
|
|
149
|
+
return result
|
|
150
|
+
|
|
151
|
+
# Search in columns
|
|
152
|
+
columns = comp.get("columns", [])
|
|
153
|
+
for col in columns:
|
|
154
|
+
if isinstance(col, dict):
|
|
155
|
+
col_components = col.get("components", [])
|
|
156
|
+
if col_components:
|
|
157
|
+
result = find_component(col_components, key, new_path, _depth + 1)
|
|
158
|
+
if result:
|
|
159
|
+
return result
|
|
160
|
+
|
|
161
|
+
# Search in tabs
|
|
162
|
+
tabs = comp.get("tabs", [])
|
|
163
|
+
for tab in tabs:
|
|
164
|
+
if isinstance(tab, dict):
|
|
165
|
+
tab_components = tab.get("components", [])
|
|
166
|
+
if tab_components:
|
|
167
|
+
result = find_component(tab_components, key, new_path, _depth + 1)
|
|
168
|
+
if result:
|
|
169
|
+
return result
|
|
170
|
+
|
|
171
|
+
# Search in table rows (rows[][] structure)
|
|
172
|
+
rows = comp.get("rows", [])
|
|
173
|
+
for row in rows:
|
|
174
|
+
if isinstance(row, list):
|
|
175
|
+
for cell in row:
|
|
176
|
+
if isinstance(cell, dict):
|
|
177
|
+
cell_components = cell.get("components", [])
|
|
178
|
+
if cell_components:
|
|
179
|
+
result = find_component(
|
|
180
|
+
cell_components, key, new_path, _depth + 1
|
|
181
|
+
)
|
|
182
|
+
if result:
|
|
183
|
+
return result
|
|
184
|
+
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def find_component_parent(
|
|
189
|
+
components: list[dict[str, Any]],
|
|
190
|
+
key: str,
|
|
191
|
+
) -> tuple[dict[str, Any] | None, list[dict[str, Any]], int]:
|
|
192
|
+
"""Find the parent container and index of a component.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
components: List of Form.io components.
|
|
196
|
+
key: The component key to find.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
Tuple of (parent_component, siblings_list, index).
|
|
200
|
+
parent_component is None if the component is at root level.
|
|
201
|
+
siblings_list is the list containing the component.
|
|
202
|
+
index is the position of the component in siblings_list.
|
|
203
|
+
|
|
204
|
+
Raises:
|
|
205
|
+
ValueError: If component is not found.
|
|
206
|
+
RecursionError: If nesting exceeds MAX_RECURSION_DEPTH.
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
def _search(
|
|
210
|
+
comps: list[dict[str, Any]], parent: dict[str, Any] | None, depth: int = 0
|
|
211
|
+
) -> tuple[dict[str, Any] | None, list[dict[str, Any]], int] | None:
|
|
212
|
+
if depth > MAX_RECURSION_DEPTH:
|
|
213
|
+
raise RecursionError(
|
|
214
|
+
f"Component tree exceeds maximum depth of {MAX_RECURSION_DEPTH}"
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
for i, comp in enumerate(comps):
|
|
218
|
+
if not isinstance(comp, dict):
|
|
219
|
+
continue
|
|
220
|
+
|
|
221
|
+
if comp.get("key") == key:
|
|
222
|
+
return (parent, comps, i)
|
|
223
|
+
|
|
224
|
+
# Search in nested components
|
|
225
|
+
nested = comp.get("components", [])
|
|
226
|
+
if nested:
|
|
227
|
+
result = _search(nested, comp, depth + 1)
|
|
228
|
+
if result:
|
|
229
|
+
return result
|
|
230
|
+
|
|
231
|
+
# Search in columns
|
|
232
|
+
columns = comp.get("columns", [])
|
|
233
|
+
for col in columns:
|
|
234
|
+
if isinstance(col, dict):
|
|
235
|
+
col_components = col.get("components", [])
|
|
236
|
+
if col_components:
|
|
237
|
+
result = _search(col_components, comp, depth + 1)
|
|
238
|
+
if result:
|
|
239
|
+
return result
|
|
240
|
+
|
|
241
|
+
# Search in tabs
|
|
242
|
+
tabs = comp.get("tabs", [])
|
|
243
|
+
for tab in tabs:
|
|
244
|
+
if isinstance(tab, dict):
|
|
245
|
+
tab_components = tab.get("components", [])
|
|
246
|
+
if tab_components:
|
|
247
|
+
result = _search(tab_components, comp, depth + 1)
|
|
248
|
+
if result:
|
|
249
|
+
return result
|
|
250
|
+
|
|
251
|
+
# Search in table rows (rows[][] structure)
|
|
252
|
+
rows = comp.get("rows", [])
|
|
253
|
+
for row in rows:
|
|
254
|
+
if isinstance(row, list):
|
|
255
|
+
for cell in row:
|
|
256
|
+
if isinstance(cell, dict):
|
|
257
|
+
cell_components = cell.get("components", [])
|
|
258
|
+
if cell_components:
|
|
259
|
+
result = _search(cell_components, comp, depth + 1)
|
|
260
|
+
if result:
|
|
261
|
+
return result
|
|
262
|
+
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
result = _search(components, None)
|
|
266
|
+
if result is None:
|
|
267
|
+
raise ValueError(f"Component with key '{key}' not found")
|
|
268
|
+
return result
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def get_all_component_keys(
|
|
272
|
+
components: list[dict[str, Any]],
|
|
273
|
+
include_duplicates: bool = False,
|
|
274
|
+
) -> set[str] | list[str]:
|
|
275
|
+
"""Get all component keys in the tree.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
components: List of Form.io components.
|
|
279
|
+
include_duplicates: If True, returns list with duplicates preserved.
|
|
280
|
+
If False (default), returns set of unique keys.
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
Set of unique keys, or list with duplicates if include_duplicates=True.
|
|
284
|
+
|
|
285
|
+
Raises:
|
|
286
|
+
RecursionError: If nesting exceeds MAX_RECURSION_DEPTH.
|
|
287
|
+
"""
|
|
288
|
+
# Handle non-list components (BPA API may return int or other types)
|
|
289
|
+
if not isinstance(components, list):
|
|
290
|
+
return [] if include_duplicates else set()
|
|
291
|
+
|
|
292
|
+
keys: list[str] = []
|
|
293
|
+
|
|
294
|
+
def _collect(comps: list[dict[str, Any]], depth: int = 0) -> None:
|
|
295
|
+
if depth > MAX_RECURSION_DEPTH:
|
|
296
|
+
raise RecursionError(
|
|
297
|
+
f"Component tree exceeds maximum depth of {MAX_RECURSION_DEPTH}"
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
# Skip if comps is not a list (defensive for recursive calls)
|
|
301
|
+
if not isinstance(comps, list):
|
|
302
|
+
return
|
|
303
|
+
|
|
304
|
+
for comp in comps:
|
|
305
|
+
if not isinstance(comp, dict):
|
|
306
|
+
continue
|
|
307
|
+
|
|
308
|
+
key = comp.get("key")
|
|
309
|
+
if key:
|
|
310
|
+
keys.append(key)
|
|
311
|
+
|
|
312
|
+
# Collect from nested components
|
|
313
|
+
nested = comp.get("components", [])
|
|
314
|
+
if nested and isinstance(nested, list):
|
|
315
|
+
_collect(nested, depth + 1)
|
|
316
|
+
|
|
317
|
+
# Collect from columns
|
|
318
|
+
columns = comp.get("columns", [])
|
|
319
|
+
if isinstance(columns, list):
|
|
320
|
+
for col in columns:
|
|
321
|
+
if isinstance(col, dict):
|
|
322
|
+
col_components = col.get("components", [])
|
|
323
|
+
if col_components and isinstance(col_components, list):
|
|
324
|
+
_collect(col_components, depth + 1)
|
|
325
|
+
|
|
326
|
+
# Collect from tabs
|
|
327
|
+
tabs = comp.get("tabs", [])
|
|
328
|
+
if isinstance(tabs, list):
|
|
329
|
+
for tab in tabs:
|
|
330
|
+
if isinstance(tab, dict):
|
|
331
|
+
tab_components = tab.get("components", [])
|
|
332
|
+
if tab_components and isinstance(tab_components, list):
|
|
333
|
+
_collect(tab_components, depth + 1)
|
|
334
|
+
|
|
335
|
+
_collect(components)
|
|
336
|
+
|
|
337
|
+
if include_duplicates:
|
|
338
|
+
return keys
|
|
339
|
+
return set(keys)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def validate_component_key_unique(
|
|
343
|
+
components: list[dict[str, Any]],
|
|
344
|
+
key: str,
|
|
345
|
+
) -> bool:
|
|
346
|
+
"""Check if a key is unique within the component tree.
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
components: Existing Form.io components.
|
|
350
|
+
key: Key to check for uniqueness.
|
|
351
|
+
|
|
352
|
+
Returns:
|
|
353
|
+
True if key is unique, False if it already exists.
|
|
354
|
+
"""
|
|
355
|
+
existing = get_all_component_keys(components)
|
|
356
|
+
return key not in existing
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def insert_component(
|
|
360
|
+
components: list[dict[str, Any]],
|
|
361
|
+
component: dict[str, Any],
|
|
362
|
+
parent_key: str | None = None,
|
|
363
|
+
position: int | None = None,
|
|
364
|
+
) -> list[dict[str, Any]]:
|
|
365
|
+
"""Insert a component into the tree.
|
|
366
|
+
|
|
367
|
+
Creates a deep copy of the components and inserts the new component.
|
|
368
|
+
Does not mutate the original list.
|
|
369
|
+
|
|
370
|
+
Args:
|
|
371
|
+
components: Original Form.io components list.
|
|
372
|
+
component: Component to insert.
|
|
373
|
+
parent_key: Key of parent container to insert into.
|
|
374
|
+
If None, inserts at root level.
|
|
375
|
+
position: Position to insert at (0-indexed).
|
|
376
|
+
If None, appends to end.
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
New components list with the component inserted.
|
|
380
|
+
|
|
381
|
+
Raises:
|
|
382
|
+
ValueError: If parent_key is specified but not found,
|
|
383
|
+
or if parent is not a container type.
|
|
384
|
+
"""
|
|
385
|
+
# NOTE: Deep copy ensures immutability - input components are never mutated.
|
|
386
|
+
# For large forms (500+ components), this may impact performance (~50-100ms).
|
|
387
|
+
# If performance becomes critical, consider structural sharing optimization.
|
|
388
|
+
result = copy.deepcopy(components)
|
|
389
|
+
new_comp = copy.deepcopy(component)
|
|
390
|
+
|
|
391
|
+
# Add BPA defaults if not present
|
|
392
|
+
for key, value in BPA_COMPONENT_DEFAULTS.items():
|
|
393
|
+
if key not in new_comp:
|
|
394
|
+
new_comp[key] = copy.deepcopy(value)
|
|
395
|
+
|
|
396
|
+
if parent_key is None:
|
|
397
|
+
# Insert at root level
|
|
398
|
+
if position is None:
|
|
399
|
+
result.append(new_comp)
|
|
400
|
+
else:
|
|
401
|
+
result.insert(position, new_comp)
|
|
402
|
+
else:
|
|
403
|
+
# Find parent and insert
|
|
404
|
+
parent_result = find_component(result, parent_key)
|
|
405
|
+
if parent_result is None:
|
|
406
|
+
raise ValueError(f"Parent component '{parent_key}' not found")
|
|
407
|
+
|
|
408
|
+
parent_comp = parent_result[0]
|
|
409
|
+
parent_type = parent_comp.get("type", "")
|
|
410
|
+
|
|
411
|
+
if parent_type not in CONTAINER_TYPES:
|
|
412
|
+
raise ValueError(
|
|
413
|
+
f"Component '{parent_key}' (type: {parent_type}) is not a container. "
|
|
414
|
+
f"Valid container types: {', '.join(sorted(CONTAINER_TYPES))}"
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
# Ensure components list exists
|
|
418
|
+
if "components" not in parent_comp:
|
|
419
|
+
parent_comp["components"] = []
|
|
420
|
+
|
|
421
|
+
if position is None:
|
|
422
|
+
parent_comp["components"].append(new_comp)
|
|
423
|
+
else:
|
|
424
|
+
parent_comp["components"].insert(position, new_comp)
|
|
425
|
+
|
|
426
|
+
return result
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def remove_component(
|
|
430
|
+
components: list[dict[str, Any]],
|
|
431
|
+
key: str,
|
|
432
|
+
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
|
433
|
+
"""Remove a component from the tree.
|
|
434
|
+
|
|
435
|
+
Creates a deep copy and removes the component.
|
|
436
|
+
|
|
437
|
+
Args:
|
|
438
|
+
components: Original Form.io components list.
|
|
439
|
+
key: Key of component to remove.
|
|
440
|
+
|
|
441
|
+
Returns:
|
|
442
|
+
Tuple of (new_components, removed_component).
|
|
443
|
+
|
|
444
|
+
Raises:
|
|
445
|
+
ValueError: If component is not found.
|
|
446
|
+
"""
|
|
447
|
+
# Deep copy ensures immutability (see insert_component for performance notes)
|
|
448
|
+
result = copy.deepcopy(components)
|
|
449
|
+
|
|
450
|
+
# Find and remove
|
|
451
|
+
parent, siblings, index = find_component_parent(result, key)
|
|
452
|
+
removed = siblings.pop(index)
|
|
453
|
+
|
|
454
|
+
return (result, removed)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def update_component(
|
|
458
|
+
components: list[dict[str, Any]],
|
|
459
|
+
key: str,
|
|
460
|
+
updates: dict[str, Any],
|
|
461
|
+
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
|
462
|
+
"""Update properties of a component.
|
|
463
|
+
|
|
464
|
+
Merges updates into the existing component. Deep merges nested dicts.
|
|
465
|
+
|
|
466
|
+
Args:
|
|
467
|
+
components: Original Form.io components list.
|
|
468
|
+
key: Key of component to update.
|
|
469
|
+
updates: Properties to update/add.
|
|
470
|
+
|
|
471
|
+
Returns:
|
|
472
|
+
Tuple of (new_components, previous_state).
|
|
473
|
+
previous_state contains the component's state before update.
|
|
474
|
+
|
|
475
|
+
Raises:
|
|
476
|
+
ValueError: If component is not found.
|
|
477
|
+
"""
|
|
478
|
+
# Deep copy ensures immutability (see insert_component for performance notes)
|
|
479
|
+
result = copy.deepcopy(components)
|
|
480
|
+
|
|
481
|
+
found = find_component(result, key)
|
|
482
|
+
if found is None:
|
|
483
|
+
raise ValueError(f"Component with key '{key}' not found")
|
|
484
|
+
|
|
485
|
+
comp = found[0]
|
|
486
|
+
previous_state = copy.deepcopy(comp)
|
|
487
|
+
|
|
488
|
+
# Deep merge updates
|
|
489
|
+
_deep_merge(comp, updates)
|
|
490
|
+
|
|
491
|
+
return (result, previous_state)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def move_component(
|
|
495
|
+
components: list[dict[str, Any]],
|
|
496
|
+
key: str,
|
|
497
|
+
new_parent_key: str | None = None,
|
|
498
|
+
new_position: int | None = None,
|
|
499
|
+
) -> tuple[list[dict[str, Any]], dict[str, str | int | None]]:
|
|
500
|
+
"""Move a component to a new location in the tree.
|
|
501
|
+
|
|
502
|
+
Args:
|
|
503
|
+
components: Original Form.io components list.
|
|
504
|
+
key: Key of component to move.
|
|
505
|
+
new_parent_key: New parent container key, or None for root.
|
|
506
|
+
new_position: Position in new parent, or None for end.
|
|
507
|
+
|
|
508
|
+
Returns:
|
|
509
|
+
Tuple of (new_components, move_info).
|
|
510
|
+
move_info contains old_parent, old_position, new_parent, new_position.
|
|
511
|
+
|
|
512
|
+
Raises:
|
|
513
|
+
ValueError: If component or new parent not found.
|
|
514
|
+
"""
|
|
515
|
+
# First remove the component
|
|
516
|
+
result, removed = remove_component(components, key)
|
|
517
|
+
|
|
518
|
+
# Track old position
|
|
519
|
+
old_parent, _, old_index = find_component_parent(components, key)
|
|
520
|
+
old_parent_key = old_parent.get("key") if old_parent else None
|
|
521
|
+
|
|
522
|
+
# Insert at new location
|
|
523
|
+
result = insert_component(result, removed, new_parent_key, new_position)
|
|
524
|
+
|
|
525
|
+
# Determine actual new position
|
|
526
|
+
actual_position: int | None = None
|
|
527
|
+
if new_parent_key:
|
|
528
|
+
new_parent = find_component(result, new_parent_key)
|
|
529
|
+
if new_parent:
|
|
530
|
+
new_siblings = new_parent[0].get("components", [])
|
|
531
|
+
actual_position = (
|
|
532
|
+
new_position if new_position is not None else len(new_siblings) - 1
|
|
533
|
+
)
|
|
534
|
+
else:
|
|
535
|
+
actual_position = new_position
|
|
536
|
+
else:
|
|
537
|
+
actual_position = new_position if new_position is not None else len(result) - 1
|
|
538
|
+
|
|
539
|
+
move_info = {
|
|
540
|
+
"old_parent": old_parent_key,
|
|
541
|
+
"old_position": old_index,
|
|
542
|
+
"new_parent": new_parent_key,
|
|
543
|
+
"new_position": actual_position,
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
return (result, move_info)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _deep_merge(target: dict[str, Any], source: dict[str, Any]) -> None:
|
|
550
|
+
"""Deep merge source dict into target dict (mutates target).
|
|
551
|
+
|
|
552
|
+
Args:
|
|
553
|
+
target: Dict to merge into.
|
|
554
|
+
source: Dict to merge from.
|
|
555
|
+
"""
|
|
556
|
+
for key, value in source.items():
|
|
557
|
+
if key in target and isinstance(target[key], dict) and isinstance(value, dict):
|
|
558
|
+
_deep_merge(target[key], value)
|
|
559
|
+
else:
|
|
560
|
+
target[key] = copy.deepcopy(value)
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
# =============================================================================
|
|
564
|
+
# Component Builders - Convenience functions for creating common components
|
|
565
|
+
# =============================================================================
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def build_textfield(
|
|
569
|
+
key: str,
|
|
570
|
+
label: str,
|
|
571
|
+
*,
|
|
572
|
+
required: bool = False,
|
|
573
|
+
placeholder: str = "",
|
|
574
|
+
tooltip: str = "",
|
|
575
|
+
description: str = "",
|
|
576
|
+
size: str = "md",
|
|
577
|
+
hidden: bool = False,
|
|
578
|
+
disabled: bool = False,
|
|
579
|
+
default_value: str = "",
|
|
580
|
+
) -> dict[str, Any]:
|
|
581
|
+
"""Build a textfield component with BPA-specific properties.
|
|
582
|
+
|
|
583
|
+
Args:
|
|
584
|
+
key: Unique component key (e.g., "applicantFullName").
|
|
585
|
+
label: Display label.
|
|
586
|
+
required: Whether field is required.
|
|
587
|
+
placeholder: Placeholder text.
|
|
588
|
+
tooltip: Tooltip text.
|
|
589
|
+
description: Field description.
|
|
590
|
+
size: Field size ("sm", "md", "lg").
|
|
591
|
+
hidden: Whether field is hidden.
|
|
592
|
+
disabled: Whether field is disabled.
|
|
593
|
+
default_value: Default value.
|
|
594
|
+
|
|
595
|
+
Returns:
|
|
596
|
+
Complete textfield component definition.
|
|
597
|
+
"""
|
|
598
|
+
component: dict[str, Any] = {
|
|
599
|
+
"type": "textfield",
|
|
600
|
+
"key": key,
|
|
601
|
+
"label": label,
|
|
602
|
+
"size": size,
|
|
603
|
+
"validate": {"required": required},
|
|
604
|
+
"input": True,
|
|
605
|
+
"tableView": True,
|
|
606
|
+
**BPA_COMPONENT_DEFAULTS,
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if placeholder:
|
|
610
|
+
component["placeholder"] = placeholder
|
|
611
|
+
if tooltip:
|
|
612
|
+
component["tooltip"] = tooltip
|
|
613
|
+
if description:
|
|
614
|
+
component["description"] = description
|
|
615
|
+
if hidden:
|
|
616
|
+
component["hidden"] = True
|
|
617
|
+
if disabled:
|
|
618
|
+
component["disabled"] = True
|
|
619
|
+
if default_value:
|
|
620
|
+
component["defaultValue"] = default_value
|
|
621
|
+
|
|
622
|
+
return component
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def build_number(
|
|
626
|
+
key: str,
|
|
627
|
+
label: str,
|
|
628
|
+
*,
|
|
629
|
+
required: bool = False,
|
|
630
|
+
min_value: float | None = None,
|
|
631
|
+
max_value: float | None = None,
|
|
632
|
+
decimal_limit: int | None = None,
|
|
633
|
+
default_value: float | None = None,
|
|
634
|
+
) -> dict[str, Any]:
|
|
635
|
+
"""Build a number component.
|
|
636
|
+
|
|
637
|
+
Args:
|
|
638
|
+
key: Unique component key.
|
|
639
|
+
label: Display label.
|
|
640
|
+
required: Whether field is required.
|
|
641
|
+
min_value: Minimum allowed value.
|
|
642
|
+
max_value: Maximum allowed value.
|
|
643
|
+
decimal_limit: Maximum decimal places.
|
|
644
|
+
default_value: Default value.
|
|
645
|
+
|
|
646
|
+
Returns:
|
|
647
|
+
Complete number component definition.
|
|
648
|
+
"""
|
|
649
|
+
component: dict[str, Any] = {
|
|
650
|
+
"type": "number",
|
|
651
|
+
"key": key,
|
|
652
|
+
"label": label,
|
|
653
|
+
"validate": {"required": required},
|
|
654
|
+
"input": True,
|
|
655
|
+
"tableView": True,
|
|
656
|
+
**BPA_COMPONENT_DEFAULTS,
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if min_value is not None:
|
|
660
|
+
component["validate"]["min"] = min_value
|
|
661
|
+
if max_value is not None:
|
|
662
|
+
component["validate"]["max"] = max_value
|
|
663
|
+
if decimal_limit is not None:
|
|
664
|
+
component["decimalLimit"] = decimal_limit
|
|
665
|
+
if default_value is not None:
|
|
666
|
+
component["defaultValue"] = default_value
|
|
667
|
+
|
|
668
|
+
return component
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def build_select(
|
|
672
|
+
key: str,
|
|
673
|
+
label: str,
|
|
674
|
+
*,
|
|
675
|
+
required: bool = False,
|
|
676
|
+
data_source: str = "values",
|
|
677
|
+
values: list[dict[str, str]] | None = None,
|
|
678
|
+
catalog: str | None = None,
|
|
679
|
+
multiple: bool = False,
|
|
680
|
+
placeholder: str = "",
|
|
681
|
+
) -> dict[str, Any]:
|
|
682
|
+
"""Build a select/dropdown component.
|
|
683
|
+
|
|
684
|
+
Args:
|
|
685
|
+
key: Unique component key.
|
|
686
|
+
label: Display label.
|
|
687
|
+
required: Whether field is required.
|
|
688
|
+
data_source: Data source type ("values", "Catalogue", "url").
|
|
689
|
+
values: List of {"label": ..., "value": ...} options.
|
|
690
|
+
catalog: Catalog name (when data_source is "Catalogue").
|
|
691
|
+
multiple: Allow multiple selections.
|
|
692
|
+
placeholder: Placeholder text.
|
|
693
|
+
|
|
694
|
+
Returns:
|
|
695
|
+
Complete select component definition.
|
|
696
|
+
"""
|
|
697
|
+
component: dict[str, Any] = {
|
|
698
|
+
"type": "select",
|
|
699
|
+
"key": key,
|
|
700
|
+
"label": label,
|
|
701
|
+
"validate": {"required": required},
|
|
702
|
+
"input": True,
|
|
703
|
+
"tableView": True,
|
|
704
|
+
"data": {"dataSrc": data_source},
|
|
705
|
+
**BPA_COMPONENT_DEFAULTS,
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
if data_source == "values" and values:
|
|
709
|
+
component["data"]["values"] = values
|
|
710
|
+
elif data_source == "Catalogue" and catalog:
|
|
711
|
+
component["data"]["catalog"] = catalog
|
|
712
|
+
component["data"]["systemSource"] = "BPA"
|
|
713
|
+
|
|
714
|
+
if multiple:
|
|
715
|
+
component["multiple"] = True
|
|
716
|
+
if placeholder:
|
|
717
|
+
component["placeholder"] = placeholder
|
|
718
|
+
|
|
719
|
+
return component
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def build_checkbox(
|
|
723
|
+
key: str,
|
|
724
|
+
label: str,
|
|
725
|
+
*,
|
|
726
|
+
required: bool = False,
|
|
727
|
+
default_value: bool = False,
|
|
728
|
+
) -> dict[str, Any]:
|
|
729
|
+
"""Build a checkbox component.
|
|
730
|
+
|
|
731
|
+
Args:
|
|
732
|
+
key: Unique component key.
|
|
733
|
+
label: Display label.
|
|
734
|
+
required: Whether field is required.
|
|
735
|
+
default_value: Default checked state.
|
|
736
|
+
|
|
737
|
+
Returns:
|
|
738
|
+
Complete checkbox component definition.
|
|
739
|
+
"""
|
|
740
|
+
return {
|
|
741
|
+
"type": "checkbox",
|
|
742
|
+
"key": key,
|
|
743
|
+
"label": label,
|
|
744
|
+
"validate": {"required": required},
|
|
745
|
+
"defaultValue": default_value,
|
|
746
|
+
"input": True,
|
|
747
|
+
"tableView": True,
|
|
748
|
+
**BPA_COMPONENT_DEFAULTS,
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def build_textarea(
|
|
753
|
+
key: str,
|
|
754
|
+
label: str,
|
|
755
|
+
*,
|
|
756
|
+
required: bool = False,
|
|
757
|
+
rows: int = 3,
|
|
758
|
+
placeholder: str = "",
|
|
759
|
+
) -> dict[str, Any]:
|
|
760
|
+
"""Build a textarea component.
|
|
761
|
+
|
|
762
|
+
Args:
|
|
763
|
+
key: Unique component key.
|
|
764
|
+
label: Display label.
|
|
765
|
+
required: Whether field is required.
|
|
766
|
+
rows: Number of visible rows.
|
|
767
|
+
placeholder: Placeholder text.
|
|
768
|
+
|
|
769
|
+
Returns:
|
|
770
|
+
Complete textarea component definition.
|
|
771
|
+
"""
|
|
772
|
+
component: dict[str, Any] = {
|
|
773
|
+
"type": "textarea",
|
|
774
|
+
"key": key,
|
|
775
|
+
"label": label,
|
|
776
|
+
"rows": rows,
|
|
777
|
+
"validate": {"required": required},
|
|
778
|
+
"input": True,
|
|
779
|
+
"tableView": True,
|
|
780
|
+
**BPA_COMPONENT_DEFAULTS,
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
if placeholder:
|
|
784
|
+
component["placeholder"] = placeholder
|
|
785
|
+
|
|
786
|
+
return component
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def build_panel(
|
|
790
|
+
key: str,
|
|
791
|
+
title: str,
|
|
792
|
+
*,
|
|
793
|
+
components: list[dict[str, Any]] | None = None,
|
|
794
|
+
collapsible: bool = False,
|
|
795
|
+
collapsed: bool = False,
|
|
796
|
+
) -> dict[str, Any]:
|
|
797
|
+
"""Build a panel container component.
|
|
798
|
+
|
|
799
|
+
Args:
|
|
800
|
+
key: Unique component key.
|
|
801
|
+
title: Panel title.
|
|
802
|
+
components: Nested components.
|
|
803
|
+
collapsible: Whether panel can be collapsed.
|
|
804
|
+
collapsed: Initial collapsed state.
|
|
805
|
+
|
|
806
|
+
Returns:
|
|
807
|
+
Complete panel component definition.
|
|
808
|
+
"""
|
|
809
|
+
panel: dict[str, Any] = {
|
|
810
|
+
"type": "panel",
|
|
811
|
+
"key": key,
|
|
812
|
+
"title": title,
|
|
813
|
+
"components": components or [],
|
|
814
|
+
"input": False,
|
|
815
|
+
"tableView": False,
|
|
816
|
+
**BPA_COMPONENT_DEFAULTS,
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
if collapsible:
|
|
820
|
+
panel["collapsible"] = True
|
|
821
|
+
panel["collapsed"] = collapsed
|
|
822
|
+
|
|
823
|
+
return panel
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def build_columns(
|
|
827
|
+
key: str,
|
|
828
|
+
*,
|
|
829
|
+
column_count: int = 2,
|
|
830
|
+
components_per_column: list[list[dict[str, Any]]] | None = None,
|
|
831
|
+
) -> dict[str, Any]:
|
|
832
|
+
"""Build a columns layout component.
|
|
833
|
+
|
|
834
|
+
Args:
|
|
835
|
+
key: Unique component key.
|
|
836
|
+
column_count: Number of columns (default 2).
|
|
837
|
+
components_per_column: List of component lists for each column.
|
|
838
|
+
|
|
839
|
+
Returns:
|
|
840
|
+
Complete columns component definition.
|
|
841
|
+
"""
|
|
842
|
+
columns = []
|
|
843
|
+
width = 12 // column_count # Bootstrap grid (12 columns)
|
|
844
|
+
|
|
845
|
+
for i in range(column_count):
|
|
846
|
+
col_components = (
|
|
847
|
+
components_per_column[i]
|
|
848
|
+
if components_per_column and i < len(components_per_column)
|
|
849
|
+
else []
|
|
850
|
+
)
|
|
851
|
+
columns.append(
|
|
852
|
+
{
|
|
853
|
+
"components": col_components,
|
|
854
|
+
"width": width,
|
|
855
|
+
"offset": 0,
|
|
856
|
+
"push": 0,
|
|
857
|
+
"pull": 0,
|
|
858
|
+
"size": "md",
|
|
859
|
+
}
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
return {
|
|
863
|
+
"type": "columns",
|
|
864
|
+
"key": key,
|
|
865
|
+
"columns": columns,
|
|
866
|
+
"input": False,
|
|
867
|
+
"tableView": False,
|
|
868
|
+
**BPA_COMPONENT_DEFAULTS,
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
# =============================================================================
|
|
873
|
+
# Validation Functions
|
|
874
|
+
# =============================================================================
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def validate_component(component: dict[str, Any]) -> list[str]:
|
|
878
|
+
"""Validate a component structure.
|
|
879
|
+
|
|
880
|
+
Args:
|
|
881
|
+
component: Component to validate.
|
|
882
|
+
|
|
883
|
+
Returns:
|
|
884
|
+
List of validation error messages (empty if valid).
|
|
885
|
+
"""
|
|
886
|
+
errors: list[str] = []
|
|
887
|
+
|
|
888
|
+
# Required fields
|
|
889
|
+
if not component.get("key"):
|
|
890
|
+
errors.append("Component must have a 'key' property")
|
|
891
|
+
elif not _is_valid_key(component["key"]):
|
|
892
|
+
errors.append(
|
|
893
|
+
f"Invalid key format '{component['key']}'. "
|
|
894
|
+
"Keys must be alphanumeric with underscores, starting with a letter."
|
|
895
|
+
)
|
|
896
|
+
|
|
897
|
+
if not component.get("type"):
|
|
898
|
+
errors.append("Component must have a 'type' property")
|
|
899
|
+
|
|
900
|
+
# Type-specific validation
|
|
901
|
+
comp_type = component.get("type", "")
|
|
902
|
+
|
|
903
|
+
if comp_type in ("textfield", "textarea", "number", "select", "checkbox"):
|
|
904
|
+
if not component.get("label"):
|
|
905
|
+
errors.append(
|
|
906
|
+
f"Component type '{comp_type}' should have a 'label' property"
|
|
907
|
+
)
|
|
908
|
+
|
|
909
|
+
if comp_type == "select":
|
|
910
|
+
data = component.get("data", {})
|
|
911
|
+
data_src = data.get("dataSrc", "values")
|
|
912
|
+
if data_src == "values" and not data.get("values"):
|
|
913
|
+
# Values source but no values - just a warning
|
|
914
|
+
pass
|
|
915
|
+
elif data_src == "Catalogue" and not data.get("catalog"):
|
|
916
|
+
errors.append(
|
|
917
|
+
"Select with Catalogue source must have 'data.catalog' property"
|
|
918
|
+
)
|
|
919
|
+
|
|
920
|
+
return errors
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def _is_valid_key(key: str) -> bool:
|
|
924
|
+
"""Check if a key follows valid naming conventions.
|
|
925
|
+
|
|
926
|
+
Args:
|
|
927
|
+
key: Component key to validate.
|
|
928
|
+
|
|
929
|
+
Returns:
|
|
930
|
+
True if key is valid.
|
|
931
|
+
"""
|
|
932
|
+
# Must start with letter, contain only alphanumeric and underscores
|
|
933
|
+
return bool(re.match(r"^[a-zA-Z][a-zA-Z0-9_]*$", key))
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def validate_form_schema(schema: dict[str, Any]) -> list[str]:
|
|
937
|
+
"""Validate a complete form schema.
|
|
938
|
+
|
|
939
|
+
Args:
|
|
940
|
+
schema: Form schema with components.
|
|
941
|
+
|
|
942
|
+
Returns:
|
|
943
|
+
List of validation error messages.
|
|
944
|
+
"""
|
|
945
|
+
errors: list[str] = []
|
|
946
|
+
|
|
947
|
+
if "components" not in schema:
|
|
948
|
+
errors.append("Form schema must have 'components' array")
|
|
949
|
+
return errors
|
|
950
|
+
|
|
951
|
+
components = schema["components"]
|
|
952
|
+
if not isinstance(components, list):
|
|
953
|
+
errors.append("'components' must be an array")
|
|
954
|
+
return errors
|
|
955
|
+
|
|
956
|
+
# Check for duplicate keys using list with duplicates preserved
|
|
957
|
+
all_keys = get_all_component_keys(components, include_duplicates=True)
|
|
958
|
+
seen: set[str] = set()
|
|
959
|
+
for key in all_keys:
|
|
960
|
+
if key in seen:
|
|
961
|
+
errors.append(f"Duplicate component key: '{key}'")
|
|
962
|
+
else:
|
|
963
|
+
seen.add(key)
|
|
964
|
+
|
|
965
|
+
# Validate each component
|
|
966
|
+
for i, comp in enumerate(components):
|
|
967
|
+
comp_errors = validate_component(comp)
|
|
968
|
+
for error in comp_errors:
|
|
969
|
+
errors.append(f"Component [{i}]: {error}")
|
|
970
|
+
|
|
971
|
+
return errors
|