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.

@@ -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["uk_tooltip"] = description
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
- label_attrs["style"] = f"color: {self.label_color};"
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="block text-sm font-medium text-gray-700 mb-1",
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 in a collapsible accordion
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 (mui.Accordion) containing the complete field
202
+ A FastHTML component containing the complete field
120
203
  """
121
- # 1. Get the full label component (fh.Label)
204
+ # 1. Get the label component
122
205
  label_component = self.render_label()
123
206
 
124
- # Apply color styling directly to the Label component if needed
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. Define unique IDs for potential targeting
135
- item_id = f"{self.field_name}_item"
136
- accordion_id = f"{self.field_name}_accordion"
137
-
138
- # 4. Create the AccordionItem with the full label component as title
139
- accordion_item = mui.AccordionItem(
140
- label_component, # Use the entire label component including the "for" attribute
141
- input_component, # Content component (the input field)
142
- open=True, # Open by default
143
- li_kwargs={"id": item_id}, # Pass the specific ID for the <li>
144
- cls="mb-2", # Add spacing between accordion items
145
- )
146
-
147
- # 5. Wrap the single AccordionItem in an Accordion container
148
- accordion_container = mui.Accordion(
149
- accordion_item, # The single item to include
150
- id=accordion_id, # ID for the accordion container (ul)
151
- multiple=True, # Allow multiple open (though only one exists)
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
- not self.is_optional
173
- and self.field_info.default is None
174
- and getattr(self.field_info, "default_factory", None) is None
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": "w-full",
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
- 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
- )
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": "w-full",
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
- is_field_required = (
282
- not self.is_optional
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": "w-full",
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
- is_field_required = (
328
- not self.is_optional
329
- and self.field_info.default is None
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": "w-full",
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
- is_field_required = (
375
- not self.is_optional
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": "w-full",
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
- # 1. Get the label content (the inner Span with text/tooltip)
434
- label_component = self.render_label()
435
- if isinstance(label_component, fh.FT) and label_component.children:
436
- label_content = label_component.children[0]
437
- # Extract label style if present
438
- label_style = label_component.attrs.get("style", "")
439
- # Apply label style directly to the span if needed
440
- if label_style:
441
- # Check if label_content is already a Span, otherwise wrap it
442
- if isinstance(label_content, fh.Span):
443
- label_content.attrs["style"] = label_style
444
- else:
445
- # This case is less likely if render_label returns Label(Span(...))
446
- label_content = fh.Span(label_content, style=label_style)
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
- # Fallback if structure is different (should not happen ideally)
449
- label_content = self.original_field_name.replace("_", " ").title()
450
- label_style = f"color: {self.label_color};" if self.label_color else ""
451
- if label_style:
452
- label_content = fh.Span(label_content, style=label_style)
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
- label_content, # Title component (already potentially styled Span)
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="mb-4", # Add bottom margin to the <li> element
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
- # Prepare values dict
508
- values_dict = (
509
- self.value.model_dump()
510
- if hasattr(self.value, "model_dump")
511
- else self.value
512
- if isinstance(self.value, dict)
513
- else {}
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 directly instead of using FormRenderer
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
- # Determine initial value
524
- nested_field_value = (
525
- values_dict.get(nested_field_name) if values_dict else None
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
- # Apply default if needed
529
- if nested_field_value is None:
530
- if nested_field_info.default is not None:
531
- nested_field_value = nested_field_info.default
532
- elif getattr(nested_field_info, "default_factory", None) is not None:
533
- try:
534
- nested_field_value = nested_field_info.default_factory()
535
- except Exception:
536
- nested_field_value = None
537
-
538
- # Get renderer for this nested field
539
- registry = FieldRendererRegistry() # Get singleton instance
540
- renderer_cls = registry.get_renderer(nested_field_name, nested_field_info)
541
-
542
- if not renderer_cls:
543
- # Fall back to StringFieldRenderer if no renderer found
544
- renderer_cls = StringFieldRenderer
545
-
546
- # The prefix for nested fields is simply the field_name of this BaseModel instance + underscore
547
- # field_name already includes the form prefix, so we don't need to add self.prefix again
548
- nested_prefix = f"{self.field_name}_"
549
-
550
- # Create and render the nested field
551
- renderer = renderer_cls(
552
- field_name=nested_field_name,
553
- field_info=nested_field_info,
554
- value=nested_field_value,
555
- prefix=nested_prefix,
556
- disabled=self.disabled, # Propagate disabled state to nested fields
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
- nested_inputs.append(renderer.render())
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, cls="space-y-3 items-stretch"
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="p-3 mt-1 border rounded",
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
- # Get the original label
588
- original_label = self.render_label()
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 = f"{self.prefix}{self.original_field_name}_items_container"
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=f"#{form_name}-form",
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
- original_label,
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 original label but still make it clickable
920
+ # If no form name, just use the styled label but still make it clickable
621
921
  label_with_icon = fh.Div(
622
- original_label,
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(label_with_icon, self.render_input(), cls="mb-4")
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 with form-specific prefix in ID
683
- container_id = f"{self.prefix}{self.original_field_name}_items_container"
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="space-y-2", # Add space between items
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
- # Extract form name from prefix if available
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/{form_name}/list/add/{self.original_field_name}"
703
- if form_name
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="mb-4 border rounded-md p-4",
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, try to create a model instance for display
771
- model_for_display = item_type.model_validate(item)
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 - include the form prefix in nested 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
- nested_values = (
820
- item.model_dump()
821
- if hasattr(item, "model_dump")
822
- else item
823
- if isinstance(item, dict)
824
- else {}
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
- nested_field_value = nested_values.get(nested_field_name)
862
-
863
- # Apply default if needed
864
- if (
865
- nested_field_value is None
866
- and hasattr(nested_field_info, "default")
867
- and nested_field_info.default is not None
868
- ):
869
- nested_field_value = nested_field_info.default
870
- elif (
871
- nested_field_value is None
872
- and hasattr(nested_field_info, "default_factory")
873
- and nested_field_info.default_factory is not None
874
- ):
875
- try:
876
- nested_field_value = nested_field_info.default_factory()
877
- except Exception:
878
- pass
879
-
880
- # Get renderer and render field
881
- renderer_cls = FieldRendererRegistry().get_renderer(
882
- nested_field_name, nested_field_info
883
- )
884
- renderer = renderer_cls(
885
- field_name=nested_field_name,
886
- field_info=nested_field_info,
887
- value=nested_field_value,
888
- prefix=name_prefix,
889
- disabled=self.disabled, # Propagate disabled state
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
- # Add rendered field to content elements
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 with form name if available
1248
+ # Generate HTMX endpoints using hierarchical paths
915
1249
  delete_url = (
916
- f"/form/{form_name}/list/delete/{self.original_field_name}"
917
- if form_name
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/{form_name}/list/add/{self.original_field_name}"
923
- if form_name
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 border-t border-gray-200",
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
- content_wrapper = fh.Div(*item_content_elements, cls="px-4 py-3 space-y-3")
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="uk-card uk-card-default uk-margin-small-bottom", # Use cls keyword arg directly
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
- content_wrapper = fh.Div(content_component, cls="px-4 py-3")
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="mb-2", # Use cls keyword arg directly
1381
+ cls=card_cls, # Use theme-aware card classes
1032
1382
  li_kwargs=li_attrs, # Pass remaining li attributes without cls
1033
1383
  )