fh-pydantic-form 0.1.2__py3-none-any.whl → 0.2.0__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 fh-pydantic-form might be problematic. Click here for more details.
- fh_pydantic_form/__init__.py +38 -3
- fh_pydantic_form/defaults.py +160 -0
- fh_pydantic_form/field_renderers.py +339 -134
- fh_pydantic_form/form_parser.py +75 -0
- fh_pydantic_form/form_renderer.py +238 -66
- fh_pydantic_form/type_helpers.py +108 -1
- fh_pydantic_form/ui_style.py +123 -0
- fh_pydantic_form-0.2.0.dist-info/METADATA +685 -0
- fh_pydantic_form-0.2.0.dist-info/RECORD +13 -0
- fh_pydantic_form-0.1.2.dist-info/METADATA +0 -327
- fh_pydantic_form-0.1.2.dist-info/RECORD +0 -11
- {fh_pydantic_form-0.1.2.dist-info → fh_pydantic_form-0.2.0.dist-info}/WHEEL +0 -0
- {fh_pydantic_form-0.1.2.dist-info → fh_pydantic_form-0.2.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
from datetime import date, time
|
|
3
|
+
from enum import Enum
|
|
3
4
|
from typing import (
|
|
4
5
|
Any,
|
|
5
6
|
Optional,
|
|
@@ -15,13 +16,30 @@ from pydantic.fields import FieldInfo
|
|
|
15
16
|
|
|
16
17
|
from fh_pydantic_form.registry import FieldRendererRegistry
|
|
17
18
|
from fh_pydantic_form.type_helpers import (
|
|
19
|
+
_UNSET,
|
|
18
20
|
_get_underlying_type_if_optional,
|
|
19
21
|
_is_optional_type,
|
|
22
|
+
get_default,
|
|
23
|
+
)
|
|
24
|
+
from fh_pydantic_form.ui_style import (
|
|
25
|
+
SpacingTheme,
|
|
26
|
+
SpacingValue,
|
|
27
|
+
_normalize_spacing,
|
|
28
|
+
spacing,
|
|
20
29
|
)
|
|
21
30
|
|
|
22
31
|
logger = logging.getLogger(__name__)
|
|
23
32
|
|
|
24
33
|
|
|
34
|
+
def _merge_cls(base: str, extra: str) -> str:
|
|
35
|
+
"""Return base plus extra class(es) separated by a single space (handles blanks)."""
|
|
36
|
+
if extra:
|
|
37
|
+
combined = f"{base} {extra}".strip()
|
|
38
|
+
# Remove duplicate whitespace
|
|
39
|
+
return " ".join(combined.split())
|
|
40
|
+
return base
|
|
41
|
+
|
|
42
|
+
|
|
25
43
|
class BaseFieldRenderer:
|
|
26
44
|
"""
|
|
27
45
|
Base class for field renderers
|
|
@@ -42,6 +60,7 @@ class BaseFieldRenderer:
|
|
|
42
60
|
prefix: str = "",
|
|
43
61
|
disabled: bool = False,
|
|
44
62
|
label_color: Optional[str] = None,
|
|
63
|
+
spacing: SpacingValue = SpacingTheme.NORMAL,
|
|
45
64
|
):
|
|
46
65
|
"""
|
|
47
66
|
Initialize the field renderer
|
|
@@ -53,6 +72,7 @@ class BaseFieldRenderer:
|
|
|
53
72
|
prefix: Optional prefix for the field name (used for nested fields)
|
|
54
73
|
disabled: Whether the field should be rendered as disabled
|
|
55
74
|
label_color: Optional CSS color value for the field label
|
|
75
|
+
spacing: Spacing theme to use for layout ("normal", "compact", or SpacingTheme enum)
|
|
56
76
|
"""
|
|
57
77
|
self.field_name = f"{prefix}{field_name}" if prefix else field_name
|
|
58
78
|
self.original_field_name = field_name
|
|
@@ -62,6 +82,7 @@ class BaseFieldRenderer:
|
|
|
62
82
|
self.is_optional = _is_optional_type(field_info.annotation)
|
|
63
83
|
self.disabled = disabled
|
|
64
84
|
self.label_color = label_color
|
|
85
|
+
self.spacing = _normalize_spacing(spacing)
|
|
65
86
|
|
|
66
87
|
def render_label(self) -> FT:
|
|
67
88
|
"""
|
|
@@ -79,7 +100,8 @@ class BaseFieldRenderer:
|
|
|
79
100
|
# Create span attributes with tooltip if description is available
|
|
80
101
|
span_attrs = {}
|
|
81
102
|
if description:
|
|
82
|
-
span_attrs["
|
|
103
|
+
span_attrs["uk-tooltip"] = description # UIkit tooltip
|
|
104
|
+
span_attrs["title"] = description # Standard HTML tooltip
|
|
83
105
|
# Removed cursor-help class while preserving tooltip functionality
|
|
84
106
|
|
|
85
107
|
# Create the span with the label text and tooltip
|
|
@@ -88,15 +110,27 @@ class BaseFieldRenderer:
|
|
|
88
110
|
# Prepare label attributes
|
|
89
111
|
label_attrs = {"For": self.field_name}
|
|
90
112
|
|
|
113
|
+
# Build label classes with tokenized gap
|
|
114
|
+
label_gap_class = spacing("label_gap", self.spacing)
|
|
115
|
+
base_classes = f"block text-sm font-medium text-gray-700 {label_gap_class}"
|
|
116
|
+
|
|
117
|
+
cls_attr = base_classes
|
|
118
|
+
|
|
91
119
|
# Apply color styling if specified
|
|
92
120
|
if self.label_color:
|
|
93
|
-
|
|
121
|
+
# Check if it's a CSS class (contains letters/hyphens) or a color value
|
|
122
|
+
if self.label_color.replace("-", "").replace("#", "").isalnum():
|
|
123
|
+
# Looks like a CSS class, add it to the class list
|
|
124
|
+
cls_attr = f"block text-sm font-medium {self.label_color} {label_gap_class}".strip()
|
|
125
|
+
else:
|
|
126
|
+
# Treat as color value
|
|
127
|
+
label_attrs["style"] = f"color: {self.label_color};"
|
|
94
128
|
|
|
95
129
|
# Create and return the label - using standard fh.Label with appropriate styling
|
|
96
130
|
return fh.Label(
|
|
97
131
|
label_text_span,
|
|
98
132
|
**label_attrs,
|
|
99
|
-
cls=
|
|
133
|
+
cls=cls_attr,
|
|
100
134
|
)
|
|
101
135
|
|
|
102
136
|
def render_input(self) -> FT:
|
|
@@ -141,7 +175,9 @@ class BaseFieldRenderer:
|
|
|
141
175
|
input_component, # Content component (the input field)
|
|
142
176
|
open=True, # Open by default
|
|
143
177
|
li_kwargs={"id": item_id}, # Pass the specific ID for the <li>
|
|
144
|
-
cls=
|
|
178
|
+
cls=spacing(
|
|
179
|
+
"outer_margin_sm", self.spacing
|
|
180
|
+
), # Add spacing between accordion items
|
|
145
181
|
)
|
|
146
182
|
|
|
147
183
|
# 5. Wrap the single AccordionItem in an Accordion container
|
|
@@ -150,6 +186,7 @@ class BaseFieldRenderer:
|
|
|
150
186
|
id=accordion_id, # ID for the accordion container (ul)
|
|
151
187
|
multiple=True, # Allow multiple open (though only one exists)
|
|
152
188
|
collapsible=True, # Allow toggling
|
|
189
|
+
cls=f"{spacing('accordion_divider', self.spacing)} {spacing('accordion_content', self.spacing)}".strip(),
|
|
153
190
|
)
|
|
154
191
|
|
|
155
192
|
return accordion_container
|
|
@@ -168,11 +205,13 @@ class StringFieldRenderer(BaseFieldRenderer):
|
|
|
168
205
|
Returns:
|
|
169
206
|
A TextInput component appropriate for string values
|
|
170
207
|
"""
|
|
171
|
-
is_field_required = (
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
)
|
|
208
|
+
# is_field_required = (
|
|
209
|
+
# not self.is_optional
|
|
210
|
+
# and self.field_info.default is None
|
|
211
|
+
# and getattr(self.field_info, "default_factory", None) is None
|
|
212
|
+
# )
|
|
213
|
+
has_default = get_default(self.field_info) is not _UNSET
|
|
214
|
+
is_field_required = not self.is_optional and not has_default
|
|
176
215
|
|
|
177
216
|
placeholder_text = f"Enter {self.original_field_name.replace('_', ' ')}"
|
|
178
217
|
if self.is_optional:
|
|
@@ -185,7 +224,10 @@ class StringFieldRenderer(BaseFieldRenderer):
|
|
|
185
224
|
"type": "text",
|
|
186
225
|
"placeholder": placeholder_text,
|
|
187
226
|
"required": is_field_required,
|
|
188
|
-
"cls":
|
|
227
|
+
"cls": _merge_cls(
|
|
228
|
+
"w-full",
|
|
229
|
+
f"{spacing('input_size', self.spacing)} {spacing('input_padding', self.spacing)}".strip(),
|
|
230
|
+
),
|
|
189
231
|
}
|
|
190
232
|
|
|
191
233
|
# Only add the disabled attribute if the field should actually be disabled
|
|
@@ -205,11 +247,9 @@ class NumberFieldRenderer(BaseFieldRenderer):
|
|
|
205
247
|
Returns:
|
|
206
248
|
A NumberInput component appropriate for numeric values
|
|
207
249
|
"""
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
and getattr(self.field_info, "default_factory", None) is None
|
|
212
|
-
)
|
|
250
|
+
# Determine if field is required
|
|
251
|
+
has_default = get_default(self.field_info) is not _UNSET
|
|
252
|
+
is_field_required = not self.is_optional and not has_default
|
|
213
253
|
|
|
214
254
|
placeholder_text = f"Enter {self.original_field_name.replace('_', ' ')}"
|
|
215
255
|
if self.is_optional:
|
|
@@ -222,7 +262,10 @@ class NumberFieldRenderer(BaseFieldRenderer):
|
|
|
222
262
|
"type": "number",
|
|
223
263
|
"placeholder": placeholder_text,
|
|
224
264
|
"required": is_field_required,
|
|
225
|
-
"cls":
|
|
265
|
+
"cls": _merge_cls(
|
|
266
|
+
"w-full",
|
|
267
|
+
f"{spacing('input_size', self.spacing)} {spacing('input_padding', self.spacing)}".strip(),
|
|
268
|
+
),
|
|
226
269
|
"step": "any"
|
|
227
270
|
if self.field_info.annotation is float
|
|
228
271
|
or get_origin(self.field_info.annotation) is float
|
|
@@ -278,11 +321,8 @@ class DateFieldRenderer(BaseFieldRenderer):
|
|
|
278
321
|
elif isinstance(self.value, date):
|
|
279
322
|
formatted_value = self.value.isoformat() # YYYY-MM-DD
|
|
280
323
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
and self.field_info.default is None
|
|
284
|
-
and getattr(self.field_info, "default_factory", None) is None
|
|
285
|
-
)
|
|
324
|
+
has_default = get_default(self.field_info) is not _UNSET
|
|
325
|
+
is_field_required = not self.is_optional and not has_default
|
|
286
326
|
|
|
287
327
|
placeholder_text = f"Select {self.original_field_name.replace('_', ' ')}"
|
|
288
328
|
if self.is_optional:
|
|
@@ -295,7 +335,10 @@ class DateFieldRenderer(BaseFieldRenderer):
|
|
|
295
335
|
"type": "date",
|
|
296
336
|
"placeholder": placeholder_text,
|
|
297
337
|
"required": is_field_required,
|
|
298
|
-
"cls":
|
|
338
|
+
"cls": _merge_cls(
|
|
339
|
+
"w-full",
|
|
340
|
+
f"{spacing('input_size', self.spacing)} {spacing('input_padding', self.spacing)}".strip(),
|
|
341
|
+
),
|
|
299
342
|
}
|
|
300
343
|
|
|
301
344
|
# Only add the disabled attribute if the field should actually be disabled
|
|
@@ -324,11 +367,9 @@ class TimeFieldRenderer(BaseFieldRenderer):
|
|
|
324
367
|
elif isinstance(self.value, time):
|
|
325
368
|
formatted_value = self.value.strftime("%H:%M") # HH:MM
|
|
326
369
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
and getattr(self.field_info, "default_factory", None) is None
|
|
331
|
-
)
|
|
370
|
+
# Determine if field is required
|
|
371
|
+
has_default = get_default(self.field_info) is not _UNSET
|
|
372
|
+
is_field_required = not self.is_optional and not has_default
|
|
332
373
|
|
|
333
374
|
placeholder_text = f"Select {self.original_field_name.replace('_', ' ')}"
|
|
334
375
|
if self.is_optional:
|
|
@@ -341,7 +382,10 @@ class TimeFieldRenderer(BaseFieldRenderer):
|
|
|
341
382
|
"type": "time",
|
|
342
383
|
"placeholder": placeholder_text,
|
|
343
384
|
"required": is_field_required,
|
|
344
|
-
"cls":
|
|
385
|
+
"cls": _merge_cls(
|
|
386
|
+
"w-full",
|
|
387
|
+
f"{spacing('input_size', self.spacing)} {spacing('input_padding', self.spacing)}".strip(),
|
|
388
|
+
),
|
|
345
389
|
}
|
|
346
390
|
|
|
347
391
|
# Only add the disabled attribute if the field should actually be disabled
|
|
@@ -371,11 +415,8 @@ class LiteralFieldRenderer(BaseFieldRenderer):
|
|
|
371
415
|
)
|
|
372
416
|
|
|
373
417
|
# Determine if field is required
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
and self.field_info.default is None
|
|
377
|
-
and getattr(self.field_info, "default_factory", None) is None
|
|
378
|
-
)
|
|
418
|
+
has_default = get_default(self.field_info) is not _UNSET
|
|
419
|
+
is_field_required = not self.is_optional and not has_default
|
|
379
420
|
|
|
380
421
|
# Create options for each literal value
|
|
381
422
|
options = []
|
|
@@ -409,7 +450,95 @@ class LiteralFieldRenderer(BaseFieldRenderer):
|
|
|
409
450
|
"name": self.field_name,
|
|
410
451
|
"required": is_field_required,
|
|
411
452
|
"placeholder": placeholder_text,
|
|
412
|
-
"cls":
|
|
453
|
+
"cls": _merge_cls(
|
|
454
|
+
"w-full",
|
|
455
|
+
f"{spacing('input_size', self.spacing)} {spacing('input_padding', self.spacing)}".strip(),
|
|
456
|
+
),
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
# Only add the disabled attribute if the field should actually be disabled
|
|
460
|
+
if self.disabled:
|
|
461
|
+
select_attrs["disabled"] = True
|
|
462
|
+
|
|
463
|
+
# Render the select element with options and attributes
|
|
464
|
+
return mui.Select(*options, **select_attrs)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
class EnumFieldRenderer(BaseFieldRenderer):
|
|
468
|
+
"""Renderer for Enum fields as dropdown selects"""
|
|
469
|
+
|
|
470
|
+
def render_input(self) -> FT:
|
|
471
|
+
"""
|
|
472
|
+
Render input element for the field as a select dropdown
|
|
473
|
+
|
|
474
|
+
Returns:
|
|
475
|
+
A Select component with options based on the Enum values
|
|
476
|
+
"""
|
|
477
|
+
# Get the Enum class from annotation
|
|
478
|
+
annotation = _get_underlying_type_if_optional(self.field_info.annotation)
|
|
479
|
+
enum_class = annotation
|
|
480
|
+
|
|
481
|
+
if not (isinstance(enum_class, type) and issubclass(enum_class, Enum)):
|
|
482
|
+
return mui.Alert(
|
|
483
|
+
f"No enum class found for {self.field_name}", cls=mui.AlertT.warning
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
# Get all enum members
|
|
487
|
+
enum_members = list(enum_class)
|
|
488
|
+
|
|
489
|
+
if not enum_members:
|
|
490
|
+
return mui.Alert(
|
|
491
|
+
f"No enum values found for {self.field_name}", cls=mui.AlertT.warning
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
# Determine if field is required
|
|
495
|
+
has_default = get_default(self.field_info) is not _UNSET
|
|
496
|
+
is_field_required = not self.is_optional and not has_default
|
|
497
|
+
|
|
498
|
+
# Create options for each enum value
|
|
499
|
+
options = []
|
|
500
|
+
current_value_str = None
|
|
501
|
+
|
|
502
|
+
# Convert current value to string for comparison
|
|
503
|
+
if self.value is not None:
|
|
504
|
+
if isinstance(self.value, Enum):
|
|
505
|
+
current_value_str = str(self.value.value)
|
|
506
|
+
else:
|
|
507
|
+
current_value_str = str(self.value)
|
|
508
|
+
|
|
509
|
+
# Add empty option for optional fields
|
|
510
|
+
if self.is_optional:
|
|
511
|
+
options.append(
|
|
512
|
+
fh.Option("-- None --", value="", selected=(self.value is None))
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
# Add options for each enum member
|
|
516
|
+
for member in enum_members:
|
|
517
|
+
member_value_str = str(member.value)
|
|
518
|
+
display_name = member.name.replace("_", " ").title()
|
|
519
|
+
is_selected = current_value_str == member_value_str
|
|
520
|
+
options.append(
|
|
521
|
+
fh.Option(
|
|
522
|
+
display_name, # Display text
|
|
523
|
+
value=member_value_str, # Value attribute
|
|
524
|
+
selected=is_selected,
|
|
525
|
+
)
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
placeholder_text = f"Select {self.original_field_name.replace('_', ' ')}"
|
|
529
|
+
if self.is_optional:
|
|
530
|
+
placeholder_text += " (Optional)"
|
|
531
|
+
|
|
532
|
+
# Prepare attributes dictionary
|
|
533
|
+
select_attrs = {
|
|
534
|
+
"id": self.field_name,
|
|
535
|
+
"name": self.field_name,
|
|
536
|
+
"required": is_field_required,
|
|
537
|
+
"placeholder": placeholder_text,
|
|
538
|
+
"cls": _merge_cls(
|
|
539
|
+
"w-full",
|
|
540
|
+
f"{spacing('input_size', self.spacing)} {spacing('input_padding', self.spacing)}".strip(),
|
|
541
|
+
),
|
|
413
542
|
}
|
|
414
543
|
|
|
415
544
|
# Only add the disabled attribute if the field should actually be disabled
|
|
@@ -469,24 +598,25 @@ class BaseModelFieldRenderer(BaseFieldRenderer):
|
|
|
469
598
|
input_component, # Content component (the Card with nested fields)
|
|
470
599
|
open=True, # Open by default
|
|
471
600
|
li_kwargs={"id": item_id}, # Pass the specific ID for the <li>
|
|
472
|
-
cls=
|
|
601
|
+
cls=spacing(
|
|
602
|
+
"outer_margin", self.spacing
|
|
603
|
+
), # Add bottom margin to the <li> element
|
|
473
604
|
)
|
|
474
605
|
|
|
475
606
|
# 5. Wrap the single AccordionItem in an Accordion container
|
|
476
|
-
# - Set multiple=True (harmless for single item)
|
|
477
|
-
# - Set collapsible=True
|
|
478
607
|
accordion_container = mui.Accordion(
|
|
479
608
|
accordion_item, # The single item to include
|
|
480
609
|
id=accordion_id, # ID for the accordion container (ul)
|
|
481
610
|
multiple=True, # Allow multiple open (though only one exists)
|
|
482
611
|
collapsible=True, # Allow toggling
|
|
612
|
+
cls=f"{spacing('accordion_divider', self.spacing)} {spacing('accordion_content', self.spacing)}".strip(),
|
|
483
613
|
)
|
|
484
614
|
|
|
485
615
|
return accordion_container
|
|
486
616
|
|
|
487
617
|
def render_input(self) -> FT:
|
|
488
618
|
"""
|
|
489
|
-
Render input elements for nested model fields
|
|
619
|
+
Render input elements for nested model fields with robust schema drift handling
|
|
490
620
|
|
|
491
621
|
Returns:
|
|
492
622
|
A Card component containing nested form fields
|
|
@@ -504,69 +634,95 @@ class BaseModelFieldRenderer(BaseFieldRenderer):
|
|
|
504
634
|
cls=mui.AlertT.error,
|
|
505
635
|
)
|
|
506
636
|
|
|
507
|
-
#
|
|
508
|
-
|
|
509
|
-
self.value.
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
)
|
|
637
|
+
# Robust value preparation
|
|
638
|
+
if isinstance(self.value, dict):
|
|
639
|
+
values_dict = self.value.copy()
|
|
640
|
+
elif hasattr(self.value, "model_dump"):
|
|
641
|
+
values_dict = self.value.model_dump()
|
|
642
|
+
else:
|
|
643
|
+
values_dict = {}
|
|
515
644
|
|
|
516
|
-
# Create nested field inputs
|
|
645
|
+
# Create nested field inputs with error handling
|
|
517
646
|
nested_inputs = []
|
|
647
|
+
skipped_fields = []
|
|
518
648
|
|
|
649
|
+
# Only process fields that exist in current model schema
|
|
519
650
|
for (
|
|
520
651
|
nested_field_name,
|
|
521
652
|
nested_field_info,
|
|
522
653
|
) in nested_model_class.model_fields.items():
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
654
|
+
try:
|
|
655
|
+
# Check if field exists in provided values
|
|
656
|
+
field_was_provided = nested_field_name in values_dict
|
|
657
|
+
nested_field_value = (
|
|
658
|
+
values_dict.get(nested_field_name) if field_was_provided else None
|
|
659
|
+
)
|
|
527
660
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
#
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
661
|
+
# Only use defaults if field wasn't provided
|
|
662
|
+
if not field_was_provided:
|
|
663
|
+
if nested_field_info.default is not None:
|
|
664
|
+
nested_field_value = nested_field_info.default
|
|
665
|
+
elif (
|
|
666
|
+
getattr(nested_field_info, "default_factory", None) is not None
|
|
667
|
+
):
|
|
668
|
+
try:
|
|
669
|
+
nested_field_value = nested_field_info.default_factory()
|
|
670
|
+
except Exception as e:
|
|
671
|
+
logger.warning(
|
|
672
|
+
f"Default factory failed for {nested_field_name}: {e}"
|
|
673
|
+
)
|
|
674
|
+
nested_field_value = None
|
|
675
|
+
|
|
676
|
+
# Get renderer for this nested field
|
|
677
|
+
registry = FieldRendererRegistry() # Get singleton instance
|
|
678
|
+
renderer_cls = registry.get_renderer(
|
|
679
|
+
nested_field_name, nested_field_info
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
if not renderer_cls:
|
|
683
|
+
# Fall back to StringFieldRenderer if no renderer found
|
|
684
|
+
renderer_cls = StringFieldRenderer
|
|
685
|
+
|
|
686
|
+
# The prefix for nested fields is simply the field_name of this BaseModel instance + underscore
|
|
687
|
+
# field_name already includes the form prefix, so we don't need to add self.prefix again
|
|
688
|
+
nested_prefix = f"{self.field_name}_"
|
|
689
|
+
|
|
690
|
+
# Create and render the nested field
|
|
691
|
+
renderer = renderer_cls(
|
|
692
|
+
field_name=nested_field_name,
|
|
693
|
+
field_info=nested_field_info,
|
|
694
|
+
value=nested_field_value,
|
|
695
|
+
prefix=nested_prefix,
|
|
696
|
+
disabled=self.disabled, # Propagate disabled state to nested fields
|
|
697
|
+
spacing=self.spacing, # Propagate spacing to nested fields
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
nested_inputs.append(renderer.render())
|
|
701
|
+
|
|
702
|
+
except Exception as e:
|
|
703
|
+
logger.warning(
|
|
704
|
+
f"Skipping field {nested_field_name} in nested model: {e}"
|
|
705
|
+
)
|
|
706
|
+
skipped_fields.append(nested_field_name)
|
|
707
|
+
continue
|
|
558
708
|
|
|
559
|
-
|
|
709
|
+
# Log summary if fields were skipped
|
|
710
|
+
if skipped_fields:
|
|
711
|
+
logger.info(
|
|
712
|
+
f"Skipped {len(skipped_fields)} fields in {self.field_name}: {skipped_fields}"
|
|
713
|
+
)
|
|
560
714
|
|
|
561
715
|
# Create container for nested inputs
|
|
562
716
|
nested_form_content = mui.DivVStacked(
|
|
563
|
-
*nested_inputs,
|
|
717
|
+
*nested_inputs,
|
|
718
|
+
cls=f"{spacing('inner_gap', self.spacing)} items-stretch",
|
|
564
719
|
)
|
|
565
720
|
|
|
566
721
|
# Wrap in card for visual distinction
|
|
722
|
+
t = self.spacing
|
|
567
723
|
return mui.Card(
|
|
568
724
|
nested_form_content,
|
|
569
|
-
cls="
|
|
725
|
+
cls=f"{spacing('padding_sm', t)} mt-1 {spacing('card_border', t)} rounded".strip(),
|
|
570
726
|
)
|
|
571
727
|
|
|
572
728
|
|
|
@@ -626,7 +782,11 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
626
782
|
)
|
|
627
783
|
|
|
628
784
|
# Return container with label+icon and input
|
|
629
|
-
return fh.Div(
|
|
785
|
+
return fh.Div(
|
|
786
|
+
label_with_icon,
|
|
787
|
+
self.render_input(),
|
|
788
|
+
cls=spacing("outer_margin", self.spacing),
|
|
789
|
+
)
|
|
630
790
|
|
|
631
791
|
def render_input(self) -> FT:
|
|
632
792
|
"""
|
|
@@ -688,7 +848,7 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
688
848
|
id=container_id,
|
|
689
849
|
multiple=True, # Allow multiple items to be open at once
|
|
690
850
|
collapsible=True, # Make it collapsible
|
|
691
|
-
cls="
|
|
851
|
+
cls=f"{spacing('inner_gap_small', self.spacing)} {spacing('accordion_content', self.spacing)}".strip(), # Add space between items and accordion content styling
|
|
692
852
|
)
|
|
693
853
|
|
|
694
854
|
# Empty state message if no items
|
|
@@ -728,10 +888,11 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
728
888
|
)
|
|
729
889
|
|
|
730
890
|
# Return the complete component
|
|
891
|
+
t = self.spacing
|
|
731
892
|
return fh.Div(
|
|
732
893
|
accordion,
|
|
733
894
|
empty_state,
|
|
734
|
-
cls="
|
|
895
|
+
cls=f"{spacing('outer_margin', t)} {spacing('card_border', t)} rounded-md {spacing('padding', t)}".strip(),
|
|
735
896
|
)
|
|
736
897
|
|
|
737
898
|
def _render_item_card(self, item, idx, item_type, is_open=False) -> FT:
|
|
@@ -767,8 +928,8 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
767
928
|
model_for_display = item
|
|
768
929
|
|
|
769
930
|
elif isinstance(item, dict):
|
|
770
|
-
# Item is a dict,
|
|
771
|
-
model_for_display = item_type.
|
|
931
|
+
# Item is a dict, use model_construct for better performance (defaults are known-good)
|
|
932
|
+
model_for_display = item_type.model_construct(**item)
|
|
772
933
|
|
|
773
934
|
else:
|
|
774
935
|
# Handle cases where item is None or unexpected type
|
|
@@ -812,17 +973,17 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
812
973
|
item_content_elements = []
|
|
813
974
|
|
|
814
975
|
if is_model:
|
|
815
|
-
# Handle BaseModel items
|
|
976
|
+
# Handle BaseModel items with robust schema drift handling
|
|
816
977
|
# Form name prefix + field name + index + _
|
|
817
978
|
name_prefix = f"{self.prefix}{self.original_field_name}_{idx}_"
|
|
818
979
|
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
980
|
+
# Robust value preparation for schema drift handling
|
|
981
|
+
if isinstance(item, dict):
|
|
982
|
+
nested_values = item.copy()
|
|
983
|
+
elif hasattr(item, "model_dump"):
|
|
984
|
+
nested_values = item.model_dump()
|
|
985
|
+
else:
|
|
986
|
+
nested_values = {}
|
|
826
987
|
|
|
827
988
|
# Check if there's a specific renderer registered for this item_type
|
|
828
989
|
registry = FieldRendererRegistry()
|
|
@@ -853,44 +1014,71 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
853
1014
|
# Add the rendered input to content elements
|
|
854
1015
|
item_content_elements.append(item_renderer.render_input())
|
|
855
1016
|
else:
|
|
856
|
-
# Fall back to original behavior: render each field individually
|
|
1017
|
+
# Fall back to original behavior: render each field individually with schema drift handling
|
|
1018
|
+
valid_fields = []
|
|
1019
|
+
skipped_fields = []
|
|
1020
|
+
|
|
1021
|
+
# Only process fields that exist in current model
|
|
857
1022
|
for (
|
|
858
1023
|
nested_field_name,
|
|
859
1024
|
nested_field_info,
|
|
860
1025
|
) in item_type.model_fields.items():
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
1026
|
+
try:
|
|
1027
|
+
field_was_provided = nested_field_name in nested_values
|
|
1028
|
+
nested_field_value = (
|
|
1029
|
+
nested_values.get(nested_field_name)
|
|
1030
|
+
if field_was_provided
|
|
1031
|
+
else None
|
|
1032
|
+
)
|
|
1033
|
+
|
|
1034
|
+
# Use defaults only if field not provided
|
|
1035
|
+
if not field_was_provided:
|
|
1036
|
+
if nested_field_info.default is not None:
|
|
1037
|
+
nested_field_value = nested_field_info.default
|
|
1038
|
+
elif (
|
|
1039
|
+
getattr(nested_field_info, "default_factory", None)
|
|
1040
|
+
is not None
|
|
1041
|
+
):
|
|
1042
|
+
try:
|
|
1043
|
+
nested_field_value = (
|
|
1044
|
+
nested_field_info.default_factory()
|
|
1045
|
+
)
|
|
1046
|
+
except Exception:
|
|
1047
|
+
continue # Skip fields with problematic defaults
|
|
1048
|
+
|
|
1049
|
+
# Get renderer and render field with error handling
|
|
1050
|
+
renderer_cls = FieldRendererRegistry().get_renderer(
|
|
1051
|
+
nested_field_name, nested_field_info
|
|
1052
|
+
)
|
|
1053
|
+
if not renderer_cls:
|
|
1054
|
+
renderer_cls = StringFieldRenderer
|
|
1055
|
+
|
|
1056
|
+
renderer = renderer_cls(
|
|
1057
|
+
field_name=nested_field_name,
|
|
1058
|
+
field_info=nested_field_info,
|
|
1059
|
+
value=nested_field_value,
|
|
1060
|
+
prefix=name_prefix,
|
|
1061
|
+
disabled=self.disabled, # Propagate disabled state
|
|
1062
|
+
spacing=self.spacing, # Propagate spacing
|
|
1063
|
+
)
|
|
1064
|
+
|
|
1065
|
+
# Add rendered field to valid fields
|
|
1066
|
+
valid_fields.append(renderer.render())
|
|
1067
|
+
|
|
1068
|
+
except Exception as e:
|
|
1069
|
+
logger.warning(
|
|
1070
|
+
f"Skipping problematic field {nested_field_name} in list item: {e}"
|
|
1071
|
+
)
|
|
1072
|
+
skipped_fields.append(nested_field_name)
|
|
1073
|
+
continue
|
|
1074
|
+
|
|
1075
|
+
# Log summary if fields were skipped
|
|
1076
|
+
if skipped_fields:
|
|
1077
|
+
logger.info(
|
|
1078
|
+
f"Skipped {len(skipped_fields)} fields in list item {idx}: {skipped_fields}"
|
|
890
1079
|
)
|
|
891
1080
|
|
|
892
|
-
|
|
893
|
-
item_content_elements.append(renderer.render())
|
|
1081
|
+
item_content_elements = valid_fields
|
|
894
1082
|
else:
|
|
895
1083
|
# Handle simple type items
|
|
896
1084
|
field_info = FieldInfo(annotation=item_type)
|
|
@@ -906,6 +1094,7 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
906
1094
|
value=item,
|
|
907
1095
|
prefix=self.prefix, # Correct: Provide the form prefix
|
|
908
1096
|
disabled=self.disabled, # Propagate disabled state
|
|
1097
|
+
spacing=self.spacing, # Propagate spacing
|
|
909
1098
|
)
|
|
910
1099
|
input_element = simple_renderer.render_input()
|
|
911
1100
|
item_content_elements.append(fh.Div(input_element))
|
|
@@ -986,6 +1175,7 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
986
1175
|
)
|
|
987
1176
|
|
|
988
1177
|
# Assemble actions div
|
|
1178
|
+
t = self.spacing
|
|
989
1179
|
actions = fh.Div(
|
|
990
1180
|
fh.Div( # Left side buttons
|
|
991
1181
|
delete_button, add_below_button, cls="flex items-center"
|
|
@@ -993,11 +1183,15 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
993
1183
|
fh.Div( # Right side buttons
|
|
994
1184
|
move_up_button, move_down_button, cls="flex items-center space-x-1"
|
|
995
1185
|
),
|
|
996
|
-
cls="flex justify-between w-full mt-3 pt-3
|
|
1186
|
+
cls=f"flex justify-between w-full mt-3 pt-3 {spacing('section_divider', t)}".strip(),
|
|
997
1187
|
)
|
|
998
1188
|
|
|
999
1189
|
# Create a wrapper Div for the main content elements with proper padding
|
|
1000
|
-
|
|
1190
|
+
t = self.spacing
|
|
1191
|
+
content_wrapper = fh.Div(
|
|
1192
|
+
*item_content_elements,
|
|
1193
|
+
cls=f"{spacing('card_body_pad', t)} {spacing('inner_gap', t)}",
|
|
1194
|
+
)
|
|
1001
1195
|
|
|
1002
1196
|
# Return the accordion item
|
|
1003
1197
|
title_component = fh.Span(
|
|
@@ -1005,11 +1199,16 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
1005
1199
|
)
|
|
1006
1200
|
li_attrs = {"id": full_card_id}
|
|
1007
1201
|
|
|
1202
|
+
# Build card classes conditionally based on spacing theme
|
|
1203
|
+
card_cls = "uk-card uk-margin-small-bottom"
|
|
1204
|
+
if self.spacing == SpacingTheme.NORMAL:
|
|
1205
|
+
card_cls += " uk-card-default"
|
|
1206
|
+
|
|
1008
1207
|
return mui.AccordionItem(
|
|
1009
1208
|
title_component, # Title as first positional argument
|
|
1010
1209
|
content_wrapper, # Use the new padded wrapper for content
|
|
1011
1210
|
actions, # More content elements
|
|
1012
|
-
cls=
|
|
1211
|
+
cls=card_cls, # Use theme-aware card classes
|
|
1013
1212
|
open=is_open,
|
|
1014
1213
|
li_kwargs=li_attrs, # Pass remaining li attributes without cls
|
|
1015
1214
|
)
|
|
@@ -1023,11 +1222,17 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
1023
1222
|
li_attrs = {"id": f"{self.field_name}_{idx}_error_card"}
|
|
1024
1223
|
|
|
1025
1224
|
# Wrap error component in a div with consistent padding
|
|
1026
|
-
|
|
1225
|
+
t = self.spacing
|
|
1226
|
+
content_wrapper = fh.Div(content_component, cls=spacing("card_body_pad", t))
|
|
1227
|
+
|
|
1228
|
+
# Build card classes conditionally based on spacing theme
|
|
1229
|
+
card_cls = "uk-card uk-margin-small-bottom"
|
|
1230
|
+
if self.spacing == SpacingTheme.NORMAL:
|
|
1231
|
+
card_cls += " uk-card-default"
|
|
1027
1232
|
|
|
1028
1233
|
return mui.AccordionItem(
|
|
1029
1234
|
title_component, # Title as first positional argument
|
|
1030
1235
|
content_wrapper, # Wrapped content element
|
|
1031
|
-
cls=
|
|
1236
|
+
cls=card_cls, # Use theme-aware card classes
|
|
1032
1237
|
li_kwargs=li_attrs, # Pass remaining li attributes without cls
|
|
1033
1238
|
)
|