fh-pydantic-form 0.1.3__py3-none-any.whl → 0.2.1__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 +565 -215
- fh_pydantic_form/form_parser.py +151 -43
- fh_pydantic_form/form_renderer.py +321 -161
- fh_pydantic_form/list_path.py +145 -0
- fh_pydantic_form/type_helpers.py +108 -1
- fh_pydantic_form/ui_style.py +134 -0
- fh_pydantic_form-0.2.1.dist-info/METADATA +675 -0
- fh_pydantic_form-0.2.1.dist-info/RECORD +14 -0
- fh_pydantic_form-0.1.3.dist-info/METADATA +0 -327
- fh_pydantic_form-0.1.3.dist-info/RECORD +0 -11
- {fh_pydantic_form-0.1.3.dist-info → fh_pydantic_form-0.2.1.dist-info}/WHEEL +0 -0
- {fh_pydantic_form-0.1.3.dist-info → fh_pydantic_form-0.2.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
from datetime import date, time
|
|
3
|
+
from enum import Enum
|
|
3
4
|
from typing import (
|
|
4
5
|
Any,
|
|
6
|
+
List,
|
|
5
7
|
Optional,
|
|
6
8
|
get_args,
|
|
7
9
|
get_origin,
|
|
@@ -15,13 +17,30 @@ from pydantic.fields import FieldInfo
|
|
|
15
17
|
|
|
16
18
|
from fh_pydantic_form.registry import FieldRendererRegistry
|
|
17
19
|
from fh_pydantic_form.type_helpers import (
|
|
20
|
+
_UNSET,
|
|
18
21
|
_get_underlying_type_if_optional,
|
|
19
22
|
_is_optional_type,
|
|
23
|
+
get_default,
|
|
24
|
+
)
|
|
25
|
+
from fh_pydantic_form.ui_style import (
|
|
26
|
+
SpacingTheme,
|
|
27
|
+
SpacingValue,
|
|
28
|
+
_normalize_spacing,
|
|
29
|
+
spacing,
|
|
20
30
|
)
|
|
21
31
|
|
|
22
32
|
logger = logging.getLogger(__name__)
|
|
23
33
|
|
|
24
34
|
|
|
35
|
+
def _merge_cls(base: str, extra: str) -> str:
|
|
36
|
+
"""Return base plus extra class(es) separated by a single space (handles blanks)."""
|
|
37
|
+
if extra:
|
|
38
|
+
combined = f"{base} {extra}".strip()
|
|
39
|
+
# Remove duplicate whitespace
|
|
40
|
+
return " ".join(combined.split())
|
|
41
|
+
return base
|
|
42
|
+
|
|
43
|
+
|
|
25
44
|
class BaseFieldRenderer:
|
|
26
45
|
"""
|
|
27
46
|
Base class for field renderers
|
|
@@ -42,6 +61,9 @@ class BaseFieldRenderer:
|
|
|
42
61
|
prefix: str = "",
|
|
43
62
|
disabled: bool = False,
|
|
44
63
|
label_color: Optional[str] = None,
|
|
64
|
+
spacing: SpacingValue = SpacingTheme.NORMAL,
|
|
65
|
+
field_path: Optional[List[str]] = None,
|
|
66
|
+
form_name: Optional[str] = None,
|
|
45
67
|
):
|
|
46
68
|
"""
|
|
47
69
|
Initialize the field renderer
|
|
@@ -53,15 +75,61 @@ class BaseFieldRenderer:
|
|
|
53
75
|
prefix: Optional prefix for the field name (used for nested fields)
|
|
54
76
|
disabled: Whether the field should be rendered as disabled
|
|
55
77
|
label_color: Optional CSS color value for the field label
|
|
78
|
+
spacing: Spacing theme to use for layout ("normal", "compact", or SpacingTheme enum)
|
|
79
|
+
field_path: Path segments from root to this field (for nested list support)
|
|
80
|
+
form_name: Explicit form name (used for nested list URLs)
|
|
56
81
|
"""
|
|
57
82
|
self.field_name = f"{prefix}{field_name}" if prefix else field_name
|
|
58
83
|
self.original_field_name = field_name
|
|
59
84
|
self.field_info = field_info
|
|
60
85
|
self.value = value
|
|
61
86
|
self.prefix = prefix
|
|
87
|
+
self.field_path: List[str] = field_path or []
|
|
88
|
+
self.explicit_form_name: Optional[str] = form_name
|
|
62
89
|
self.is_optional = _is_optional_type(field_info.annotation)
|
|
63
90
|
self.disabled = disabled
|
|
64
91
|
self.label_color = label_color
|
|
92
|
+
self.spacing = _normalize_spacing(spacing)
|
|
93
|
+
|
|
94
|
+
def _is_inline_color(self, color: str) -> bool:
|
|
95
|
+
"""
|
|
96
|
+
Determine if a color should be applied as an inline style or CSS class.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
color: The color value to check
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
True if the color should be applied as inline style, False if as CSS class
|
|
103
|
+
"""
|
|
104
|
+
# Check if it's a hex color value (starts with #) or basic HTML color name
|
|
105
|
+
return color.startswith("#") or color in [
|
|
106
|
+
"red",
|
|
107
|
+
"blue",
|
|
108
|
+
"green",
|
|
109
|
+
"yellow",
|
|
110
|
+
"orange",
|
|
111
|
+
"purple",
|
|
112
|
+
"pink",
|
|
113
|
+
"cyan",
|
|
114
|
+
"magenta",
|
|
115
|
+
"brown",
|
|
116
|
+
"black",
|
|
117
|
+
"white",
|
|
118
|
+
"gray",
|
|
119
|
+
"grey",
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
def _get_color_class(self, color: str) -> str:
|
|
123
|
+
"""
|
|
124
|
+
Get the appropriate CSS class for a color.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
color: The color name
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
The CSS class string for the color
|
|
131
|
+
"""
|
|
132
|
+
return f"text-{color}-600"
|
|
65
133
|
|
|
66
134
|
def render_label(self) -> FT:
|
|
67
135
|
"""
|
|
@@ -79,7 +147,8 @@ class BaseFieldRenderer:
|
|
|
79
147
|
# Create span attributes with tooltip if description is available
|
|
80
148
|
span_attrs = {}
|
|
81
149
|
if description:
|
|
82
|
-
span_attrs["
|
|
150
|
+
span_attrs["uk-tooltip"] = description # UIkit tooltip
|
|
151
|
+
span_attrs["title"] = description # Standard HTML tooltip
|
|
83
152
|
# Removed cursor-help class while preserving tooltip functionality
|
|
84
153
|
|
|
85
154
|
# Create the span with the label text and tooltip
|
|
@@ -88,15 +157,26 @@ class BaseFieldRenderer:
|
|
|
88
157
|
# Prepare label attributes
|
|
89
158
|
label_attrs = {"For": self.field_name}
|
|
90
159
|
|
|
160
|
+
# Build label classes with tokenized gap
|
|
161
|
+
label_gap_class = spacing("label_gap", self.spacing)
|
|
162
|
+
base_classes = f"block text-sm font-medium text-gray-700 {label_gap_class}"
|
|
163
|
+
|
|
164
|
+
cls_attr = base_classes
|
|
165
|
+
|
|
91
166
|
# Apply color styling if specified
|
|
92
167
|
if self.label_color:
|
|
93
|
-
|
|
168
|
+
if self._is_inline_color(self.label_color):
|
|
169
|
+
# Treat as color value
|
|
170
|
+
label_attrs["style"] = f"color: {self.label_color};"
|
|
171
|
+
else:
|
|
172
|
+
# Treat as CSS class (includes Tailwind colors like emerald, amber, rose, teal, indigo, lime, violet, etc.)
|
|
173
|
+
cls_attr = f"block text-sm font-medium {self._get_color_class(self.label_color)} {label_gap_class}".strip()
|
|
94
174
|
|
|
95
175
|
# Create and return the label - using standard fh.Label with appropriate styling
|
|
96
176
|
return fh.Label(
|
|
97
177
|
label_text_span,
|
|
98
178
|
**label_attrs,
|
|
99
|
-
cls=
|
|
179
|
+
cls=cls_attr,
|
|
100
180
|
)
|
|
101
181
|
|
|
102
182
|
def render_input(self) -> FT:
|
|
@@ -113,46 +193,38 @@ class BaseFieldRenderer:
|
|
|
113
193
|
|
|
114
194
|
def render(self) -> FT:
|
|
115
195
|
"""
|
|
116
|
-
Render the complete field (label + input) with spacing
|
|
196
|
+
Render the complete field (label + input) with spacing
|
|
197
|
+
|
|
198
|
+
For compact spacing: renders label and input side-by-side
|
|
199
|
+
For normal spacing: renders label above input (traditional)
|
|
117
200
|
|
|
118
201
|
Returns:
|
|
119
|
-
A FastHTML component
|
|
202
|
+
A FastHTML component containing the complete field
|
|
120
203
|
"""
|
|
121
|
-
# 1. Get the
|
|
204
|
+
# 1. Get the label component
|
|
122
205
|
label_component = self.render_label()
|
|
123
206
|
|
|
124
|
-
#
|
|
125
|
-
if self.label_color and isinstance(label_component, fh.FT):
|
|
126
|
-
if "style" in label_component.attrs:
|
|
127
|
-
label_component.attrs["style"] += f" color: {self.label_color};"
|
|
128
|
-
else:
|
|
129
|
-
label_component.attrs["style"] = f"color: {self.label_color};"
|
|
130
|
-
|
|
131
|
-
# 2. Render the input field that will be the accordion content
|
|
207
|
+
# 2. Render the input field
|
|
132
208
|
input_component = self.render_input()
|
|
133
209
|
|
|
134
|
-
# 3.
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
collapsible=True, # Allow toggling
|
|
153
|
-
)
|
|
154
|
-
|
|
155
|
-
return accordion_container
|
|
210
|
+
# 3. Choose layout based on spacing theme
|
|
211
|
+
if self.spacing == SpacingTheme.COMPACT:
|
|
212
|
+
# Horizontal layout for compact mode
|
|
213
|
+
return fh.Div(
|
|
214
|
+
fh.Div(
|
|
215
|
+
label_component,
|
|
216
|
+
input_component,
|
|
217
|
+
cls=f"flex {spacing('horizontal_gap', self.spacing)} {spacing('label_align', self.spacing)}",
|
|
218
|
+
),
|
|
219
|
+
cls=spacing("outer_margin", self.spacing),
|
|
220
|
+
)
|
|
221
|
+
else:
|
|
222
|
+
# Vertical layout for normal mode (existing behavior)
|
|
223
|
+
return fh.Div(
|
|
224
|
+
label_component,
|
|
225
|
+
input_component,
|
|
226
|
+
cls=spacing("outer_margin", self.spacing),
|
|
227
|
+
)
|
|
156
228
|
|
|
157
229
|
|
|
158
230
|
# ---- Specific Field Renderers ----
|
|
@@ -168,11 +240,13 @@ class StringFieldRenderer(BaseFieldRenderer):
|
|
|
168
240
|
Returns:
|
|
169
241
|
A TextInput component appropriate for string values
|
|
170
242
|
"""
|
|
171
|
-
is_field_required = (
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
)
|
|
243
|
+
# is_field_required = (
|
|
244
|
+
# not self.is_optional
|
|
245
|
+
# and self.field_info.default is None
|
|
246
|
+
# and getattr(self.field_info, "default_factory", None) is None
|
|
247
|
+
# )
|
|
248
|
+
has_default = get_default(self.field_info) is not _UNSET
|
|
249
|
+
is_field_required = not self.is_optional and not has_default
|
|
176
250
|
|
|
177
251
|
placeholder_text = f"Enter {self.original_field_name.replace('_', ' ')}"
|
|
178
252
|
if self.is_optional:
|
|
@@ -185,7 +259,10 @@ class StringFieldRenderer(BaseFieldRenderer):
|
|
|
185
259
|
"type": "text",
|
|
186
260
|
"placeholder": placeholder_text,
|
|
187
261
|
"required": is_field_required,
|
|
188
|
-
"cls":
|
|
262
|
+
"cls": _merge_cls(
|
|
263
|
+
"w-full",
|
|
264
|
+
f"{spacing('input_size', self.spacing)} {spacing('input_padding', self.spacing)}".strip(),
|
|
265
|
+
),
|
|
189
266
|
}
|
|
190
267
|
|
|
191
268
|
# Only add the disabled attribute if the field should actually be disabled
|
|
@@ -205,11 +282,9 @@ class NumberFieldRenderer(BaseFieldRenderer):
|
|
|
205
282
|
Returns:
|
|
206
283
|
A NumberInput component appropriate for numeric values
|
|
207
284
|
"""
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
and getattr(self.field_info, "default_factory", None) is None
|
|
212
|
-
)
|
|
285
|
+
# Determine if field is required
|
|
286
|
+
has_default = get_default(self.field_info) is not _UNSET
|
|
287
|
+
is_field_required = not self.is_optional and not has_default
|
|
213
288
|
|
|
214
289
|
placeholder_text = f"Enter {self.original_field_name.replace('_', ' ')}"
|
|
215
290
|
if self.is_optional:
|
|
@@ -222,7 +297,10 @@ class NumberFieldRenderer(BaseFieldRenderer):
|
|
|
222
297
|
"type": "number",
|
|
223
298
|
"placeholder": placeholder_text,
|
|
224
299
|
"required": is_field_required,
|
|
225
|
-
"cls":
|
|
300
|
+
"cls": _merge_cls(
|
|
301
|
+
"w-full",
|
|
302
|
+
f"{spacing('input_size', self.spacing)} {spacing('input_padding', self.spacing)}".strip(),
|
|
303
|
+
),
|
|
226
304
|
"step": "any"
|
|
227
305
|
if self.field_info.annotation is float
|
|
228
306
|
or get_origin(self.field_info.annotation) is float
|
|
@@ -258,6 +336,29 @@ class BooleanFieldRenderer(BaseFieldRenderer):
|
|
|
258
336
|
|
|
259
337
|
return mui.CheckboxX(**checkbox_attrs)
|
|
260
338
|
|
|
339
|
+
def render(self) -> FT:
|
|
340
|
+
"""
|
|
341
|
+
Render the complete field (label + input) with spacing, placing the checkbox next to the label.
|
|
342
|
+
|
|
343
|
+
Returns:
|
|
344
|
+
A FastHTML component containing the complete field
|
|
345
|
+
"""
|
|
346
|
+
# Get the label component
|
|
347
|
+
label_component = self.render_label()
|
|
348
|
+
|
|
349
|
+
# Get the checkbox component
|
|
350
|
+
checkbox_component = self.render_input()
|
|
351
|
+
|
|
352
|
+
# Create a flex container to place label and checkbox side by side
|
|
353
|
+
return fh.Div(
|
|
354
|
+
fh.Div(
|
|
355
|
+
label_component,
|
|
356
|
+
checkbox_component,
|
|
357
|
+
cls="flex items-center gap-2", # Use flexbox to align items horizontally with a small gap
|
|
358
|
+
),
|
|
359
|
+
cls=spacing("outer_margin", self.spacing),
|
|
360
|
+
)
|
|
361
|
+
|
|
261
362
|
|
|
262
363
|
class DateFieldRenderer(BaseFieldRenderer):
|
|
263
364
|
"""Renderer for date fields"""
|
|
@@ -278,11 +379,8 @@ class DateFieldRenderer(BaseFieldRenderer):
|
|
|
278
379
|
elif isinstance(self.value, date):
|
|
279
380
|
formatted_value = self.value.isoformat() # YYYY-MM-DD
|
|
280
381
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
and self.field_info.default is None
|
|
284
|
-
and getattr(self.field_info, "default_factory", None) is None
|
|
285
|
-
)
|
|
382
|
+
has_default = get_default(self.field_info) is not _UNSET
|
|
383
|
+
is_field_required = not self.is_optional and not has_default
|
|
286
384
|
|
|
287
385
|
placeholder_text = f"Select {self.original_field_name.replace('_', ' ')}"
|
|
288
386
|
if self.is_optional:
|
|
@@ -295,7 +393,10 @@ class DateFieldRenderer(BaseFieldRenderer):
|
|
|
295
393
|
"type": "date",
|
|
296
394
|
"placeholder": placeholder_text,
|
|
297
395
|
"required": is_field_required,
|
|
298
|
-
"cls":
|
|
396
|
+
"cls": _merge_cls(
|
|
397
|
+
"w-full",
|
|
398
|
+
f"{spacing('input_size', self.spacing)} {spacing('input_padding', self.spacing)}".strip(),
|
|
399
|
+
),
|
|
299
400
|
}
|
|
300
401
|
|
|
301
402
|
# Only add the disabled attribute if the field should actually be disabled
|
|
@@ -324,11 +425,9 @@ class TimeFieldRenderer(BaseFieldRenderer):
|
|
|
324
425
|
elif isinstance(self.value, time):
|
|
325
426
|
formatted_value = self.value.strftime("%H:%M") # HH:MM
|
|
326
427
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
and getattr(self.field_info, "default_factory", None) is None
|
|
331
|
-
)
|
|
428
|
+
# Determine if field is required
|
|
429
|
+
has_default = get_default(self.field_info) is not _UNSET
|
|
430
|
+
is_field_required = not self.is_optional and not has_default
|
|
332
431
|
|
|
333
432
|
placeholder_text = f"Select {self.original_field_name.replace('_', ' ')}"
|
|
334
433
|
if self.is_optional:
|
|
@@ -341,7 +440,10 @@ class TimeFieldRenderer(BaseFieldRenderer):
|
|
|
341
440
|
"type": "time",
|
|
342
441
|
"placeholder": placeholder_text,
|
|
343
442
|
"required": is_field_required,
|
|
344
|
-
"cls":
|
|
443
|
+
"cls": _merge_cls(
|
|
444
|
+
"w-full",
|
|
445
|
+
f"{spacing('input_size', self.spacing)} {spacing('input_padding', self.spacing)}".strip(),
|
|
446
|
+
),
|
|
345
447
|
}
|
|
346
448
|
|
|
347
449
|
# Only add the disabled attribute if the field should actually be disabled
|
|
@@ -371,11 +473,8 @@ class LiteralFieldRenderer(BaseFieldRenderer):
|
|
|
371
473
|
)
|
|
372
474
|
|
|
373
475
|
# 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
|
-
)
|
|
476
|
+
has_default = get_default(self.field_info) is not _UNSET
|
|
477
|
+
is_field_required = not self.is_optional and not has_default
|
|
379
478
|
|
|
380
479
|
# Create options for each literal value
|
|
381
480
|
options = []
|
|
@@ -409,7 +508,95 @@ class LiteralFieldRenderer(BaseFieldRenderer):
|
|
|
409
508
|
"name": self.field_name,
|
|
410
509
|
"required": is_field_required,
|
|
411
510
|
"placeholder": placeholder_text,
|
|
412
|
-
"cls":
|
|
511
|
+
"cls": _merge_cls(
|
|
512
|
+
"w-full",
|
|
513
|
+
f"{spacing('input_size', self.spacing)} {spacing('input_padding', self.spacing)}".strip(),
|
|
514
|
+
),
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
# Only add the disabled attribute if the field should actually be disabled
|
|
518
|
+
if self.disabled:
|
|
519
|
+
select_attrs["disabled"] = True
|
|
520
|
+
|
|
521
|
+
# Render the select element with options and attributes
|
|
522
|
+
return mui.Select(*options, **select_attrs)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
class EnumFieldRenderer(BaseFieldRenderer):
|
|
526
|
+
"""Renderer for Enum fields as dropdown selects"""
|
|
527
|
+
|
|
528
|
+
def render_input(self) -> FT:
|
|
529
|
+
"""
|
|
530
|
+
Render input element for the field as a select dropdown
|
|
531
|
+
|
|
532
|
+
Returns:
|
|
533
|
+
A Select component with options based on the Enum values
|
|
534
|
+
"""
|
|
535
|
+
# Get the Enum class from annotation
|
|
536
|
+
annotation = _get_underlying_type_if_optional(self.field_info.annotation)
|
|
537
|
+
enum_class = annotation
|
|
538
|
+
|
|
539
|
+
if not (isinstance(enum_class, type) and issubclass(enum_class, Enum)):
|
|
540
|
+
return mui.Alert(
|
|
541
|
+
f"No enum class found for {self.field_name}", cls=mui.AlertT.warning
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
# Get all enum members
|
|
545
|
+
enum_members = list(enum_class)
|
|
546
|
+
|
|
547
|
+
if not enum_members:
|
|
548
|
+
return mui.Alert(
|
|
549
|
+
f"No enum values found for {self.field_name}", cls=mui.AlertT.warning
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
# Determine if field is required
|
|
553
|
+
has_default = get_default(self.field_info) is not _UNSET
|
|
554
|
+
is_field_required = not self.is_optional and not has_default
|
|
555
|
+
|
|
556
|
+
# Create options for each enum value
|
|
557
|
+
options = []
|
|
558
|
+
current_value_str = None
|
|
559
|
+
|
|
560
|
+
# Convert current value to string for comparison
|
|
561
|
+
if self.value is not None:
|
|
562
|
+
if isinstance(self.value, Enum):
|
|
563
|
+
current_value_str = str(self.value.value)
|
|
564
|
+
else:
|
|
565
|
+
current_value_str = str(self.value)
|
|
566
|
+
|
|
567
|
+
# Add empty option for optional fields
|
|
568
|
+
if self.is_optional:
|
|
569
|
+
options.append(
|
|
570
|
+
fh.Option("-- None --", value="", selected=(self.value is None))
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
# Add options for each enum member
|
|
574
|
+
for member in enum_members:
|
|
575
|
+
member_value_str = str(member.value)
|
|
576
|
+
display_name = member.name.replace("_", " ").title()
|
|
577
|
+
is_selected = current_value_str == member_value_str
|
|
578
|
+
options.append(
|
|
579
|
+
fh.Option(
|
|
580
|
+
display_name, # Display text
|
|
581
|
+
value=member_value_str, # Value attribute
|
|
582
|
+
selected=is_selected,
|
|
583
|
+
)
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
placeholder_text = f"Select {self.original_field_name.replace('_', ' ')}"
|
|
587
|
+
if self.is_optional:
|
|
588
|
+
placeholder_text += " (Optional)"
|
|
589
|
+
|
|
590
|
+
# Prepare attributes dictionary
|
|
591
|
+
select_attrs = {
|
|
592
|
+
"id": self.field_name,
|
|
593
|
+
"name": self.field_name,
|
|
594
|
+
"required": is_field_required,
|
|
595
|
+
"placeholder": placeholder_text,
|
|
596
|
+
"cls": _merge_cls(
|
|
597
|
+
"w-full",
|
|
598
|
+
f"{spacing('input_size', self.spacing)} {spacing('input_padding', self.spacing)}".strip(),
|
|
599
|
+
),
|
|
413
600
|
}
|
|
414
601
|
|
|
415
602
|
# Only add the disabled attribute if the field should actually be disabled
|
|
@@ -430,26 +617,36 @@ class BaseModelFieldRenderer(BaseFieldRenderer):
|
|
|
430
617
|
Returns:
|
|
431
618
|
A FastHTML component (mui.Accordion) containing the accordion structure.
|
|
432
619
|
"""
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
620
|
+
|
|
621
|
+
# Extract the label text and apply color styling
|
|
622
|
+
label_text = self.original_field_name.replace("_", " ").title()
|
|
623
|
+
|
|
624
|
+
# Create the title component with proper color styling
|
|
625
|
+
if self.label_color:
|
|
626
|
+
if self._is_inline_color(self.label_color):
|
|
627
|
+
# Color value - apply as inline style
|
|
628
|
+
title_component = fh.Span(
|
|
629
|
+
label_text,
|
|
630
|
+
style=f"color: {self.label_color};",
|
|
631
|
+
cls="text-sm font-medium",
|
|
632
|
+
)
|
|
633
|
+
else:
|
|
634
|
+
# CSS class - apply as Tailwind class (includes emerald, amber, rose, teal, indigo, lime, violet, etc.)
|
|
635
|
+
title_component = fh.Span(
|
|
636
|
+
label_text,
|
|
637
|
+
cls=f"text-sm font-medium {self._get_color_class(self.label_color)}",
|
|
638
|
+
)
|
|
447
639
|
else:
|
|
448
|
-
#
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
640
|
+
# No color specified - use default styling
|
|
641
|
+
title_component = fh.Span(
|
|
642
|
+
label_text, cls="text-sm font-medium text-gray-700"
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
# Add tooltip if description is available
|
|
646
|
+
description = getattr(self.field_info, "description", None)
|
|
647
|
+
if description:
|
|
648
|
+
title_component.attrs["uk-tooltip"] = description
|
|
649
|
+
title_component.attrs["title"] = description
|
|
453
650
|
|
|
454
651
|
# 2. Render the nested input fields that will be the accordion content
|
|
455
652
|
input_component = self.render_input()
|
|
@@ -459,34 +656,30 @@ class BaseModelFieldRenderer(BaseFieldRenderer):
|
|
|
459
656
|
accordion_id = f"{self.field_name}_accordion"
|
|
460
657
|
|
|
461
658
|
# 4. Create the AccordionItem using the MonsterUI component
|
|
462
|
-
# - Pass label_content as the title.
|
|
463
|
-
# - Pass input_component as the content.
|
|
464
|
-
# - Set 'open=True' to be expanded by default.
|
|
465
|
-
# - Pass item_id via li_kwargs.
|
|
466
|
-
# - Add 'mb-4' class for bottom margin spacing.
|
|
467
659
|
accordion_item = mui.AccordionItem(
|
|
468
|
-
|
|
660
|
+
title_component, # Title component with proper color styling
|
|
469
661
|
input_component, # Content component (the Card with nested fields)
|
|
470
662
|
open=True, # Open by default
|
|
471
663
|
li_kwargs={"id": item_id}, # Pass the specific ID for the <li>
|
|
472
|
-
cls=
|
|
664
|
+
cls=spacing(
|
|
665
|
+
"outer_margin", self.spacing
|
|
666
|
+
), # Add bottom margin to the <li> element
|
|
473
667
|
)
|
|
474
668
|
|
|
475
669
|
# 5. Wrap the single AccordionItem in an Accordion container
|
|
476
|
-
# - Set multiple=True (harmless for single item)
|
|
477
|
-
# - Set collapsible=True
|
|
478
670
|
accordion_container = mui.Accordion(
|
|
479
671
|
accordion_item, # The single item to include
|
|
480
672
|
id=accordion_id, # ID for the accordion container (ul)
|
|
481
673
|
multiple=True, # Allow multiple open (though only one exists)
|
|
482
674
|
collapsible=True, # Allow toggling
|
|
675
|
+
cls=f"{spacing('accordion_divider', self.spacing)} {spacing('accordion_content', self.spacing)}".strip(),
|
|
483
676
|
)
|
|
484
677
|
|
|
485
678
|
return accordion_container
|
|
486
679
|
|
|
487
680
|
def render_input(self) -> FT:
|
|
488
681
|
"""
|
|
489
|
-
Render input elements for nested model fields
|
|
682
|
+
Render input elements for nested model fields with robust schema drift handling
|
|
490
683
|
|
|
491
684
|
Returns:
|
|
492
685
|
A Card component containing nested form fields
|
|
@@ -504,75 +697,151 @@ class BaseModelFieldRenderer(BaseFieldRenderer):
|
|
|
504
697
|
cls=mui.AlertT.error,
|
|
505
698
|
)
|
|
506
699
|
|
|
507
|
-
#
|
|
508
|
-
|
|
509
|
-
self.value.
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
)
|
|
700
|
+
# Robust value preparation
|
|
701
|
+
if isinstance(self.value, dict):
|
|
702
|
+
values_dict = self.value.copy()
|
|
703
|
+
elif hasattr(self.value, "model_dump"):
|
|
704
|
+
values_dict = self.value.model_dump()
|
|
705
|
+
else:
|
|
706
|
+
values_dict = {}
|
|
515
707
|
|
|
516
|
-
# Create nested field inputs
|
|
708
|
+
# Create nested field inputs with error handling
|
|
517
709
|
nested_inputs = []
|
|
710
|
+
skipped_fields = []
|
|
518
711
|
|
|
712
|
+
# Only process fields that exist in current model schema
|
|
519
713
|
for (
|
|
520
714
|
nested_field_name,
|
|
521
715
|
nested_field_info,
|
|
522
716
|
) in nested_model_class.model_fields.items():
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
717
|
+
try:
|
|
718
|
+
# Check if field exists in provided values
|
|
719
|
+
field_was_provided = nested_field_name in values_dict
|
|
720
|
+
nested_field_value = (
|
|
721
|
+
values_dict.get(nested_field_name) if field_was_provided else None
|
|
722
|
+
)
|
|
527
723
|
|
|
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
|
-
|
|
724
|
+
# Only use defaults if field wasn't provided
|
|
725
|
+
if not field_was_provided:
|
|
726
|
+
if nested_field_info.default is not None:
|
|
727
|
+
nested_field_value = nested_field_info.default
|
|
728
|
+
elif (
|
|
729
|
+
getattr(nested_field_info, "default_factory", None) is not None
|
|
730
|
+
):
|
|
731
|
+
try:
|
|
732
|
+
nested_field_value = nested_field_info.default_factory()
|
|
733
|
+
except Exception as e:
|
|
734
|
+
logger.warning(
|
|
735
|
+
f"Default factory failed for {nested_field_name}: {e}"
|
|
736
|
+
)
|
|
737
|
+
nested_field_value = None
|
|
738
|
+
|
|
739
|
+
# Get renderer for this nested field
|
|
740
|
+
registry = FieldRendererRegistry() # Get singleton instance
|
|
741
|
+
renderer_cls = registry.get_renderer(
|
|
742
|
+
nested_field_name, nested_field_info
|
|
743
|
+
)
|
|
744
|
+
|
|
745
|
+
if not renderer_cls:
|
|
746
|
+
# Fall back to StringFieldRenderer if no renderer found
|
|
747
|
+
renderer_cls = StringFieldRenderer
|
|
748
|
+
|
|
749
|
+
# The prefix for nested fields is simply the field_name of this BaseModel instance + underscore
|
|
750
|
+
# field_name already includes the form prefix, so we don't need to add self.prefix again
|
|
751
|
+
nested_prefix = f"{self.field_name}_"
|
|
752
|
+
|
|
753
|
+
# Create and render the nested field
|
|
754
|
+
renderer = renderer_cls(
|
|
755
|
+
field_name=nested_field_name,
|
|
756
|
+
field_info=nested_field_info,
|
|
757
|
+
value=nested_field_value,
|
|
758
|
+
prefix=nested_prefix,
|
|
759
|
+
disabled=self.disabled, # Propagate disabled state to nested fields
|
|
760
|
+
spacing=self.spacing, # Propagate spacing to nested fields
|
|
761
|
+
field_path=self.field_path + [nested_field_name], # Propagate path
|
|
762
|
+
form_name=self.explicit_form_name, # Propagate form name
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
nested_inputs.append(renderer.render())
|
|
766
|
+
|
|
767
|
+
except Exception as e:
|
|
768
|
+
logger.warning(
|
|
769
|
+
f"Skipping field {nested_field_name} in nested model: {e}"
|
|
770
|
+
)
|
|
771
|
+
skipped_fields.append(nested_field_name)
|
|
772
|
+
continue
|
|
558
773
|
|
|
559
|
-
|
|
774
|
+
# Log summary if fields were skipped
|
|
775
|
+
if skipped_fields:
|
|
776
|
+
logger.info(
|
|
777
|
+
f"Skipped {len(skipped_fields)} fields in {self.field_name}: {skipped_fields}"
|
|
778
|
+
)
|
|
560
779
|
|
|
561
780
|
# Create container for nested inputs
|
|
562
781
|
nested_form_content = mui.DivVStacked(
|
|
563
|
-
*nested_inputs,
|
|
782
|
+
*nested_inputs,
|
|
783
|
+
cls=f"{spacing('inner_gap', self.spacing)} items-stretch",
|
|
564
784
|
)
|
|
565
785
|
|
|
566
786
|
# Wrap in card for visual distinction
|
|
787
|
+
t = self.spacing
|
|
567
788
|
return mui.Card(
|
|
568
789
|
nested_form_content,
|
|
569
|
-
cls="
|
|
790
|
+
cls=f"{spacing('padding_sm', t)} mt-1 {spacing('card_border', t)} rounded".strip(),
|
|
570
791
|
)
|
|
571
792
|
|
|
572
793
|
|
|
573
794
|
class ListFieldRenderer(BaseFieldRenderer):
|
|
574
795
|
"""Renderer for list fields containing any type"""
|
|
575
796
|
|
|
797
|
+
def _container_id(self) -> str:
|
|
798
|
+
"""
|
|
799
|
+
Return a DOM-unique ID for the list's <ul> / <div> wrapper.
|
|
800
|
+
|
|
801
|
+
Format: <formname>_<hierarchy>_items_container
|
|
802
|
+
Example: main_form_compact_tags_items_container
|
|
803
|
+
"""
|
|
804
|
+
base = "_".join(self.field_path) # tags or main_address_tags
|
|
805
|
+
if self._form_name: # already resolved in property
|
|
806
|
+
return f"{self._form_name}_{base}_items_container"
|
|
807
|
+
return f"{base}_items_container" # fallback (shouldn't happen)
|
|
808
|
+
|
|
809
|
+
@property
|
|
810
|
+
def _form_name(self) -> str:
|
|
811
|
+
"""Get form name - prefer explicit form name if provided"""
|
|
812
|
+
if self.explicit_form_name:
|
|
813
|
+
return self.explicit_form_name
|
|
814
|
+
|
|
815
|
+
# Fallback to extracting from prefix (for backward compatibility)
|
|
816
|
+
# The prefix always starts with the form name followed by underscore
|
|
817
|
+
# e.g., "main_form_compact_" or "main_form_compact_main_address_tags_"
|
|
818
|
+
# We need to extract just "main_form_compact"
|
|
819
|
+
if self.prefix:
|
|
820
|
+
# For backward compatibility with existing non-nested lists
|
|
821
|
+
# Split by underscore and rebuild the form name by removing known field components
|
|
822
|
+
parts = self.prefix.rstrip("_").split("_")
|
|
823
|
+
|
|
824
|
+
# For a simple heuristic: form names typically have 2-3 parts (main_form_compact)
|
|
825
|
+
# Field paths are at the end, so we find where the form name ends
|
|
826
|
+
# This is imperfect but works for most cases
|
|
827
|
+
if len(parts) >= 3 and parts[1] == "form":
|
|
828
|
+
# Standard pattern: main_form_compact
|
|
829
|
+
form_name = "_".join(parts[:3])
|
|
830
|
+
elif len(parts) >= 2:
|
|
831
|
+
# Fallback: take first 2 parts
|
|
832
|
+
form_name = "_".join(parts[:2])
|
|
833
|
+
else:
|
|
834
|
+
# Single part
|
|
835
|
+
form_name = parts[0] if parts else ""
|
|
836
|
+
|
|
837
|
+
return form_name
|
|
838
|
+
return ""
|
|
839
|
+
|
|
840
|
+
@property
|
|
841
|
+
def _list_path(self) -> str:
|
|
842
|
+
"""Get the hierarchical path for this list field"""
|
|
843
|
+
return "/".join(self.field_path)
|
|
844
|
+
|
|
576
845
|
def render(self) -> FT:
|
|
577
846
|
"""
|
|
578
847
|
Render the complete field (label + input) with spacing, adding a refresh icon for list fields.
|
|
@@ -582,13 +851,42 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
582
851
|
A FastHTML component containing the complete field with refresh icon
|
|
583
852
|
"""
|
|
584
853
|
# Extract form name from prefix (removing trailing underscore if present)
|
|
585
|
-
form_name = self.prefix.rstrip("_") if self.prefix else None
|
|
854
|
+
# form_name = self.prefix.rstrip("_") if self.prefix else None
|
|
855
|
+
form_name = self._form_name or None
|
|
856
|
+
|
|
857
|
+
# Create the label text with proper color styling
|
|
858
|
+
label_text = self.original_field_name.replace("_", " ").title()
|
|
586
859
|
|
|
587
|
-
#
|
|
588
|
-
|
|
860
|
+
# Create the styled label span
|
|
861
|
+
if self.label_color:
|
|
862
|
+
if self._is_inline_color(self.label_color):
|
|
863
|
+
# Color value - apply as inline style
|
|
864
|
+
label_span = fh.Span(
|
|
865
|
+
label_text,
|
|
866
|
+
style=f"color: {self.label_color};",
|
|
867
|
+
cls=f"block text-sm font-medium {spacing('label_gap', self.spacing)}",
|
|
868
|
+
)
|
|
869
|
+
else:
|
|
870
|
+
# CSS class - apply as Tailwind class (includes emerald, amber, rose, teal, indigo, lime, violet, etc.)
|
|
871
|
+
label_span = fh.Span(
|
|
872
|
+
label_text,
|
|
873
|
+
cls=f"block text-sm font-medium {self._get_color_class(self.label_color)} {spacing('label_gap', self.spacing)}",
|
|
874
|
+
)
|
|
875
|
+
else:
|
|
876
|
+
# No color specified - use default styling
|
|
877
|
+
label_span = fh.Span(
|
|
878
|
+
label_text,
|
|
879
|
+
cls=f"block text-sm font-medium text-gray-700 {spacing('label_gap', self.spacing)}",
|
|
880
|
+
)
|
|
881
|
+
|
|
882
|
+
# Add tooltip if description is available
|
|
883
|
+
description = getattr(self.field_info, "description", None)
|
|
884
|
+
if description:
|
|
885
|
+
label_span.attrs["uk-tooltip"] = description
|
|
886
|
+
label_span.attrs["title"] = description
|
|
589
887
|
|
|
590
888
|
# Construct the container ID that will be generated by render_input()
|
|
591
|
-
container_id =
|
|
889
|
+
container_id = self._container_id()
|
|
592
890
|
|
|
593
891
|
# Only add refresh icon if we have a form name
|
|
594
892
|
if form_name:
|
|
@@ -605,28 +903,34 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
605
903
|
hx_post=f"/form/{form_name}/refresh",
|
|
606
904
|
hx_target=f"#{form_name}-inputs-wrapper",
|
|
607
905
|
hx_swap="innerHTML",
|
|
608
|
-
hx_include=
|
|
906
|
+
hx_include="closest form", # ← key change
|
|
609
907
|
uk_tooltip="Refresh form display to update list summaries",
|
|
908
|
+
# Prevent 'toggleListItems' on the parent from firing
|
|
909
|
+
onclick="event.stopPropagation();",
|
|
610
910
|
)
|
|
611
911
|
|
|
612
912
|
# Combine label and icon
|
|
613
913
|
label_with_icon = fh.Div(
|
|
614
|
-
|
|
914
|
+
label_span, # Use the properly styled label span
|
|
615
915
|
refresh_icon_trigger,
|
|
616
916
|
cls="flex items-center cursor-pointer", # Added cursor-pointer
|
|
617
917
|
onclick=f"toggleListItems('{container_id}'); return false;", # Add click handler
|
|
618
918
|
)
|
|
619
919
|
else:
|
|
620
|
-
# If no form name, just use the
|
|
920
|
+
# If no form name, just use the styled label but still make it clickable
|
|
621
921
|
label_with_icon = fh.Div(
|
|
622
|
-
|
|
922
|
+
label_span, # Use the properly styled label span
|
|
623
923
|
cls="flex items-center cursor-pointer", # Added cursor-pointer
|
|
624
924
|
onclick=f"toggleListItems('{container_id}'); return false;", # Add click handler
|
|
625
925
|
uk_tooltip="Click to toggle all items open/closed",
|
|
626
926
|
)
|
|
627
927
|
|
|
628
928
|
# Return container with label+icon and input
|
|
629
|
-
return fh.Div(
|
|
929
|
+
return fh.Div(
|
|
930
|
+
label_with_icon,
|
|
931
|
+
self.render_input(),
|
|
932
|
+
cls=spacing("outer_margin", self.spacing),
|
|
933
|
+
)
|
|
630
934
|
|
|
631
935
|
def render_input(self) -> FT:
|
|
632
936
|
"""
|
|
@@ -679,8 +983,8 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
679
983
|
)
|
|
680
984
|
)
|
|
681
985
|
|
|
682
|
-
# Container for list items
|
|
683
|
-
container_id =
|
|
986
|
+
# Container for list items using hierarchical field path
|
|
987
|
+
container_id = self._container_id()
|
|
684
988
|
|
|
685
989
|
# Use mui.Accordion component
|
|
686
990
|
accordion = mui.Accordion(
|
|
@@ -688,19 +992,16 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
688
992
|
id=container_id,
|
|
689
993
|
multiple=True, # Allow multiple items to be open at once
|
|
690
994
|
collapsible=True, # Make it collapsible
|
|
691
|
-
cls="
|
|
995
|
+
cls=f"{spacing('inner_gap_small', self.spacing)} {spacing('accordion_content', self.spacing)}".strip(), # Add space between items and accordion content styling
|
|
692
996
|
)
|
|
693
997
|
|
|
694
998
|
# Empty state message if no items
|
|
695
999
|
empty_state = ""
|
|
696
1000
|
if not items:
|
|
697
|
-
#
|
|
698
|
-
form_name = self.prefix.rstrip("_") if self.prefix else None
|
|
699
|
-
|
|
700
|
-
# Check if it's a simple type or BaseModel
|
|
1001
|
+
# Use hierarchical path for URL
|
|
701
1002
|
add_url = (
|
|
702
|
-
f"/form/{
|
|
703
|
-
if
|
|
1003
|
+
f"/form/{self._form_name}/list/add/{self._list_path}"
|
|
1004
|
+
if self._form_name
|
|
704
1005
|
else f"/list/add/{self.field_name}"
|
|
705
1006
|
)
|
|
706
1007
|
|
|
@@ -728,10 +1029,11 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
728
1029
|
)
|
|
729
1030
|
|
|
730
1031
|
# Return the complete component
|
|
1032
|
+
t = self.spacing
|
|
731
1033
|
return fh.Div(
|
|
732
1034
|
accordion,
|
|
733
1035
|
empty_state,
|
|
734
|
-
cls="
|
|
1036
|
+
cls=f"{spacing('outer_margin', t)} {spacing('card_border', t)} rounded-md {spacing('padding', t)}".strip(),
|
|
735
1037
|
)
|
|
736
1038
|
|
|
737
1039
|
def _render_item_card(self, item, idx, item_type, is_open=False) -> FT:
|
|
@@ -752,9 +1054,6 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
752
1054
|
item_id = f"{self.field_name}_{idx}"
|
|
753
1055
|
item_card_id = f"{item_id}_card"
|
|
754
1056
|
|
|
755
|
-
# Extract form name from prefix if available
|
|
756
|
-
form_name = self.prefix.rstrip("_") if self.prefix else None
|
|
757
|
-
|
|
758
1057
|
# Check if it's a simple type or BaseModel
|
|
759
1058
|
is_model = hasattr(item_type, "model_fields")
|
|
760
1059
|
|
|
@@ -767,8 +1066,8 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
767
1066
|
model_for_display = item
|
|
768
1067
|
|
|
769
1068
|
elif isinstance(item, dict):
|
|
770
|
-
# Item is a dict,
|
|
771
|
-
model_for_display = item_type.
|
|
1069
|
+
# Item is a dict, use model_construct for better performance (defaults are known-good)
|
|
1070
|
+
model_for_display = item_type.model_construct(**item)
|
|
772
1071
|
|
|
773
1072
|
else:
|
|
774
1073
|
# Handle cases where item is None or unexpected type
|
|
@@ -812,17 +1111,17 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
812
1111
|
item_content_elements = []
|
|
813
1112
|
|
|
814
1113
|
if is_model:
|
|
815
|
-
# Handle BaseModel items
|
|
1114
|
+
# Handle BaseModel items with robust schema drift handling
|
|
816
1115
|
# Form name prefix + field name + index + _
|
|
817
1116
|
name_prefix = f"{self.prefix}{self.original_field_name}_{idx}_"
|
|
818
1117
|
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
1118
|
+
# Robust value preparation for schema drift handling
|
|
1119
|
+
if isinstance(item, dict):
|
|
1120
|
+
nested_values = item.copy()
|
|
1121
|
+
elif hasattr(item, "model_dump"):
|
|
1122
|
+
nested_values = item.model_dump()
|
|
1123
|
+
else:
|
|
1124
|
+
nested_values = {}
|
|
826
1125
|
|
|
827
1126
|
# Check if there's a specific renderer registered for this item_type
|
|
828
1127
|
registry = FieldRendererRegistry()
|
|
@@ -853,44 +1152,76 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
853
1152
|
# Add the rendered input to content elements
|
|
854
1153
|
item_content_elements.append(item_renderer.render_input())
|
|
855
1154
|
else:
|
|
856
|
-
# Fall back to original behavior: render each field individually
|
|
1155
|
+
# Fall back to original behavior: render each field individually with schema drift handling
|
|
1156
|
+
valid_fields = []
|
|
1157
|
+
skipped_fields = []
|
|
1158
|
+
|
|
1159
|
+
# Only process fields that exist in current model
|
|
857
1160
|
for (
|
|
858
1161
|
nested_field_name,
|
|
859
1162
|
nested_field_info,
|
|
860
1163
|
) 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
|
-
|
|
1164
|
+
try:
|
|
1165
|
+
field_was_provided = nested_field_name in nested_values
|
|
1166
|
+
nested_field_value = (
|
|
1167
|
+
nested_values.get(nested_field_name)
|
|
1168
|
+
if field_was_provided
|
|
1169
|
+
else None
|
|
1170
|
+
)
|
|
1171
|
+
|
|
1172
|
+
# Use defaults only if field not provided
|
|
1173
|
+
if not field_was_provided:
|
|
1174
|
+
if nested_field_info.default is not None:
|
|
1175
|
+
nested_field_value = nested_field_info.default
|
|
1176
|
+
elif (
|
|
1177
|
+
getattr(nested_field_info, "default_factory", None)
|
|
1178
|
+
is not None
|
|
1179
|
+
):
|
|
1180
|
+
try:
|
|
1181
|
+
nested_field_value = (
|
|
1182
|
+
nested_field_info.default_factory()
|
|
1183
|
+
)
|
|
1184
|
+
except Exception:
|
|
1185
|
+
continue # Skip fields with problematic defaults
|
|
1186
|
+
|
|
1187
|
+
# Get renderer and render field with error handling
|
|
1188
|
+
renderer_cls = FieldRendererRegistry().get_renderer(
|
|
1189
|
+
nested_field_name, nested_field_info
|
|
1190
|
+
)
|
|
1191
|
+
if not renderer_cls:
|
|
1192
|
+
renderer_cls = StringFieldRenderer
|
|
1193
|
+
|
|
1194
|
+
renderer = renderer_cls(
|
|
1195
|
+
field_name=nested_field_name,
|
|
1196
|
+
field_info=nested_field_info,
|
|
1197
|
+
value=nested_field_value,
|
|
1198
|
+
prefix=name_prefix,
|
|
1199
|
+
disabled=self.disabled, # Propagate disabled state
|
|
1200
|
+
spacing=self.spacing, # Propagate spacing
|
|
1201
|
+
field_path=self.field_path
|
|
1202
|
+
+ [
|
|
1203
|
+
str(idx),
|
|
1204
|
+
nested_field_name,
|
|
1205
|
+
], # Propagate path with index
|
|
1206
|
+
)
|
|
1207
|
+
|
|
1208
|
+
# Add rendered field to valid fields
|
|
1209
|
+
valid_fields.append(renderer.render())
|
|
1210
|
+
|
|
1211
|
+
except Exception as e:
|
|
1212
|
+
logger.warning(
|
|
1213
|
+
f"Skipping problematic field {nested_field_name} in list item: {e}"
|
|
1214
|
+
)
|
|
1215
|
+
skipped_fields.append(nested_field_name)
|
|
1216
|
+
continue
|
|
1217
|
+
|
|
1218
|
+
# Log summary if fields were skipped
|
|
1219
|
+
if skipped_fields:
|
|
1220
|
+
logger.info(
|
|
1221
|
+
f"Skipped {len(skipped_fields)} fields in list item {idx}: {skipped_fields}"
|
|
890
1222
|
)
|
|
891
1223
|
|
|
892
|
-
|
|
893
|
-
item_content_elements.append(renderer.render())
|
|
1224
|
+
item_content_elements = valid_fields
|
|
894
1225
|
else:
|
|
895
1226
|
# Handle simple type items
|
|
896
1227
|
field_info = FieldInfo(annotation=item_type)
|
|
@@ -906,21 +1237,24 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
906
1237
|
value=item,
|
|
907
1238
|
prefix=self.prefix, # Correct: Provide the form prefix
|
|
908
1239
|
disabled=self.disabled, # Propagate disabled state
|
|
1240
|
+
spacing=self.spacing, # Propagate spacing
|
|
1241
|
+
field_path=self.field_path
|
|
1242
|
+
+ [str(idx)], # Propagate path with index
|
|
909
1243
|
)
|
|
910
1244
|
input_element = simple_renderer.render_input()
|
|
911
1245
|
item_content_elements.append(fh.Div(input_element))
|
|
912
1246
|
|
|
913
1247
|
# --- Create action buttons with form-specific URLs ---
|
|
914
|
-
# Generate HTMX endpoints
|
|
1248
|
+
# Generate HTMX endpoints using hierarchical paths
|
|
915
1249
|
delete_url = (
|
|
916
|
-
f"/form/{
|
|
917
|
-
if
|
|
1250
|
+
f"/form/{self._form_name}/list/delete/{self._list_path}"
|
|
1251
|
+
if self._form_name
|
|
918
1252
|
else f"/list/delete/{self.field_name}"
|
|
919
1253
|
)
|
|
920
1254
|
|
|
921
1255
|
add_url = (
|
|
922
|
-
f"/form/{
|
|
923
|
-
if
|
|
1256
|
+
f"/form/{self._form_name}/list/add/{self._list_path}"
|
|
1257
|
+
if self._form_name
|
|
924
1258
|
else f"/list/add/{self.field_name}"
|
|
925
1259
|
)
|
|
926
1260
|
|
|
@@ -986,6 +1320,7 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
986
1320
|
)
|
|
987
1321
|
|
|
988
1322
|
# Assemble actions div
|
|
1323
|
+
t = self.spacing
|
|
989
1324
|
actions = fh.Div(
|
|
990
1325
|
fh.Div( # Left side buttons
|
|
991
1326
|
delete_button, add_below_button, cls="flex items-center"
|
|
@@ -993,11 +1328,15 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
993
1328
|
fh.Div( # Right side buttons
|
|
994
1329
|
move_up_button, move_down_button, cls="flex items-center space-x-1"
|
|
995
1330
|
),
|
|
996
|
-
cls="flex justify-between w-full mt-3 pt-3
|
|
1331
|
+
cls=f"flex justify-between w-full mt-3 pt-3 {spacing('section_divider', t)}".strip(),
|
|
997
1332
|
)
|
|
998
1333
|
|
|
999
1334
|
# Create a wrapper Div for the main content elements with proper padding
|
|
1000
|
-
|
|
1335
|
+
t = self.spacing
|
|
1336
|
+
content_wrapper = fh.Div(
|
|
1337
|
+
*item_content_elements,
|
|
1338
|
+
cls=f"{spacing('card_body_pad', t)} {spacing('inner_gap', t)}",
|
|
1339
|
+
)
|
|
1001
1340
|
|
|
1002
1341
|
# Return the accordion item
|
|
1003
1342
|
title_component = fh.Span(
|
|
@@ -1005,11 +1344,16 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
1005
1344
|
)
|
|
1006
1345
|
li_attrs = {"id": full_card_id}
|
|
1007
1346
|
|
|
1347
|
+
# Build card classes conditionally based on spacing theme
|
|
1348
|
+
card_cls = "uk-card uk-margin-small-bottom"
|
|
1349
|
+
if self.spacing == SpacingTheme.NORMAL:
|
|
1350
|
+
card_cls += " uk-card-default"
|
|
1351
|
+
|
|
1008
1352
|
return mui.AccordionItem(
|
|
1009
1353
|
title_component, # Title as first positional argument
|
|
1010
1354
|
content_wrapper, # Use the new padded wrapper for content
|
|
1011
1355
|
actions, # More content elements
|
|
1012
|
-
cls=
|
|
1356
|
+
cls=card_cls, # Use theme-aware card classes
|
|
1013
1357
|
open=is_open,
|
|
1014
1358
|
li_kwargs=li_attrs, # Pass remaining li attributes without cls
|
|
1015
1359
|
)
|
|
@@ -1023,11 +1367,17 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
1023
1367
|
li_attrs = {"id": f"{self.field_name}_{idx}_error_card"}
|
|
1024
1368
|
|
|
1025
1369
|
# Wrap error component in a div with consistent padding
|
|
1026
|
-
|
|
1370
|
+
t = self.spacing
|
|
1371
|
+
content_wrapper = fh.Div(content_component, cls=spacing("card_body_pad", t))
|
|
1372
|
+
|
|
1373
|
+
# Build card classes conditionally based on spacing theme
|
|
1374
|
+
card_cls = "uk-card uk-margin-small-bottom"
|
|
1375
|
+
if self.spacing == SpacingTheme.NORMAL:
|
|
1376
|
+
card_cls += " uk-card-default"
|
|
1027
1377
|
|
|
1028
1378
|
return mui.AccordionItem(
|
|
1029
1379
|
title_component, # Title as first positional argument
|
|
1030
1380
|
content_wrapper, # Wrapped content element
|
|
1031
|
-
cls=
|
|
1381
|
+
cls=card_cls, # Use theme-aware card classes
|
|
1032
1382
|
li_kwargs=li_attrs, # Pass remaining li attributes without cls
|
|
1033
1383
|
)
|