fh-pydantic-form 0.3.9__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.
@@ -0,0 +1,2330 @@
1
+ import logging
2
+ import re
3
+ from datetime import date, time
4
+ from decimal import Decimal
5
+ from enum import Enum
6
+ from typing import (
7
+ Any,
8
+ List,
9
+ Optional,
10
+ get_args,
11
+ get_origin,
12
+ )
13
+
14
+ import fasthtml.common as fh
15
+ import monsterui.all as mui
16
+ from fastcore.xml import FT
17
+ from pydantic import BaseModel, ValidationError
18
+ from pydantic.fields import FieldInfo
19
+
20
+ from fh_pydantic_form.color_utils import (
21
+ DEFAULT_METRIC_GREY,
22
+ get_metric_colors,
23
+ robust_color_to_rgba,
24
+ )
25
+ from fh_pydantic_form.constants import _UNSET
26
+ from fh_pydantic_form.defaults import default_dict_for_model, default_for_annotation
27
+ from fh_pydantic_form.registry import FieldRendererRegistry
28
+ from fh_pydantic_form.type_helpers import (
29
+ DecorationScope,
30
+ MetricEntry,
31
+ MetricsDict,
32
+ _get_underlying_type_if_optional,
33
+ _is_optional_type,
34
+ _is_skip_json_schema_field,
35
+ get_default,
36
+ normalize_path_segments,
37
+ )
38
+ from fh_pydantic_form.ui_style import (
39
+ SpacingTheme,
40
+ SpacingValue,
41
+ _normalize_spacing,
42
+ spacing,
43
+ spacing_many,
44
+ )
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+
49
+ def _is_form_control(node: Any) -> bool:
50
+ """
51
+ Returns True if this node is a form control element that should receive highlighting.
52
+
53
+ Detects both raw HTML form controls and MonsterUI wrapper components.
54
+ """
55
+ if not hasattr(node, "tag"):
56
+ return False
57
+
58
+ tag = str(getattr(node, "tag", "")).lower()
59
+
60
+ # Raw HTML controls
61
+ if tag in ("input", "select", "textarea"):
62
+ return True
63
+
64
+ # For MonsterUI components, highlight the outer div container instead of inner elements
65
+ # This provides better visual feedback since MonsterUI hides the actual select elements
66
+ if hasattr(node, "attrs") and hasattr(node, "children"):
67
+ classes = str(node.attrs.get("cls", "") or node.attrs.get("class", ""))
68
+
69
+ # Check if this div contains a MonsterUI component
70
+ if tag == "div" and node.children:
71
+ for child in node.children:
72
+ child_tag = str(getattr(child, "tag", "")).lower()
73
+ if child_tag.startswith("uk-") and any(
74
+ control in child_tag for control in ["select", "input", "checkbox"]
75
+ ):
76
+ return True
77
+
78
+ # Also check for direct MonsterUI wrapper classes
79
+ if tag == "div" and "uk-select" in classes:
80
+ return True
81
+
82
+ # MonsterUI typically uses uk- prefixed classes
83
+ if any(
84
+ c
85
+ for c in classes.split()
86
+ if c.startswith("uk-")
87
+ and any(t in c for t in ["input", "select", "checkbox"])
88
+ ):
89
+ return True
90
+
91
+ return False
92
+
93
+
94
+ def _merge_cls(base: str, extra: str) -> str:
95
+ """Return base plus extra class(es) separated by a single space (handles blanks)."""
96
+ if extra:
97
+ combined = f"{base} {extra}".strip()
98
+ # Remove duplicate whitespace
99
+ return " ".join(combined.split())
100
+ return base
101
+
102
+
103
+ class MetricsRendererMixin:
104
+ """Mixin to add metrics highlighting capabilities to field renderers"""
105
+
106
+ def _decorate_label(
107
+ self,
108
+ label: FT,
109
+ metric_entry: Optional[MetricEntry],
110
+ ) -> FT:
111
+ """
112
+ Decorate a label element with a metric badge (bullet) if applicable.
113
+ """
114
+ return self._decorate_metrics(label, metric_entry, scope=DecorationScope.BULLET)
115
+
116
+ def _decorate_metrics(
117
+ self,
118
+ element: FT,
119
+ metric_entry: Optional[MetricEntry],
120
+ *,
121
+ scope: DecorationScope = DecorationScope.BOTH,
122
+ ) -> FT:
123
+ """
124
+ Decorate an element with metrics visual feedback.
125
+
126
+ Args:
127
+ element: The FastHTML element to decorate
128
+ metric_entry: Optional metric entry with color, score, and comment
129
+ scope: Which decorations to apply (BORDER, BULLET, or BOTH)
130
+
131
+ Returns:
132
+ Decorated element with left color bar, tooltip, and optional metric badge
133
+ """
134
+ if not metric_entry:
135
+ return element
136
+
137
+ # Add tooltip with comment if available
138
+ comment = metric_entry.get("comment")
139
+ if comment and hasattr(element, "attrs"):
140
+ element.attrs["uk-tooltip"] = comment
141
+ element.attrs["title"] = comment # Fallback standard tooltip
142
+
143
+ # Add left color bar if requested
144
+ if scope in {DecorationScope.BORDER, DecorationScope.BOTH}:
145
+ border_color = self._metric_border_color(metric_entry)
146
+ if border_color and hasattr(element, "attrs"):
147
+ existing_style = element.attrs.get("style", "")
148
+ element.attrs["style"] = (
149
+ f"border-left: 4px solid {border_color}; padding-left: 0.25rem; position: relative; z-index: 0; {existing_style}"
150
+ )
151
+
152
+ # Add metric score badge if requested and present
153
+ score = metric_entry.get("metric")
154
+ color = metric_entry.get("color")
155
+ if (
156
+ scope in {DecorationScope.BULLET, DecorationScope.BOTH}
157
+ and score is not None
158
+ ):
159
+ # Determine bullet colors based on LangSmith-style system when no color provided
160
+ if color:
161
+ # Use provided color - convert to full opacity for badge
162
+ badge_bg_rgba = robust_color_to_rgba(color, 1.0)
163
+ # Extract RGB values and use them for badge background
164
+ rgb_match = re.match(
165
+ r"rgba\((\d+),\s*(\d+),\s*(\d+),\s*[\d.]+\)", badge_bg_rgba
166
+ )
167
+ if rgb_match:
168
+ r, g, b = rgb_match.groups()
169
+ badge_bg = f"rgb({r}, {g}, {b})"
170
+ else:
171
+ badge_bg = color
172
+ text_color = "white"
173
+ else:
174
+ # Use metric-based color system
175
+ badge_bg, text_color = get_metric_colors(score)
176
+
177
+ # Create custom styled span that looks like a bullet/pill
178
+ metric_badge = fh.Span(
179
+ str(score),
180
+ style=f"""
181
+ background-color: {badge_bg};
182
+ color: {text_color};
183
+ padding: 0.125rem 0.5rem;
184
+ border-radius: 9999px;
185
+ font-size: 0.75rem;
186
+ font-weight: 500;
187
+ display: inline-block;
188
+ margin-left: 0.5rem;
189
+ vertical-align: top;
190
+ line-height: 1.25;
191
+ white-space: nowrap;
192
+ text-shadow: 0 1px 2px rgba(0,0,0,0.1);
193
+ """,
194
+ cls="uk-text-nowrap",
195
+ )
196
+
197
+ # Use helper to attach badge properly
198
+ return self._attach_metric_badge(element, metric_badge)
199
+
200
+ return element
201
+
202
+ def _attach_metric_badge(self, element: FT, badge: FT) -> FT:
203
+ """
204
+ Attach a metric badge to an element in the most appropriate way.
205
+
206
+ Args:
207
+ element: The element to attach the badge to
208
+ badge: The badge element to attach
209
+
210
+ Returns:
211
+ Element with badge attached
212
+ """
213
+ # Check if element is an inline-capable tag
214
+ tag = str(getattr(element, "tag", "")).lower()
215
+ inline_tags = {"span", "a", "h1", "h2", "h3", "h4", "h5", "h6", "label"}
216
+
217
+ if tag in inline_tags and hasattr(element, "children"):
218
+ # For inline elements, append badge directly to children
219
+ if isinstance(element.children, list):
220
+ element.children.append(badge)
221
+ else:
222
+ # Convert to list if needed
223
+ element.children = list(element.children) + [badge]
224
+ return element
225
+
226
+ # For other elements, wrap in a flex container
227
+ return fh.Div(element, badge, cls="relative inline-flex items-center w-full")
228
+
229
+ def _highlight_input_fields(self, element: FT, metric_entry: MetricEntry) -> FT:
230
+ """
231
+ Find nested form controls and add a colored box-shadow to them
232
+ based on the metric entry color.
233
+
234
+ Args:
235
+ element: The FT element to search within
236
+ metric_entry: The metric entry containing color information
237
+
238
+ Returns:
239
+ The element with highlighted input fields
240
+ """
241
+ if not metric_entry:
242
+ return element
243
+
244
+ # Determine the color to use for highlighting
245
+ color = metric_entry.get("color")
246
+ score = metric_entry.get("metric")
247
+
248
+ if color:
249
+ # Use the provided color
250
+ highlight_color = color
251
+ elif score is not None:
252
+ # Use metric-based color system (background color from the helper)
253
+ highlight_color, _ = get_metric_colors(score)
254
+ else:
255
+ # No color or metric available
256
+ return element
257
+
258
+ # Create the highlight CSS with appropriate opacity for both border and background
259
+ # Use !important to ensure our styles override MonsterUI defaults
260
+ # Focus on border highlighting since background might conflict with MonsterUI styling
261
+ border_rgba = robust_color_to_rgba(highlight_color, 0.8)
262
+ background_rgba = robust_color_to_rgba(highlight_color, 0.1)
263
+ highlight_css = f"border: 2px solid {border_rgba} !important; border-radius: 4px !important; box-shadow: 0 0 0 1px {border_rgba} !important; background-color: {background_rgba} !important;"
264
+
265
+ # Track how many elements we highlight
266
+ highlight_count = 0
267
+
268
+ # Recursively find and style input elements
269
+ def apply_highlight(node):
270
+ """Recursively apply highlighting to input elements"""
271
+ nonlocal highlight_count
272
+
273
+ if _is_form_control(node):
274
+ # Add or update the style attribute
275
+ if hasattr(node, "attrs"):
276
+ existing_style = node.attrs.get("style", "")
277
+ node.attrs["style"] = highlight_css + " " + existing_style
278
+ highlight_count += 1
279
+
280
+ # Process children if they exist
281
+ if hasattr(node, "children") and node.children:
282
+ for child in node.children:
283
+ apply_highlight(child)
284
+
285
+ # Apply highlighting to the element tree
286
+ apply_highlight(element)
287
+
288
+ if highlight_count == 0:
289
+ pass # No form controls found to highlight
290
+
291
+ return element
292
+
293
+ def _metric_border_color(
294
+ self, metric_entry: Optional[MetricEntry]
295
+ ) -> Optional[str]:
296
+ """
297
+ Get an RGBA color string for a metric entry's left border bar.
298
+
299
+ Args:
300
+ metric_entry: The metric entry containing color/score information
301
+
302
+ Returns:
303
+ RGBA color string for left border bar, or None if no metric
304
+ """
305
+ if not metric_entry:
306
+ return None
307
+
308
+ # Use provided color if available
309
+ if metric_entry.get("color"):
310
+ return robust_color_to_rgba(metric_entry["color"], 0.8)
311
+
312
+ # Otherwise derive from metric score
313
+ metric = metric_entry.get("metric")
314
+ if metric is not None:
315
+ color, _ = get_metric_colors(metric)
316
+ # If get_metric_colors returns the fallback grey, use our unified light grey
317
+ if color == DEFAULT_METRIC_GREY:
318
+ return DEFAULT_METRIC_GREY
319
+ return robust_color_to_rgba(color, 0.8)
320
+
321
+ # If only a comment is present, return the unified light grey color
322
+ if metric_entry.get("comment"):
323
+ return DEFAULT_METRIC_GREY
324
+
325
+ return None
326
+
327
+
328
+ def _build_path_string_static(path_segments: List[str]) -> str:
329
+ """
330
+ Static version of BaseFieldRenderer._build_path_string for use without instance.
331
+
332
+ Convert field_path list to dot/bracket notation string for metric lookup.
333
+
334
+ Examples:
335
+ ['experience', '0', 'company'] -> 'experience[0].company'
336
+ ['skills', 'programming_languages', '2'] -> 'skills.programming_languages[2]'
337
+
338
+ Args:
339
+ path_segments: List of path segments
340
+
341
+ Returns:
342
+ Path string in dot/bracket notation
343
+ """
344
+ parts: List[str] = []
345
+ for segment in path_segments:
346
+ # Check if segment is numeric or a list index pattern
347
+ if segment.isdigit() or segment.startswith("new_"):
348
+ # Interpret as list index
349
+ if parts:
350
+ parts[-1] += f"[{segment}]"
351
+ else: # Defensive fallback
352
+ parts.append(f"[{segment}]")
353
+ else:
354
+ parts.append(segment)
355
+ return ".".join(parts)
356
+
357
+
358
+ class BaseFieldRenderer(MetricsRendererMixin):
359
+ """
360
+ Base class for field renderers
361
+
362
+ Field renderers are responsible for:
363
+ - Rendering a label for the field
364
+ - Rendering an appropriate input element for the field
365
+ - Combining the label and input with proper spacing
366
+ - Optionally applying comparison visual feedback
367
+
368
+ Subclasses must implement render_input()
369
+ """
370
+
371
+ def __init__(
372
+ self,
373
+ field_name: str,
374
+ field_info: FieldInfo,
375
+ value: Any = None,
376
+ prefix: str = "",
377
+ disabled: bool = False,
378
+ label_color: Optional[str] = None,
379
+ spacing: SpacingValue = SpacingTheme.NORMAL,
380
+ field_path: Optional[List[str]] = None,
381
+ form_name: Optional[str] = None,
382
+ metric_entry: Optional[MetricEntry] = None,
383
+ metrics_dict: Optional[MetricsDict] = None,
384
+ refresh_endpoint_override: Optional[str] = None,
385
+ keep_skip_json_pathset: Optional[set[str]] = None,
386
+ comparison_copy_enabled: bool = False,
387
+ comparison_copy_target: Optional[str] = None,
388
+ comparison_name: Optional[str] = None,
389
+ **kwargs, # Accept additional kwargs for extensibility
390
+ ):
391
+ """
392
+ Initialize the field renderer
393
+
394
+ Args:
395
+ field_name: The name of the field
396
+ field_info: The FieldInfo for the field
397
+ value: The current value of the field (optional)
398
+ prefix: Optional prefix for the field name (used for nested fields)
399
+ disabled: Whether the field should be rendered as disabled
400
+ label_color: Optional CSS color value for the field label
401
+ spacing: Spacing theme to use for layout ("normal", "compact", or SpacingTheme enum)
402
+ field_path: Path segments from root to this field (for nested list support)
403
+ form_name: Explicit form name (used for nested list URLs)
404
+ metric_entry: Optional metric entry for visual feedback
405
+ metrics_dict: Optional full metrics dict for auto-lookup
406
+ refresh_endpoint_override: Optional override URL for refresh actions (used in ComparisonForm)
407
+ comparison_copy_enabled: If True, show copy button for this field
408
+ comparison_copy_target: "left" or "right" - which side this field is on
409
+ comparison_name: Name of the ComparisonForm (for copy route URLs)
410
+ **kwargs: Additional keyword arguments for extensibility
411
+ """
412
+ self.field_name = f"{prefix}{field_name}" if prefix else field_name
413
+ self.original_field_name = field_name
414
+ self.field_info = field_info
415
+ # Normalize PydanticUndefined → None so it never renders as text
416
+ try:
417
+ from pydantic_core import PydanticUndefined
418
+
419
+ if value is PydanticUndefined:
420
+ value = None
421
+ except Exception:
422
+ pass
423
+ self.value = value
424
+ self.prefix = prefix
425
+ self.field_path: List[str] = field_path or []
426
+ self.explicit_form_name: Optional[str] = form_name
427
+ self.is_optional = _is_optional_type(field_info.annotation)
428
+ self.disabled = disabled
429
+ self.label_color = label_color
430
+ self.spacing = _normalize_spacing(spacing)
431
+ self.metrics_dict = metrics_dict
432
+ self._refresh_endpoint_override = refresh_endpoint_override
433
+ self._keep_skip_json_pathset = keep_skip_json_pathset or set()
434
+ self._cmp_copy_enabled = comparison_copy_enabled
435
+ self._cmp_copy_target = comparison_copy_target
436
+ self._cmp_name = comparison_name
437
+
438
+ # Initialize metric entry attribute
439
+ self.metric_entry: Optional[MetricEntry] = None
440
+
441
+ # Auto-resolve metric entry if not explicitly provided
442
+ if metric_entry is not None:
443
+ self.metric_entry = metric_entry
444
+ elif metrics_dict:
445
+ path_string = self._build_path_string()
446
+ self.metric_entry = metrics_dict.get(path_string)
447
+
448
+ def _build_path_string(self) -> str:
449
+ """
450
+ Convert field_path list to dot/bracket notation string for comparison lookup.
451
+
452
+ Examples:
453
+ ['experience', '0', 'company'] -> 'experience[0].company'
454
+ ['skills', 'programming_languages', '2'] -> 'skills.programming_languages[2]'
455
+
456
+ Returns:
457
+ Path string in dot/bracket notation
458
+ """
459
+ parts: List[str] = []
460
+ for segment in self.field_path:
461
+ # Check if segment is numeric or a list index pattern
462
+ if segment.isdigit() or segment.startswith("new_"):
463
+ # Interpret as list index
464
+ if parts:
465
+ parts[-1] += f"[{segment}]"
466
+ else: # Defensive fallback
467
+ parts.append(f"[{segment}]")
468
+ else:
469
+ parts.append(segment)
470
+ return ".".join(parts)
471
+
472
+ def _normalized_dot_path(self, path_segments: List[str]) -> str:
473
+ """Normalize path segments by dropping indices and joining with dots."""
474
+ return normalize_path_segments(path_segments)
475
+
476
+ def _is_kept_skip_field(self, full_path: List[str]) -> bool:
477
+ """Return True if a SkipJsonSchema field should be kept based on keep list."""
478
+ normalized = self._normalized_dot_path(full_path)
479
+ return bool(normalized) and normalized in self._keep_skip_json_pathset
480
+
481
+ def _is_inline_color(self, color: str) -> bool:
482
+ """
483
+ Determine if a color should be applied as an inline style or CSS class.
484
+
485
+ Args:
486
+ color: The color value to check
487
+
488
+ Returns:
489
+ True if the color should be applied as inline style, False if as CSS class
490
+ """
491
+ # Check if it's a hex color value (starts with #) or basic HTML color name
492
+ return color.startswith("#") or color in [
493
+ "red",
494
+ "blue",
495
+ "green",
496
+ "yellow",
497
+ "orange",
498
+ "purple",
499
+ "pink",
500
+ "cyan",
501
+ "magenta",
502
+ "brown",
503
+ "black",
504
+ "white",
505
+ "gray",
506
+ "grey",
507
+ ]
508
+
509
+ def _get_color_class(self, color: str) -> str:
510
+ """
511
+ Get the appropriate CSS class for a color.
512
+
513
+ Args:
514
+ color: The color name
515
+
516
+ Returns:
517
+ The CSS class string for the color
518
+ """
519
+ return f"text-{color}-600"
520
+
521
+ def _render_comparison_copy_button(self) -> Optional[FT]:
522
+ """
523
+ Render a copy button for comparison forms.
524
+
525
+ Note: Copy buttons are never disabled, even if the field itself is disabled.
526
+ This allows copying from disabled (read-only) fields to editable fields.
527
+
528
+ Returns:
529
+ A copy button component, or None if not in comparison mode
530
+ """
531
+ if not (self._cmp_copy_enabled and self._cmp_copy_target and self._cmp_name):
532
+ return None
533
+
534
+ path = self._build_path_string()
535
+ # Use arrow pointing in the direction of the copy (towards target)
536
+ arrow = "arrow-left" if self._cmp_copy_target == "left" else "arrow-right"
537
+ tooltip_text = f"Copy to {self._cmp_copy_target}"
538
+
539
+ # Note: We explicitly do NOT pass disabled=self.disabled here
540
+ # Copy buttons should always be enabled, even in disabled forms
541
+ #
542
+ # Pure JS copy: Bypass HTMX entirely to avoid accordion collapse
543
+ # Button is on SOURCE side, arrow points to TARGET side
544
+ return mui.Button(
545
+ mui.UkIcon(arrow, cls="w-4 h-4 text-gray-500 hover:text-blue-600"),
546
+ type="button",
547
+ onclick=f"window.fhpfPerformCopy('{path}', '{self.prefix}', '{self._cmp_copy_target}'); return false;",
548
+ uk_tooltip=tooltip_text,
549
+ cls="uk-button-text uk-button-small flex-shrink-0",
550
+ style="all: unset; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; padding: 0.25rem; min-width: 1.5rem;",
551
+ )
552
+
553
+ def render_label(self) -> FT:
554
+ """
555
+ Render label for the field
556
+
557
+ Returns:
558
+ A FastHTML component for the label
559
+ """
560
+ # Get field description from field_info
561
+ description = getattr(self.field_info, "description", None)
562
+
563
+ # Prepare label text
564
+ label_text = self.original_field_name.replace("_", " ").title()
565
+
566
+ # Create span attributes with tooltip if description is available
567
+ span_attrs = {}
568
+ if description:
569
+ span_attrs["uk-tooltip"] = description # UIkit tooltip
570
+ span_attrs["title"] = description # Standard HTML tooltip
571
+ # Removed cursor-help class while preserving tooltip functionality
572
+
573
+ # Create the span with the label text and tooltip
574
+ label_text_span = fh.Span(label_text, **span_attrs)
575
+
576
+ # Prepare label attributes
577
+ label_attrs = {"for": self.field_name}
578
+
579
+ # Build label classes with tokenized gap
580
+ label_gap_class = spacing("label_gap", self.spacing)
581
+ base_classes = f"block text-sm font-medium text-gray-700 {label_gap_class}"
582
+
583
+ cls_attr = base_classes
584
+
585
+ # Apply color styling if specified
586
+ if self.label_color:
587
+ if self._is_inline_color(self.label_color):
588
+ # Treat as color value
589
+ label_attrs["style"] = f"color: {self.label_color};"
590
+ else:
591
+ # Treat as CSS class (includes Tailwind colors like emerald, amber, rose, teal, indigo, lime, violet, etc.)
592
+ cls_attr = f"block text-sm font-medium {self._get_color_class(self.label_color)} {label_gap_class}".strip()
593
+
594
+ # Create and return the label - using standard fh.Label with appropriate styling
595
+ return fh.Label(
596
+ label_text_span,
597
+ **label_attrs,
598
+ cls=cls_attr,
599
+ )
600
+
601
+ def render_input(self) -> FT:
602
+ """
603
+ Render input element for the field
604
+
605
+ Returns:
606
+ A FastHTML component for the input element
607
+
608
+ Raises:
609
+ NotImplementedError: Subclasses must implement this method
610
+ """
611
+ raise NotImplementedError("Subclasses must implement render_input")
612
+
613
+ def render(self) -> FT:
614
+ """
615
+ Render the complete field (label + input) with spacing
616
+
617
+ For compact spacing: renders label and input side-by-side
618
+ For normal spacing: renders label above input (traditional)
619
+
620
+ Returns:
621
+ A FastHTML component containing the complete field
622
+ """
623
+ # 1. Get the label component (without copy button)
624
+ label_component = self.render_label()
625
+
626
+ # 2. Render the input field
627
+ input_component = self.render_input()
628
+
629
+ # 3. Get the copy button if enabled
630
+ copy_button = self._render_comparison_copy_button()
631
+
632
+ # 4. Choose layout based on spacing theme
633
+ if self.spacing == SpacingTheme.COMPACT:
634
+ # Horizontal layout for compact mode
635
+ field_element = fh.Div(
636
+ fh.Div(
637
+ label_component,
638
+ input_component,
639
+ cls=f"flex {spacing('horizontal_gap', self.spacing)} {spacing('label_align', self.spacing)} w-full",
640
+ ),
641
+ cls=f"{spacing('outer_margin', self.spacing)} w-full",
642
+ )
643
+ else:
644
+ # Vertical layout for normal mode
645
+ field_element = fh.Div(
646
+ label_component,
647
+ input_component,
648
+ cls=spacing("outer_margin", self.spacing),
649
+ )
650
+
651
+ # 5. Apply metrics decoration if available
652
+ decorated_field = self._decorate_metrics(field_element, self.metric_entry)
653
+
654
+ # 6. If copy button exists, wrap the entire decorated field with copy button on the right
655
+ if copy_button:
656
+ return fh.Div(
657
+ decorated_field,
658
+ copy_button,
659
+ cls="flex items-start gap-2 w-full",
660
+ )
661
+ else:
662
+ return decorated_field
663
+
664
+
665
+ # ---- Specific Field Renderers ----
666
+
667
+
668
+ class StringFieldRenderer(BaseFieldRenderer):
669
+ """Renderer for string fields"""
670
+
671
+ def __init__(self, *args, **kwargs):
672
+ """Initialize string field renderer, passing all arguments to parent"""
673
+ super().__init__(*args, **kwargs)
674
+
675
+ def render_input(self) -> FT:
676
+ """
677
+ Render input element for the field
678
+
679
+ Returns:
680
+ A TextArea component appropriate for string values
681
+ """
682
+
683
+ has_default = get_default(self.field_info) is not _UNSET
684
+ is_field_required = not self.is_optional and not has_default
685
+
686
+ placeholder_text = f"Enter {self.original_field_name.replace('_', ' ')}"
687
+ if self.is_optional:
688
+ placeholder_text += " (Optional)"
689
+
690
+ input_cls_parts = ["w-full"]
691
+ input_spacing_cls = spacing_many(
692
+ ["input_size", "input_padding", "input_line_height", "input_font_size"],
693
+ self.spacing,
694
+ )
695
+ if input_spacing_cls:
696
+ input_cls_parts.append(input_spacing_cls)
697
+
698
+ # Calculate appropriate number of rows based on content
699
+ if isinstance(self.value, str) and self.value:
700
+ # Count line breaks
701
+ line_count = len(self.value.split("\n"))
702
+ # Also consider content length for very long single lines (assuming ~60 chars per line)
703
+ char_count = len(self.value)
704
+ estimated_lines = max(line_count, (char_count // 60) + 1)
705
+ # Compact bounds: minimum 1 row, maximum 3 rows
706
+ rows = min(max(estimated_lines, 1), 3)
707
+ else:
708
+ # Single row for empty content
709
+ rows = 1
710
+
711
+ input_attrs = {
712
+ "id": self.field_name,
713
+ "name": self.field_name,
714
+ "placeholder": placeholder_text,
715
+ "required": is_field_required,
716
+ "cls": " ".join(input_cls_parts),
717
+ "rows": rows,
718
+ "style": "resize: vertical; min-height: 2.5rem; padding: 0.5rem; line-height: 1.25;",
719
+ "data-field-path": self._build_path_string(),
720
+ }
721
+
722
+ # Only add the disabled attribute if the field should actually be disabled
723
+ if self.disabled:
724
+ input_attrs["disabled"] = True
725
+
726
+ # Convert value to string representation, handling None and all other types
727
+ if self.value is None:
728
+ display_value = ""
729
+ else:
730
+ display_value = str(self.value)
731
+
732
+ return mui.TextArea(display_value, **input_attrs)
733
+
734
+
735
+ class NumberFieldRenderer(BaseFieldRenderer):
736
+ """Renderer for number fields (int, float)"""
737
+
738
+ def __init__(self, *args, **kwargs):
739
+ """Initialize number field renderer, passing all arguments to parent"""
740
+ super().__init__(*args, **kwargs)
741
+
742
+ def render_input(self) -> FT:
743
+ """
744
+ Render input element for the field
745
+
746
+ Returns:
747
+ A NumberInput component appropriate for numeric values
748
+ """
749
+ # Determine if field is required
750
+ has_default = get_default(self.field_info) is not _UNSET
751
+ is_field_required = not self.is_optional and not has_default
752
+
753
+ placeholder_text = f"Enter {self.original_field_name.replace('_', ' ')}"
754
+ if self.is_optional:
755
+ placeholder_text += " (Optional)"
756
+
757
+ input_cls_parts = ["w-full"]
758
+ input_spacing_cls = spacing_many(
759
+ ["input_size", "input_padding", "input_line_height", "input_font_size"],
760
+ self.spacing,
761
+ )
762
+ if input_spacing_cls:
763
+ input_cls_parts.append(input_spacing_cls)
764
+
765
+ input_attrs = {
766
+ "value": str(self.value) if self.value is not None else "",
767
+ "id": self.field_name,
768
+ "name": self.field_name,
769
+ "type": "number",
770
+ "placeholder": placeholder_text,
771
+ "required": is_field_required,
772
+ "cls": " ".join(input_cls_parts),
773
+ "step": "any"
774
+ if self.field_info.annotation is float
775
+ or get_origin(self.field_info.annotation) is float
776
+ else "1",
777
+ "data-field-path": self._build_path_string(),
778
+ }
779
+
780
+ # Only add the disabled attribute if the field should actually be disabled
781
+ if self.disabled:
782
+ input_attrs["disabled"] = True
783
+
784
+ return mui.Input(**input_attrs)
785
+
786
+
787
+ class DecimalFieldRenderer(BaseFieldRenderer):
788
+ """Renderer for decimal.Decimal fields"""
789
+
790
+ def __init__(self, *args, **kwargs):
791
+ """Initialize decimal field renderer, passing all arguments to parent"""
792
+ super().__init__(*args, **kwargs)
793
+
794
+ def render_input(self) -> FT:
795
+ """
796
+ Render input element for decimal fields
797
+
798
+ Returns:
799
+ A NumberInput component appropriate for decimal values
800
+ """
801
+ # Determine if field is required
802
+ has_default = get_default(self.field_info) is not _UNSET
803
+ is_field_required = not self.is_optional and not has_default
804
+
805
+ placeholder_text = f"Enter {self.original_field_name.replace('_', ' ')}"
806
+ if self.is_optional:
807
+ placeholder_text += " (Optional)"
808
+
809
+ input_cls_parts = ["w-full"]
810
+ input_spacing_cls = spacing_many(
811
+ ["input_size", "input_padding", "input_line_height", "input_font_size"],
812
+ self.spacing,
813
+ )
814
+ if input_spacing_cls:
815
+ input_cls_parts.append(input_spacing_cls)
816
+
817
+ # Convert Decimal value to string for display
818
+ if isinstance(self.value, Decimal):
819
+ # Use format to avoid scientific notation
820
+ display_value = format(self.value, "f")
821
+ # Normalize zero values to display as "0"
822
+ if self.value == 0:
823
+ display_value = "0"
824
+ elif self.value is not None:
825
+ display_value = str(self.value)
826
+ else:
827
+ display_value = ""
828
+
829
+ input_attrs = {
830
+ "value": display_value,
831
+ "id": self.field_name,
832
+ "name": self.field_name,
833
+ "type": "number",
834
+ "placeholder": placeholder_text,
835
+ "required": is_field_required,
836
+ "cls": " ".join(input_cls_parts),
837
+ "step": "any", # Allow arbitrary decimal precision
838
+ "data-field-path": self._build_path_string(),
839
+ }
840
+
841
+ # Only add the disabled attribute if the field should actually be disabled
842
+ if self.disabled:
843
+ input_attrs["disabled"] = True
844
+
845
+ return mui.Input(**input_attrs)
846
+
847
+
848
+ class BooleanFieldRenderer(BaseFieldRenderer):
849
+ """Renderer for boolean fields"""
850
+
851
+ def __init__(self, *args, **kwargs):
852
+ """Initialize boolean field renderer, passing all arguments to parent"""
853
+ super().__init__(*args, **kwargs)
854
+
855
+ def render_input(self) -> FT:
856
+ """
857
+ Render input element for the field
858
+
859
+ Returns:
860
+ A CheckboxX component appropriate for boolean values
861
+ """
862
+ checkbox_attrs = {
863
+ "id": self.field_name,
864
+ "name": self.field_name,
865
+ "checked": bool(self.value),
866
+ "data-field-path": self._build_path_string(),
867
+ }
868
+
869
+ # Only add the disabled attribute if the field should actually be disabled
870
+ if self.disabled:
871
+ checkbox_attrs["disabled"] = True
872
+
873
+ return mui.CheckboxX(**checkbox_attrs)
874
+
875
+ def render(self) -> FT:
876
+ """
877
+ Render the complete field (label + input) with spacing, placing the checkbox next to the label.
878
+
879
+ Returns:
880
+ A FastHTML component containing the complete field
881
+ """
882
+ # Get the label component
883
+ label_component = self.render_label()
884
+ # Decorate the label with the metric badge (bullet)
885
+ label_component = self._decorate_label(label_component, self.metric_entry)
886
+
887
+ # Get the checkbox component
888
+ checkbox_component = self.render_input()
889
+
890
+ # Get the copy button if enabled
891
+ copy_button = self._render_comparison_copy_button()
892
+
893
+ # Create a flex container to place label and checkbox side by side
894
+ field_element = fh.Div(
895
+ fh.Div(
896
+ label_component,
897
+ checkbox_component,
898
+ cls="flex items-center gap-2 w-full", # Use flexbox to align items horizontally with a small gap
899
+ ),
900
+ cls=f"{spacing('outer_margin', self.spacing)} w-full",
901
+ )
902
+
903
+ # Apply metrics decoration if available (border only, as bullet is in the label)
904
+ decorated_field = self._decorate_metrics(
905
+ field_element, self.metric_entry, scope=DecorationScope.BORDER
906
+ )
907
+
908
+ # If copy button exists, wrap the entire decorated field with copy button on the right
909
+ if copy_button:
910
+ return fh.Div(
911
+ decorated_field,
912
+ copy_button,
913
+ cls="flex items-start gap-2 w-full",
914
+ )
915
+ else:
916
+ return decorated_field
917
+
918
+
919
+ class DateFieldRenderer(BaseFieldRenderer):
920
+ """Renderer for date fields"""
921
+
922
+ def __init__(self, *args, **kwargs):
923
+ """Initialize date field renderer, passing all arguments to parent"""
924
+ super().__init__(*args, **kwargs)
925
+
926
+ def render_input(self) -> FT:
927
+ """
928
+ Render input element for the field
929
+
930
+ Returns:
931
+ A DateInput component appropriate for date values
932
+ """
933
+ formatted_value = ""
934
+ if (
935
+ isinstance(self.value, str) and len(self.value) == 10
936
+ ): # Basic check for YYYY-MM-DD format
937
+ # Assume it's the correct string format from the form
938
+ formatted_value = self.value
939
+ elif isinstance(self.value, date):
940
+ formatted_value = self.value.isoformat() # YYYY-MM-DD
941
+
942
+ has_default = get_default(self.field_info) is not _UNSET
943
+ is_field_required = not self.is_optional and not has_default
944
+
945
+ placeholder_text = f"Select {self.original_field_name.replace('_', ' ')}"
946
+ if self.is_optional:
947
+ placeholder_text += " (Optional)"
948
+
949
+ input_cls_parts = ["w-full"]
950
+ input_spacing_cls = spacing_many(
951
+ ["input_size", "input_padding", "input_line_height", "input_font_size"],
952
+ self.spacing,
953
+ )
954
+ if input_spacing_cls:
955
+ input_cls_parts.append(input_spacing_cls)
956
+
957
+ input_attrs = {
958
+ "value": formatted_value,
959
+ "id": self.field_name,
960
+ "name": self.field_name,
961
+ "type": "date",
962
+ "placeholder": placeholder_text,
963
+ "required": is_field_required,
964
+ "cls": " ".join(input_cls_parts),
965
+ "data-field-path": self._build_path_string(),
966
+ }
967
+
968
+ # Only add the disabled attribute if the field should actually be disabled
969
+ if self.disabled:
970
+ input_attrs["disabled"] = True
971
+
972
+ return mui.Input(**input_attrs)
973
+
974
+
975
+ class TimeFieldRenderer(BaseFieldRenderer):
976
+ """Renderer for time fields"""
977
+
978
+ def __init__(self, *args, **kwargs):
979
+ """Initialize time field renderer, passing all arguments to parent"""
980
+ super().__init__(*args, **kwargs)
981
+
982
+ def render_input(self) -> FT:
983
+ """
984
+ Render input element for the field
985
+
986
+ Returns:
987
+ A TimeInput component appropriate for time values
988
+ """
989
+ formatted_value = ""
990
+ if isinstance(self.value, str):
991
+ # Try to parse the time string using various formats
992
+ time_formats = ["%H:%M", "%H:%M:%S", "%H:%M:%S.%f"]
993
+
994
+ for fmt in time_formats:
995
+ try:
996
+ from datetime import datetime
997
+
998
+ parsed_time = datetime.strptime(self.value, fmt).time()
999
+ formatted_value = parsed_time.strftime("%H:%M")
1000
+ break
1001
+ except ValueError:
1002
+ continue
1003
+ elif isinstance(self.value, time):
1004
+ formatted_value = self.value.strftime("%H:%M") # HH:MM
1005
+
1006
+ # Determine if field is required
1007
+ has_default = get_default(self.field_info) is not _UNSET
1008
+ is_field_required = not self.is_optional and not has_default
1009
+
1010
+ placeholder_text = f"Select {self.original_field_name.replace('_', ' ')}"
1011
+ if self.is_optional:
1012
+ placeholder_text += " (Optional)"
1013
+
1014
+ input_cls_parts = ["w-full"]
1015
+ input_spacing_cls = spacing_many(
1016
+ ["input_size", "input_padding", "input_line_height", "input_font_size"],
1017
+ self.spacing,
1018
+ )
1019
+ if input_spacing_cls:
1020
+ input_cls_parts.append(input_spacing_cls)
1021
+
1022
+ input_attrs = {
1023
+ "value": formatted_value,
1024
+ "id": self.field_name,
1025
+ "name": self.field_name,
1026
+ "type": "time",
1027
+ "placeholder": placeholder_text,
1028
+ "required": is_field_required,
1029
+ "cls": " ".join(input_cls_parts),
1030
+ "data-field-path": self._build_path_string(),
1031
+ }
1032
+
1033
+ # Only add the disabled attribute if the field should actually be disabled
1034
+ if self.disabled:
1035
+ input_attrs["disabled"] = True
1036
+
1037
+ return mui.Input(**input_attrs)
1038
+
1039
+
1040
+ class LiteralFieldRenderer(BaseFieldRenderer):
1041
+ """Renderer for Literal fields as dropdown selects"""
1042
+
1043
+ def __init__(self, *args, **kwargs):
1044
+ """Initialize literal field renderer, passing all arguments to parent"""
1045
+ super().__init__(*args, **kwargs)
1046
+
1047
+ def render_input(self) -> FT:
1048
+ """
1049
+ Render input element for the field as a select dropdown
1050
+
1051
+ Returns:
1052
+ A Select component with options based on the Literal values
1053
+ """
1054
+ # Get the Literal values from annotation
1055
+ annotation = _get_underlying_type_if_optional(self.field_info.annotation)
1056
+ literal_values = get_args(annotation)
1057
+
1058
+ if not literal_values:
1059
+ return mui.Alert(
1060
+ f"No literal values found for {self.field_name}", cls=mui.AlertT.warning
1061
+ )
1062
+
1063
+ # Determine if field is required
1064
+ has_default = get_default(self.field_info) is not _UNSET
1065
+ is_field_required = not self.is_optional and not has_default
1066
+
1067
+ # Create options for each literal value
1068
+ options = []
1069
+ current_value_str = str(self.value) if self.value is not None else None
1070
+
1071
+ # Add empty option for optional fields
1072
+ if self.is_optional:
1073
+ options.append(
1074
+ fh.Option("-- None --", value="", selected=(self.value is None))
1075
+ )
1076
+
1077
+ # Add options for each literal value
1078
+ for value in literal_values:
1079
+ value_str = str(value)
1080
+ is_selected = current_value_str == value_str
1081
+ options.append(
1082
+ fh.Option(
1083
+ value_str, # Display text
1084
+ value=value_str, # Value attribute
1085
+ selected=is_selected,
1086
+ )
1087
+ )
1088
+
1089
+ placeholder_text = f"Select {self.original_field_name.replace('_', ' ')}"
1090
+ if self.is_optional:
1091
+ placeholder_text += " (Optional)"
1092
+
1093
+ # Prepare attributes dictionary
1094
+ select_cls_parts = ["w-full"]
1095
+ select_spacing_cls = spacing_many(
1096
+ ["input_size", "input_padding", "input_line_height", "input_font_size"],
1097
+ self.spacing,
1098
+ )
1099
+ if select_spacing_cls:
1100
+ select_cls_parts.append(select_spacing_cls)
1101
+
1102
+ select_attrs = {
1103
+ "id": self.field_name,
1104
+ "name": self.field_name,
1105
+ "required": is_field_required,
1106
+ "placeholder": placeholder_text,
1107
+ "cls": " ".join(select_cls_parts),
1108
+ "data-field-path": self._build_path_string(),
1109
+ }
1110
+
1111
+ if self.disabled:
1112
+ select_attrs["disabled"] = True
1113
+
1114
+ # Render the select element with options and attributes
1115
+ return mui.Select(*options, **select_attrs)
1116
+
1117
+
1118
+ class EnumFieldRenderer(BaseFieldRenderer):
1119
+ """Renderer for Enum fields as dropdown selects"""
1120
+
1121
+ def __init__(self, *args, **kwargs):
1122
+ """Initialize enum field renderer, passing all arguments to parent"""
1123
+ super().__init__(*args, **kwargs)
1124
+
1125
+ def render_input(self) -> FT:
1126
+ """
1127
+ Render input element for the field as a select dropdown
1128
+
1129
+ Returns:
1130
+ A Select component with options based on the Enum values
1131
+ """
1132
+ # Get the Enum class from annotation
1133
+ annotation = _get_underlying_type_if_optional(self.field_info.annotation)
1134
+ enum_class = annotation
1135
+
1136
+ if not (isinstance(enum_class, type) and issubclass(enum_class, Enum)):
1137
+ return mui.Alert(
1138
+ f"No enum class found for {self.field_name}", cls=mui.AlertT.warning
1139
+ )
1140
+
1141
+ # Get all enum members
1142
+ enum_members = list(enum_class)
1143
+
1144
+ if not enum_members:
1145
+ return mui.Alert(
1146
+ f"No enum values found for {self.field_name}", cls=mui.AlertT.warning
1147
+ )
1148
+
1149
+ # Determine if field is required
1150
+ has_default = get_default(self.field_info) is not _UNSET
1151
+ is_field_required = not self.is_optional and not has_default
1152
+
1153
+ # Create options for each enum value
1154
+ options = []
1155
+ current_value_str = None
1156
+
1157
+ # Convert current value to string for comparison
1158
+ if self.value is not None:
1159
+ if isinstance(self.value, Enum):
1160
+ current_value_str = str(self.value.value)
1161
+ else:
1162
+ current_value_str = str(self.value)
1163
+
1164
+ # Add empty option for optional fields
1165
+ if self.is_optional:
1166
+ options.append(
1167
+ fh.Option("-- None --", value="", selected=(self.value is None))
1168
+ )
1169
+
1170
+ # Add options for each enum member
1171
+ for member in enum_members:
1172
+ member_value_str = str(member.value)
1173
+ display_name = member.name.replace("_", " ").title()
1174
+ is_selected = current_value_str == member_value_str
1175
+ options.append(
1176
+ fh.Option(
1177
+ display_name, # Display text
1178
+ value=member_value_str, # Value attribute
1179
+ selected=is_selected,
1180
+ )
1181
+ )
1182
+
1183
+ placeholder_text = f"Select {self.original_field_name.replace('_', ' ')}"
1184
+ if self.is_optional:
1185
+ placeholder_text += " (Optional)"
1186
+
1187
+ # Prepare attributes dictionary
1188
+ select_attrs = {
1189
+ "id": self.field_name,
1190
+ "name": self.field_name,
1191
+ "required": is_field_required,
1192
+ "placeholder": placeholder_text,
1193
+ "cls": _merge_cls(
1194
+ "w-full",
1195
+ f"{spacing('input_size', self.spacing)} {spacing('input_padding', self.spacing)}".strip(),
1196
+ ),
1197
+ "data-field-path": self._build_path_string(),
1198
+ }
1199
+
1200
+ # Only add the disabled attribute if the field should actually be disabled
1201
+ if self.disabled:
1202
+ select_attrs["disabled"] = True
1203
+
1204
+ # Render the select element with options and attributes
1205
+ return mui.Select(*options, **select_attrs)
1206
+
1207
+
1208
+ class BaseModelFieldRenderer(BaseFieldRenderer):
1209
+ """Renderer for nested Pydantic BaseModel fields"""
1210
+
1211
+ def __init__(self, *args, **kwargs):
1212
+ """Initialize base model field renderer, passing all arguments to parent"""
1213
+ super().__init__(*args, **kwargs)
1214
+
1215
+ def render(self) -> FT:
1216
+ """
1217
+ Render the nested BaseModel field as a single-item accordion using mui.Accordion.
1218
+
1219
+ Returns:
1220
+ A FastHTML component (mui.Accordion) containing the accordion structure.
1221
+ """
1222
+
1223
+ # Extract the label text and apply color styling
1224
+ label_text = self.original_field_name.replace("_", " ").title()
1225
+
1226
+ # Create the title component with proper color styling
1227
+ if self.label_color:
1228
+ if self._is_inline_color(self.label_color):
1229
+ # Color value - apply as inline style
1230
+ title_span = fh.Span(
1231
+ label_text,
1232
+ style=f"color: {self.label_color};",
1233
+ cls="text-sm font-medium",
1234
+ )
1235
+ else:
1236
+ # CSS class - apply as Tailwind class (includes emerald, amber, rose, teal, indigo, lime, violet, etc.)
1237
+ title_span = fh.Span(
1238
+ label_text,
1239
+ cls=f"text-sm font-medium {self._get_color_class(self.label_color)}",
1240
+ )
1241
+ else:
1242
+ # No color specified - use default styling
1243
+ title_span = fh.Span(label_text, cls="text-sm font-medium text-gray-700")
1244
+
1245
+ # Add tooltip if description is available
1246
+ description = getattr(self.field_info, "description", None)
1247
+ if description:
1248
+ title_span.attrs["uk-tooltip"] = description
1249
+ title_span.attrs["title"] = description
1250
+
1251
+ # Apply metrics decoration to title (bullet only, no border)
1252
+ title_with_metrics = self._decorate_metrics(
1253
+ title_span, self.metric_entry, scope=DecorationScope.BULLET
1254
+ )
1255
+
1256
+ # Get copy button if enabled - add it AFTER metrics decoration
1257
+ copy_button = self._render_comparison_copy_button()
1258
+
1259
+ # Wrap title (with metrics) and copy button together if copy button exists
1260
+ if copy_button:
1261
+ title_component = fh.Div(
1262
+ title_with_metrics,
1263
+ copy_button,
1264
+ cls="flex items-center justify-between gap-2 w-full",
1265
+ )
1266
+ else:
1267
+ title_component = title_with_metrics
1268
+
1269
+ # Compute border color for the top-level BaseModel card
1270
+ border_color = self._metric_border_color(self.metric_entry)
1271
+ li_style = {}
1272
+ if border_color:
1273
+ li_style["style"] = (
1274
+ f"border-left: 4px solid {border_color}; padding-left: 0.25rem;"
1275
+ )
1276
+
1277
+ # 2. Render the nested input fields that will be the accordion content
1278
+ input_component = self.render_input()
1279
+
1280
+ # 3. Define unique IDs for potential targeting
1281
+ item_id = f"{self.field_name}_item"
1282
+ accordion_id = f"{self.field_name}_accordion"
1283
+
1284
+ # 4. Create the AccordionItem using the MonsterUI component
1285
+ accordion_item = mui.AccordionItem(
1286
+ title_component, # Title component with proper color styling
1287
+ input_component, # Content component (the Card with nested fields)
1288
+ open=True, # Open by default
1289
+ li_kwargs={
1290
+ "id": item_id,
1291
+ **li_style,
1292
+ }, # Pass the specific ID and style for the <li>
1293
+ cls=spacing(
1294
+ "outer_margin", self.spacing
1295
+ ), # Add bottom margin to the <li> element
1296
+ )
1297
+
1298
+ # 5. Wrap the single AccordionItem in an Accordion container
1299
+ accordion_cls = spacing_many(
1300
+ ["accordion_divider", "accordion_content"], self.spacing
1301
+ )
1302
+ accordion_container = mui.Accordion(
1303
+ accordion_item, # The single item to include
1304
+ id=accordion_id, # ID for the accordion container (ul)
1305
+ multiple=True, # Allow multiple open (though only one exists)
1306
+ collapsible=True, # Allow toggling
1307
+ cls=f"{accordion_cls} w-full".strip(),
1308
+ )
1309
+
1310
+ # 6. Apply metrics decoration to the title only (bullet), not the container
1311
+ # The parent list renderer handles the border decoration
1312
+ return accordion_container
1313
+
1314
+ def render_input(self) -> FT:
1315
+ """
1316
+ Render input elements for nested model fields with robust schema drift handling
1317
+
1318
+ Returns:
1319
+ A Card component containing nested form fields
1320
+ """
1321
+ # Get the nested model class from annotation
1322
+ nested_model_class = _get_underlying_type_if_optional(
1323
+ self.field_info.annotation
1324
+ )
1325
+
1326
+ if nested_model_class is None or not hasattr(
1327
+ nested_model_class, "model_fields"
1328
+ ):
1329
+ return mui.Alert(
1330
+ f"No nested model class found for {self.field_name}",
1331
+ cls=mui.AlertT.error,
1332
+ )
1333
+
1334
+ # Robust value preparation
1335
+ if isinstance(self.value, dict):
1336
+ values_dict = self.value.copy()
1337
+ elif hasattr(self.value, "model_dump"):
1338
+ values_dict = self.value.model_dump()
1339
+ else:
1340
+ values_dict = {}
1341
+
1342
+ # Create nested field inputs with error handling
1343
+ nested_inputs = []
1344
+ skipped_fields = []
1345
+
1346
+ # Only process fields that exist in current model schema
1347
+ for (
1348
+ nested_field_name,
1349
+ nested_field_info,
1350
+ ) in nested_model_class.model_fields.items():
1351
+ try:
1352
+ # Check if field exists in provided values
1353
+ field_was_provided = nested_field_name in values_dict
1354
+ nested_field_value = (
1355
+ values_dict.get(nested_field_name) if field_was_provided else None
1356
+ )
1357
+
1358
+ # Only use defaults if field wasn't provided
1359
+ if not field_was_provided:
1360
+ dv = get_default(nested_field_info) # _UNSET if truly unset
1361
+ if dv is not _UNSET:
1362
+ nested_field_value = dv
1363
+ else:
1364
+ ann = nested_field_info.annotation
1365
+ base_ann = get_origin(ann) or ann
1366
+ if isinstance(base_ann, type) and issubclass(
1367
+ base_ann, BaseModel
1368
+ ):
1369
+ nested_field_value = default_dict_for_model(base_ann)
1370
+ else:
1371
+ nested_field_value = default_for_annotation(ann)
1372
+
1373
+ # Skip SkipJsonSchema fields unless explicitly kept
1374
+ if _is_skip_json_schema_field(
1375
+ nested_field_info
1376
+ ) and not self._is_kept_skip_field(
1377
+ self.field_path + [nested_field_name]
1378
+ ):
1379
+ continue
1380
+
1381
+ # Get renderer for this nested field
1382
+ registry = FieldRendererRegistry() # Get singleton instance
1383
+ renderer_cls = registry.get_renderer(
1384
+ nested_field_name, nested_field_info
1385
+ )
1386
+
1387
+ if not renderer_cls:
1388
+ # Fall back to StringFieldRenderer if no renderer found
1389
+ renderer_cls = StringFieldRenderer
1390
+
1391
+ # The prefix for nested fields is simply the field_name of this BaseModel instance + underscore
1392
+ # field_name already includes the form prefix, so we don't need to add self.prefix again
1393
+ nested_prefix = f"{self.field_name}_"
1394
+
1395
+ # Create and render the nested field
1396
+ renderer = renderer_cls(
1397
+ field_name=nested_field_name,
1398
+ field_info=nested_field_info,
1399
+ value=nested_field_value,
1400
+ prefix=nested_prefix,
1401
+ disabled=self.disabled, # Propagate disabled state to nested fields
1402
+ spacing=self.spacing, # Propagate spacing to nested fields
1403
+ field_path=self.field_path
1404
+ + [nested_field_name], # Propagate path with field name
1405
+ form_name=self.explicit_form_name, # Propagate form name
1406
+ metric_entry=None, # Let auto-lookup handle it
1407
+ metrics_dict=self.metrics_dict, # Pass down the metrics dict
1408
+ refresh_endpoint_override=self._refresh_endpoint_override, # Propagate refresh override
1409
+ keep_skip_json_pathset=self._keep_skip_json_pathset, # Propagate keep paths
1410
+ comparison_copy_enabled=self._cmp_copy_enabled, # Propagate comparison copy settings
1411
+ comparison_copy_target=self._cmp_copy_target,
1412
+ comparison_name=self._cmp_name,
1413
+ )
1414
+
1415
+ nested_inputs.append(renderer.render())
1416
+
1417
+ except Exception as e:
1418
+ logger.warning(
1419
+ f"Skipping field {nested_field_name} in nested model: {e}"
1420
+ )
1421
+ skipped_fields.append(nested_field_name)
1422
+ continue
1423
+
1424
+ # Log summary if fields were skipped
1425
+ if skipped_fields:
1426
+ logger.info(
1427
+ f"Skipped {len(skipped_fields)} fields in {self.field_name}: {skipped_fields}"
1428
+ )
1429
+
1430
+ # Create container for nested inputs
1431
+ nested_form_content = mui.DivVStacked(
1432
+ *nested_inputs,
1433
+ cls=f"{spacing('inner_gap', self.spacing)} items-stretch",
1434
+ )
1435
+
1436
+ # Wrap in card for visual distinction
1437
+ t = self.spacing
1438
+ return mui.Card(
1439
+ nested_form_content,
1440
+ cls=f"{spacing('padding_sm', t)} mt-1 {spacing('card_border', t)} rounded".strip(),
1441
+ )
1442
+
1443
+
1444
+ class ListFieldRenderer(BaseFieldRenderer):
1445
+ """Renderer for list fields containing any type"""
1446
+
1447
+ def __init__(
1448
+ self,
1449
+ field_name: str,
1450
+ field_info: FieldInfo,
1451
+ value: Any = None,
1452
+ prefix: str = "",
1453
+ disabled: bool = False,
1454
+ label_color: Optional[str] = None,
1455
+ spacing: SpacingValue = SpacingTheme.NORMAL,
1456
+ field_path: Optional[List[str]] = None,
1457
+ form_name: Optional[str] = None,
1458
+ metric_entry: Optional[MetricEntry] = None,
1459
+ metrics_dict: Optional[MetricsDict] = None,
1460
+ show_item_border: bool = True,
1461
+ **kwargs, # Accept additional kwargs
1462
+ ):
1463
+ """
1464
+ Initialize the list field renderer
1465
+
1466
+ Args:
1467
+ field_name: The name of the field
1468
+ field_info: The FieldInfo for the field
1469
+ value: The current value of the field (optional)
1470
+ prefix: Optional prefix for the field name (used for nested fields)
1471
+ disabled: Whether the field should be rendered as disabled
1472
+ label_color: Optional CSS color value for the field label
1473
+ spacing: Spacing theme to use for layout
1474
+ field_path: Path segments from root to this field
1475
+ form_name: Explicit form name
1476
+ metric_entry: Optional metric entry for visual feedback
1477
+ metrics_dict: Optional full metrics dict for auto-lookup
1478
+ show_item_border: Whether to show colored borders on list items based on metrics
1479
+ **kwargs: Additional keyword arguments passed to parent
1480
+ """
1481
+ super().__init__(
1482
+ field_name=field_name,
1483
+ field_info=field_info,
1484
+ value=value,
1485
+ prefix=prefix,
1486
+ disabled=disabled,
1487
+ label_color=label_color,
1488
+ spacing=spacing,
1489
+ field_path=field_path,
1490
+ form_name=form_name,
1491
+ metric_entry=metric_entry,
1492
+ metrics_dict=metrics_dict,
1493
+ **kwargs, # Pass kwargs to parent
1494
+ )
1495
+ self.show_item_border = show_item_border
1496
+
1497
+ def _container_id(self) -> str:
1498
+ """
1499
+ Return a DOM-unique ID for the list's <ul> / <div> wrapper.
1500
+
1501
+ Format: <formname>_<hierarchy>_items_container
1502
+ Example: main_form_compact_tags_items_container
1503
+ """
1504
+ base = "_".join(self.field_path) # tags or main_address_tags
1505
+ if self._form_name: # already resolved in property
1506
+ return f"{self._form_name}_{base}_items_container"
1507
+ return f"{base}_items_container" # fallback (shouldn't happen)
1508
+
1509
+ @property
1510
+ def _form_name(self) -> str:
1511
+ """Get form name - prefer explicit form name if provided"""
1512
+ if self.explicit_form_name:
1513
+ return self.explicit_form_name
1514
+
1515
+ # Fallback to extracting from prefix (for backward compatibility)
1516
+ # The prefix always starts with the form name followed by underscore
1517
+ # e.g., "main_form_compact_" or "main_form_compact_main_address_tags_"
1518
+ # We need to extract just "main_form_compact"
1519
+ if self.prefix:
1520
+ # For backward compatibility with existing non-nested lists
1521
+ # Split by underscore and rebuild the form name by removing known field components
1522
+ parts = self.prefix.rstrip("_").split("_")
1523
+
1524
+ # For a simple heuristic: form names typically have 2-3 parts (main_form_compact)
1525
+ # Field paths are at the end, so we find where the form name ends
1526
+ # This is imperfect but works for most cases
1527
+ if len(parts) >= 3 and parts[1] == "form":
1528
+ # Standard pattern: main_form_compact
1529
+ form_name = "_".join(parts[:3])
1530
+ elif len(parts) >= 2:
1531
+ # Fallback: take first 2 parts
1532
+ form_name = "_".join(parts[:2])
1533
+ else:
1534
+ # Single part
1535
+ form_name = parts[0] if parts else ""
1536
+
1537
+ return form_name
1538
+ return ""
1539
+
1540
+ @property
1541
+ def _list_path(self) -> str:
1542
+ """Get the hierarchical path for this list field"""
1543
+ return "/".join(self.field_path)
1544
+
1545
+ def render(self) -> FT:
1546
+ """
1547
+ Render the complete field (label + input) with spacing, adding a refresh icon for list fields.
1548
+ Makes the label clickable to toggle all list items open/closed.
1549
+
1550
+ Returns:
1551
+ A FastHTML component containing the complete field with refresh icon
1552
+ """
1553
+ # Extract form name from prefix (removing trailing underscore if present)
1554
+ # form_name = self.prefix.rstrip("_") if self.prefix else None
1555
+ form_name = self._form_name or None
1556
+
1557
+ # Create the label text with proper color styling and item count
1558
+ items = [] if not isinstance(self.value, list) else self.value
1559
+ item_count = len(items)
1560
+ label_text = f"{self.original_field_name.replace('_', ' ').title()} ({item_count} item{'s' if item_count != 1 else ''})"
1561
+
1562
+ # Create the styled label span
1563
+ if self.label_color:
1564
+ if self._is_inline_color(self.label_color):
1565
+ # Color value - apply as inline style
1566
+ label_span = fh.Span(
1567
+ label_text,
1568
+ style=f"color: {self.label_color};",
1569
+ cls=f"block text-sm font-medium {spacing('label_gap', self.spacing)}",
1570
+ )
1571
+ else:
1572
+ # CSS class - apply as Tailwind class (includes emerald, amber, rose, teal, indigo, lime, violet, etc.)
1573
+ label_span = fh.Span(
1574
+ label_text,
1575
+ cls=f"block text-sm font-medium {self._get_color_class(self.label_color)} {spacing('label_gap', self.spacing)}",
1576
+ )
1577
+ else:
1578
+ # No color specified - use default styling
1579
+ label_span = fh.Span(
1580
+ label_text,
1581
+ cls=f"block text-sm font-medium text-gray-700 {spacing('label_gap', self.spacing)}",
1582
+ )
1583
+
1584
+ # Add tooltip if description is available
1585
+ description = getattr(self.field_info, "description", None)
1586
+ if description:
1587
+ label_span.attrs["uk-tooltip"] = description
1588
+ label_span.attrs["title"] = description
1589
+
1590
+ # Metric decoration will be applied to the title_component below
1591
+
1592
+ # Build action buttons row (refresh only, NOT copy - copy goes after metrics)
1593
+ action_buttons = []
1594
+
1595
+ # Add refresh icon if we have a form name and field is not disabled
1596
+ if form_name and not self.disabled:
1597
+ # Create the smaller icon component
1598
+ refresh_icon_component = mui.UkIcon(
1599
+ "refresh-ccw",
1600
+ cls="w-3 h-3 text-gray-500 hover:text-blue-600", # Smaller size
1601
+ )
1602
+
1603
+ # Use override endpoint if provided (for ComparisonForm), otherwise use standard form refresh
1604
+ refresh_url = (
1605
+ self._refresh_endpoint_override or f"/form/{form_name}/refresh"
1606
+ )
1607
+
1608
+ # Get container ID for accordion state preservation
1609
+ container_id = self._container_id()
1610
+
1611
+ # Create refresh icon as a button with aggressive styling reset
1612
+ refresh_icon_trigger = mui.Button(
1613
+ refresh_icon_component,
1614
+ type="button", # Prevent form submission
1615
+ hx_post=refresh_url,
1616
+ hx_target=f"#{form_name}-inputs-wrapper",
1617
+ hx_swap="innerHTML",
1618
+ hx_trigger="click", # Explicit trigger on click
1619
+ hx_include="closest form", # Include all form fields from the enclosing form
1620
+ hx_preserve="scroll",
1621
+ uk_tooltip="Refresh form display to update list summaries",
1622
+ style="all: unset; display: inline-flex; align-items: center; cursor: pointer; padding: 0 0.5rem;",
1623
+ **{
1624
+ "hx-on::before-request": f"window.saveAccordionState && window.saveAccordionState('{container_id}')"
1625
+ },
1626
+ **{
1627
+ "hx-on::after-swap": f"window.restoreAccordionState && window.restoreAccordionState('{container_id}')"
1628
+ },
1629
+ )
1630
+ action_buttons.append(refresh_icon_trigger)
1631
+
1632
+ # Build title component with label and action buttons (excluding copy button)
1633
+ if action_buttons:
1634
+ # Combine label and action buttons
1635
+ title_base = fh.Div(
1636
+ fh.Div(
1637
+ label_span, # Use the properly styled label span
1638
+ cls="flex-1", # Take up remaining space
1639
+ ),
1640
+ fh.Div(
1641
+ *action_buttons,
1642
+ cls="flex-shrink-0 flex items-center gap-1 px-1", # Don't shrink, add horizontal padding
1643
+ onclick="event.stopPropagation();", # Isolate the action buttons area
1644
+ ),
1645
+ cls="flex items-center",
1646
+ )
1647
+ else:
1648
+ # If no action buttons, just use the styled label
1649
+ title_base = fh.Div(
1650
+ label_span, # Use the properly styled label span
1651
+ cls="flex items-center",
1652
+ )
1653
+
1654
+ # Apply metrics decoration to title (bullet only, no border)
1655
+ title_with_metrics = self._decorate_metrics(
1656
+ title_base, self.metric_entry, scope=DecorationScope.BULLET
1657
+ )
1658
+
1659
+ # Add copy button AFTER metrics decoration
1660
+ copy_button = self._render_comparison_copy_button()
1661
+ if copy_button:
1662
+ # Wrap title (with metrics) and copy button together
1663
+ title_component = fh.Div(
1664
+ title_with_metrics,
1665
+ copy_button,
1666
+ cls="flex items-center justify-between gap-2 w-full",
1667
+ )
1668
+ else:
1669
+ title_component = title_with_metrics
1670
+
1671
+ # Compute border color for the wrapper accordion
1672
+ border_color = self._metric_border_color(self.metric_entry)
1673
+ li_style = {}
1674
+ if border_color:
1675
+ li_style["style"] = (
1676
+ f"border-left: 4px solid {border_color}; padding-left: 0.25rem;"
1677
+ )
1678
+
1679
+ # Create the wrapper AccordionItem that contains all the list items
1680
+ list_content = self.render_input()
1681
+
1682
+ # Define unique IDs for the wrapper accordion
1683
+ wrapper_item_id = f"{self.field_name}_wrapper_item"
1684
+ wrapper_accordion_id = f"{self.field_name}_wrapper_accordion"
1685
+
1686
+ # Create the wrapper AccordionItem
1687
+ wrapper_accordion_item = mui.AccordionItem(
1688
+ title_component, # Title component with label and refresh icon
1689
+ list_content, # Content component (the list items)
1690
+ open=True, # Open by default
1691
+ li_kwargs={
1692
+ "id": wrapper_item_id,
1693
+ **li_style,
1694
+ },
1695
+ cls=spacing("outer_margin", self.spacing),
1696
+ )
1697
+
1698
+ # Wrap in an Accordion container
1699
+ accordion_cls = spacing_many(
1700
+ ["accordion_divider", "accordion_content"], self.spacing
1701
+ )
1702
+ wrapper_accordion = mui.Accordion(
1703
+ wrapper_accordion_item,
1704
+ id=wrapper_accordion_id,
1705
+ multiple=True,
1706
+ collapsible=True,
1707
+ cls=f"{accordion_cls} w-full".strip(),
1708
+ )
1709
+
1710
+ return wrapper_accordion
1711
+
1712
+ def render_input(self) -> FT:
1713
+ """
1714
+ Render a list of items with add/delete/move capabilities
1715
+
1716
+ Returns:
1717
+ A component containing the list items and controls
1718
+ """
1719
+ # Initialize the value as an empty list, ensuring it's always a list
1720
+ items = [] if not isinstance(self.value, list) else self.value
1721
+
1722
+ annotation = getattr(self.field_info, "annotation", None)
1723
+ item_type = None # Initialize here to avoid UnboundLocalError
1724
+
1725
+ # Handle Optional[List[...]] by unwrapping the Optional first
1726
+ base_annotation = _get_underlying_type_if_optional(annotation)
1727
+
1728
+ if (
1729
+ base_annotation is not None
1730
+ and hasattr(base_annotation, "__origin__")
1731
+ and base_annotation.__origin__ is list
1732
+ ):
1733
+ item_type = base_annotation.__args__[0]
1734
+
1735
+ if not item_type:
1736
+ logger.error(f"Cannot determine item type for list field {self.field_name}")
1737
+ return mui.Alert(
1738
+ f"Cannot determine item type for list field {self.field_name}",
1739
+ cls=mui.AlertT.error,
1740
+ )
1741
+
1742
+ # Create list items
1743
+ item_elements = []
1744
+ for idx, item in enumerate(items):
1745
+ try:
1746
+ item_card = self._render_item_card(item, idx, item_type)
1747
+ item_elements.append(item_card)
1748
+ except Exception as e:
1749
+ logger.error(f"Error rendering item {idx}: {str(e)}", exc_info=True)
1750
+ error_message = f"Error rendering item {idx}: {str(e)}"
1751
+
1752
+ # Add more context to the error for debugging
1753
+ if isinstance(item, dict):
1754
+ error_message += f" (Dict keys: {list(item.keys())})"
1755
+
1756
+ item_elements.append(
1757
+ mui.AccordionItem(
1758
+ mui.Alert(
1759
+ error_message,
1760
+ cls=mui.AlertT.error,
1761
+ ),
1762
+ # title=f"Error in item {idx}",
1763
+ li_kwargs={"cls": "mb-2"},
1764
+ )
1765
+ )
1766
+
1767
+ # Container for list items using hierarchical field path
1768
+ container_id = self._container_id()
1769
+
1770
+ # Use mui.Accordion component
1771
+ accordion_cls = spacing_many(
1772
+ ["inner_gap_small", "accordion_content", "accordion_divider"], self.spacing
1773
+ )
1774
+ accordion = mui.Accordion(
1775
+ *item_elements,
1776
+ id=container_id,
1777
+ multiple=True, # Allow multiple items to be open at once
1778
+ collapsible=True, # Make it collapsible
1779
+ cls=accordion_cls.strip(), # Add space between items and accordion content styling
1780
+ )
1781
+
1782
+ # Empty state message if no items
1783
+ empty_state = ""
1784
+ if not items:
1785
+ # Use hierarchical path for URL
1786
+ add_url = (
1787
+ f"/form/{self._form_name}/list/add/{self._list_path}"
1788
+ if self._form_name
1789
+ else f"/list/add/{self.field_name}"
1790
+ )
1791
+
1792
+ # Prepare button attributes
1793
+ add_button_attrs = {
1794
+ "cls": "uk-button-primary uk-button-small mt-2",
1795
+ "hx_post": add_url,
1796
+ "hx_target": f"#{container_id}",
1797
+ "hx_swap": "beforeend",
1798
+ "type": "button",
1799
+ }
1800
+
1801
+ # Only add disabled attribute if field should be disabled
1802
+ if self.disabled:
1803
+ add_button_attrs["disabled"] = "true"
1804
+
1805
+ # Differentiate message for Optional[List] vs required List
1806
+ if self.is_optional:
1807
+ empty_message = (
1808
+ "No items in this optional list. Click 'Add Item' if needed."
1809
+ )
1810
+ else:
1811
+ empty_message = (
1812
+ "No items in this required list. Click 'Add Item' to create one."
1813
+ )
1814
+
1815
+ empty_state = mui.Alert(
1816
+ fh.Div(
1817
+ mui.UkIcon("info", cls="mr-2"),
1818
+ empty_message,
1819
+ mui.Button("Add Item", **add_button_attrs),
1820
+ cls="flex flex-col items-start",
1821
+ ),
1822
+ cls=mui.AlertT.info,
1823
+ )
1824
+
1825
+ # Return the complete component (minimal styling since it's now wrapped in an accordion)
1826
+ t = self.spacing
1827
+ return fh.Div(
1828
+ accordion,
1829
+ empty_state,
1830
+ cls=f"{spacing('padding', t)}".strip(), # Keep padding for content, remove border and margin
1831
+ )
1832
+
1833
+ def _render_item_card(self, item, idx, item_type, is_open=False) -> FT:
1834
+ """
1835
+ Render a card for a single item in the list
1836
+
1837
+ Args:
1838
+ item: The item data
1839
+ idx: The index of the item
1840
+ item_type: The type of the item
1841
+ is_open: Whether the accordion item should be open by default
1842
+
1843
+ Returns:
1844
+ A FastHTML component for the item card
1845
+ """
1846
+ try:
1847
+ # Create a unique ID for this item
1848
+ item_id = f"{self.field_name}_{idx}"
1849
+ item_card_id = f"{item_id}_card"
1850
+
1851
+ # Look up metrics for this list item
1852
+ item_path_segments = self.field_path + [str(idx)]
1853
+ path_string = _build_path_string_static(item_path_segments)
1854
+ item_metric_entry: Optional[MetricEntry] = (
1855
+ self.metrics_dict.get(path_string) if self.metrics_dict else None
1856
+ )
1857
+
1858
+ # Check if it's a simple type or BaseModel
1859
+ is_model = hasattr(item_type, "model_fields")
1860
+
1861
+ # --- Generate item summary for the accordion title ---
1862
+ # Create a user-friendly display index
1863
+ if isinstance(idx, str) and idx.startswith("new_"):
1864
+ # Get the type name for new items
1865
+ if is_model:
1866
+ # For BaseModel types, use the class name
1867
+ type_name = item_type.__name__
1868
+ else:
1869
+ # For simple types, use a friendly name
1870
+ type_name_map = {
1871
+ str: "String",
1872
+ int: "Number",
1873
+ float: "Number",
1874
+ bool: "Boolean",
1875
+ date: "Date",
1876
+ time: "Time",
1877
+ }
1878
+ type_name = type_name_map.get(
1879
+ item_type,
1880
+ item_type.__name__
1881
+ if hasattr(item_type, "__name__")
1882
+ else str(item_type),
1883
+ )
1884
+
1885
+ display_idx = f"New {type_name}"
1886
+ else:
1887
+ display_idx = str(idx)
1888
+
1889
+ if is_model:
1890
+ try:
1891
+ # Determine how to get the string representation based on item type
1892
+ if isinstance(item, item_type):
1893
+ # Item is already a model instance
1894
+ model_for_display = item
1895
+
1896
+ elif isinstance(item, dict):
1897
+ # Item is a dict, use model_construct for better performance (defaults are known-good)
1898
+ model_for_display = item_type.model_construct(**item)
1899
+
1900
+ else:
1901
+ # Handle cases where item is None or unexpected type
1902
+ model_for_display = None
1903
+ logger.warning(
1904
+ f"Item {item} is neither a model instance nor a dict: {type(item).__name__}"
1905
+ )
1906
+
1907
+ if model_for_display is not None:
1908
+ # Use the model's __str__ method
1909
+ item_summary_text = f"{display_idx}: {str(model_for_display)}"
1910
+ else:
1911
+ # Fallback for None or unexpected types
1912
+ item_summary_text = f"{item_type.__name__}: (Unknown format: {type(item).__name__})"
1913
+ logger.warning(
1914
+ f"Using fallback summary text: {item_summary_text}"
1915
+ )
1916
+ except ValidationError as e:
1917
+ # Handle validation errors when creating model from dict
1918
+ logger.warning(
1919
+ f"Validation error creating display string for {item_type.__name__}: {e}"
1920
+ )
1921
+ if isinstance(item, dict):
1922
+ logger.warning(
1923
+ f"Validation failed for dict keys: {list(item.keys())}"
1924
+ )
1925
+ item_summary_text = f"{item_type.__name__}: (Invalid data)"
1926
+ except Exception as e:
1927
+ # Catch any other unexpected errors
1928
+ logger.error(
1929
+ f"Error creating display string for {item_type.__name__}: {e}",
1930
+ exc_info=True,
1931
+ )
1932
+ item_summary_text = f"{item_type.__name__}: (Error displaying item)"
1933
+ else:
1934
+ item_summary_text = f"{display_idx}: {str(item)}"
1935
+
1936
+ # --- Render item content elements ---
1937
+ item_content_elements = []
1938
+
1939
+ if is_model:
1940
+ # Handle BaseModel items with robust schema drift handling
1941
+ # Form name prefix + field name + index + _
1942
+ name_prefix = f"{self.prefix}{self.original_field_name}_{idx}_"
1943
+
1944
+ # Robust value preparation for schema drift handling
1945
+ if isinstance(item, dict):
1946
+ nested_values = item.copy()
1947
+ elif hasattr(item, "model_dump"):
1948
+ nested_values = item.model_dump()
1949
+ else:
1950
+ nested_values = {}
1951
+
1952
+ # Check if there's a specific renderer registered for this item_type
1953
+ registry = FieldRendererRegistry()
1954
+ # Create a dummy FieldInfo for the renderer lookup
1955
+ item_field_info = FieldInfo(annotation=item_type)
1956
+ # Look up potential custom renderer for this item type
1957
+ item_renderer_cls = registry.get_renderer(
1958
+ f"item_{idx}", item_field_info
1959
+ )
1960
+
1961
+ # Get the default BaseModelFieldRenderer class for comparison
1962
+ from_imports = globals()
1963
+ BaseModelFieldRenderer_cls = from_imports.get("BaseModelFieldRenderer")
1964
+
1965
+ # Check if a specific renderer (different from BaseModelFieldRenderer) was found
1966
+ if (
1967
+ item_renderer_cls
1968
+ and item_renderer_cls is not BaseModelFieldRenderer_cls
1969
+ ):
1970
+ # Use the custom renderer for the entire item
1971
+ item_renderer = item_renderer_cls(
1972
+ field_name=f"{self.original_field_name}_{idx}",
1973
+ field_info=item_field_info,
1974
+ value=item,
1975
+ prefix=self.prefix,
1976
+ disabled=self.disabled, # Propagate disabled state
1977
+ spacing=self.spacing, # Propagate spacing
1978
+ field_path=self.field_path
1979
+ + [str(idx)], # Propagate path with index
1980
+ form_name=self.explicit_form_name, # Propagate form name
1981
+ metric_entry=None, # Let auto-lookup handle it
1982
+ metrics_dict=self.metrics_dict, # Pass down the metrics dict
1983
+ refresh_endpoint_override=self._refresh_endpoint_override, # Propagate refresh override
1984
+ keep_skip_json_pathset=self._keep_skip_json_pathset, # Propagate keep paths
1985
+ comparison_copy_enabled=self._cmp_copy_enabled, # Propagate comparison copy settings
1986
+ comparison_copy_target=self._cmp_copy_target,
1987
+ comparison_name=self._cmp_name,
1988
+ )
1989
+ # Add the rendered input to content elements
1990
+ item_content_elements.append(item_renderer.render_input())
1991
+ else:
1992
+ # Fall back to original behavior: render each field individually with schema drift handling
1993
+ valid_fields = []
1994
+ skipped_fields = []
1995
+
1996
+ # Only process fields that exist in current model
1997
+ for (
1998
+ nested_field_name,
1999
+ nested_field_info,
2000
+ ) in item_type.model_fields.items():
2001
+ try:
2002
+ field_was_provided = nested_field_name in nested_values
2003
+ nested_field_value = (
2004
+ nested_values.get(nested_field_name)
2005
+ if field_was_provided
2006
+ else None
2007
+ )
2008
+
2009
+ # Use defaults only if field not provided
2010
+ if not field_was_provided:
2011
+ dv = get_default(nested_field_info)
2012
+ if dv is not _UNSET:
2013
+ nested_field_value = dv
2014
+ else:
2015
+ ann = nested_field_info.annotation
2016
+ base_ann = get_origin(ann) or ann
2017
+ if isinstance(base_ann, type) and issubclass(
2018
+ base_ann, BaseModel
2019
+ ):
2020
+ nested_field_value = default_dict_for_model(
2021
+ base_ann
2022
+ )
2023
+ else:
2024
+ nested_field_value = default_for_annotation(ann)
2025
+
2026
+ # Skip SkipJsonSchema fields unless explicitly kept
2027
+ if _is_skip_json_schema_field(
2028
+ nested_field_info
2029
+ ) and not self._is_kept_skip_field(
2030
+ self.field_path + [nested_field_name]
2031
+ ):
2032
+ continue
2033
+
2034
+ # Get renderer and render field with error handling
2035
+ renderer_cls = FieldRendererRegistry().get_renderer(
2036
+ nested_field_name, nested_field_info
2037
+ )
2038
+ if not renderer_cls:
2039
+ renderer_cls = StringFieldRenderer
2040
+
2041
+ renderer = renderer_cls(
2042
+ field_name=nested_field_name,
2043
+ field_info=nested_field_info,
2044
+ value=nested_field_value,
2045
+ prefix=name_prefix,
2046
+ disabled=self.disabled, # Propagate disabled state
2047
+ spacing=self.spacing, # Propagate spacing
2048
+ field_path=self.field_path
2049
+ + [
2050
+ str(idx),
2051
+ nested_field_name,
2052
+ ], # Propagate path with index
2053
+ form_name=self.explicit_form_name, # Propagate form name
2054
+ metric_entry=None, # Let auto-lookup handle it
2055
+ metrics_dict=self.metrics_dict, # Pass down the metrics dict
2056
+ refresh_endpoint_override=self._refresh_endpoint_override, # Propagate refresh override
2057
+ keep_skip_json_pathset=self._keep_skip_json_pathset, # Propagate keep paths
2058
+ comparison_copy_enabled=self._cmp_copy_enabled, # Propagate comparison copy settings
2059
+ comparison_copy_target=self._cmp_copy_target,
2060
+ comparison_name=self._cmp_name,
2061
+ )
2062
+
2063
+ # Add rendered field to valid fields
2064
+ valid_fields.append(renderer.render())
2065
+
2066
+ except Exception as e:
2067
+ logger.warning(
2068
+ f"Skipping problematic field {nested_field_name} in list item: {e}"
2069
+ )
2070
+ skipped_fields.append(nested_field_name)
2071
+ continue
2072
+
2073
+ # Log summary if fields were skipped
2074
+ if skipped_fields:
2075
+ logger.info(
2076
+ f"Skipped {len(skipped_fields)} fields in list item {idx}: {skipped_fields}"
2077
+ )
2078
+
2079
+ item_content_elements = valid_fields
2080
+ else:
2081
+ # Handle simple type items
2082
+ field_info = FieldInfo(annotation=item_type)
2083
+ renderer_cls = FieldRendererRegistry().get_renderer(
2084
+ f"item_{idx}", field_info
2085
+ )
2086
+ # Calculate the base name for the item within the list
2087
+ item_base_name = f"{self.original_field_name}_{idx}" # e.g., "tags_0"
2088
+
2089
+ simple_renderer = renderer_cls(
2090
+ field_name=item_base_name, # Correct: Use name relative to list field
2091
+ field_info=field_info,
2092
+ value=item,
2093
+ prefix=self.prefix, # Correct: Provide the form prefix
2094
+ disabled=self.disabled, # Propagate disabled state
2095
+ spacing=self.spacing, # Propagate spacing
2096
+ field_path=self.field_path
2097
+ + [str(idx)], # Propagate path with index
2098
+ form_name=self.explicit_form_name, # Propagate form name
2099
+ metric_entry=None, # Let auto-lookup handle it
2100
+ metrics_dict=self.metrics_dict, # Pass down the metrics dict
2101
+ refresh_endpoint_override=self._refresh_endpoint_override, # Propagate refresh override
2102
+ )
2103
+ input_element = simple_renderer.render_input()
2104
+ wrapper = fh.Div(input_element)
2105
+ # Don't apply metrics decoration here - the card border handles it
2106
+ item_content_elements.append(wrapper)
2107
+
2108
+ # --- Create action buttons with form-specific URLs ---
2109
+ # Generate HTMX endpoints using hierarchical paths
2110
+ delete_url = (
2111
+ f"/form/{self._form_name}/list/delete/{self._list_path}"
2112
+ if self._form_name
2113
+ else f"/list/delete/{self.field_name}"
2114
+ )
2115
+
2116
+ add_url = (
2117
+ f"/form/{self._form_name}/list/add/{self._list_path}"
2118
+ if self._form_name
2119
+ else f"/list/add/{self.field_name}"
2120
+ )
2121
+
2122
+ # Use the full ID (with prefix) for targeting
2123
+ full_card_id = (
2124
+ f"{self.prefix}{item_card_id}" if self.prefix else item_card_id
2125
+ )
2126
+
2127
+ # Create attribute dictionaries for buttons
2128
+ delete_button_attrs = {
2129
+ "cls": "uk-button-danger uk-button-small",
2130
+ "hx_delete": delete_url,
2131
+ "hx_target": f"#{full_card_id}",
2132
+ "hx_swap": "outerHTML",
2133
+ "uk_tooltip": "Delete this item",
2134
+ "hx_params": f"idx={idx}",
2135
+ "hx_confirm": "Are you sure you want to delete this item?",
2136
+ "type": "button", # Prevent form submission
2137
+ }
2138
+
2139
+ add_below_button_attrs = {
2140
+ "cls": "uk-button-secondary uk-button-small ml-2",
2141
+ "hx_post": add_url,
2142
+ "hx_target": f"#{full_card_id}",
2143
+ "hx_swap": "afterend",
2144
+ "uk_tooltip": "Insert new item below",
2145
+ "type": "button", # Prevent form submission
2146
+ }
2147
+
2148
+ move_up_button_attrs = {
2149
+ "cls": "uk-button-link move-up-btn",
2150
+ "onclick": "moveItemUp(this); return false;",
2151
+ "uk_tooltip": "Move up",
2152
+ "type": "button", # Prevent form submission
2153
+ }
2154
+
2155
+ move_down_button_attrs = {
2156
+ "cls": "uk-button-link move-down-btn ml-2",
2157
+ "onclick": "moveItemDown(this); return false;",
2158
+ "uk_tooltip": "Move down",
2159
+ "type": "button", # Prevent form submission
2160
+ }
2161
+
2162
+ # Create buttons using attribute dictionaries, passing disabled state directly
2163
+ delete_button = mui.Button(
2164
+ mui.UkIcon("trash"), disabled=self.disabled, **delete_button_attrs
2165
+ )
2166
+
2167
+ add_below_button = mui.Button(
2168
+ mui.UkIcon("plus-circle"),
2169
+ disabled=self.disabled,
2170
+ **add_below_button_attrs,
2171
+ )
2172
+
2173
+ move_up_button = mui.Button(
2174
+ mui.UkIcon("arrow-up"), disabled=self.disabled, **move_up_button_attrs
2175
+ )
2176
+
2177
+ move_down_button = mui.Button(
2178
+ mui.UkIcon("arrow-down"),
2179
+ disabled=self.disabled,
2180
+ **move_down_button_attrs,
2181
+ )
2182
+
2183
+ # Assemble actions div
2184
+ t = self.spacing
2185
+ actions = fh.Div(
2186
+ fh.Div( # Left side buttons
2187
+ delete_button, add_below_button, cls="flex items-center"
2188
+ ),
2189
+ fh.Div( # Right side buttons
2190
+ move_up_button, move_down_button, cls="flex items-center space-x-1"
2191
+ ),
2192
+ cls=f"flex justify-between w-full mt-3 pt-3 {spacing('section_divider', t)}".strip(),
2193
+ )
2194
+
2195
+ # Create a wrapper Div for the main content elements with proper padding
2196
+ t = self.spacing
2197
+ content_wrapper = fh.Div(
2198
+ *item_content_elements,
2199
+ cls=f"{spacing('card_body_pad', t)} {spacing('inner_gap', t)}",
2200
+ )
2201
+
2202
+ # Return the accordion item
2203
+ title_span = fh.Span(
2204
+ item_summary_text, cls="text-gray-700 font-medium pl-3"
2205
+ )
2206
+
2207
+ # Apply metrics decoration to the title span FIRST (bullet only)
2208
+ title_with_metrics = self._decorate_metrics(
2209
+ title_span, item_metric_entry, scope=DecorationScope.BULLET
2210
+ )
2211
+
2212
+ # Get copy button for this specific list item (if enabled) - add AFTER metrics
2213
+ # Create a temporary renderer context with this item's path
2214
+ if self._cmp_copy_enabled and self._cmp_copy_target and self._cmp_name:
2215
+ # Build the path for this specific item
2216
+ item_path_for_copy = self.field_path + [str(idx)]
2217
+ item_path_string = _build_path_string_static(item_path_for_copy)
2218
+ arrow = (
2219
+ "arrow-left" if self._cmp_copy_target == "left" else "arrow-right"
2220
+ )
2221
+ tooltip_text = f"Copy item to {self._cmp_copy_target}"
2222
+
2223
+ # Note: Copy button is never disabled, even in disabled forms
2224
+ # Pure JS copy: Bypass HTMX entirely to avoid accordion collapse
2225
+ item_copy_button = mui.Button(
2226
+ mui.UkIcon(arrow, cls="w-4 h-4 text-gray-500 hover:text-blue-600"),
2227
+ type="button",
2228
+ onclick=f"window.fhpfPerformCopy('{item_path_string}', '{self.prefix}', '{self._cmp_copy_target}'); return false;",
2229
+ uk_tooltip=tooltip_text,
2230
+ cls="uk-button-text uk-button-small flex-shrink-0",
2231
+ style="all: unset; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; padding: 0.25rem; min-width: 1.5rem;",
2232
+ )
2233
+
2234
+ # Wrap title (with metrics) and copy button together
2235
+ title_component = fh.Div(
2236
+ title_with_metrics,
2237
+ item_copy_button,
2238
+ cls="flex items-center justify-between gap-2 w-full",
2239
+ )
2240
+ else:
2241
+ title_component = title_with_metrics
2242
+
2243
+ # Prepare li attributes with optional border styling
2244
+ li_attrs = {"id": full_card_id}
2245
+
2246
+ # Add colored border based on metrics if enabled
2247
+ if self.show_item_border and item_metric_entry:
2248
+ border_color = self._metric_border_color(item_metric_entry)
2249
+ if border_color:
2250
+ li_attrs["style"] = (
2251
+ f"border-left: 4px solid {border_color}; padding-left: 0.25rem;"
2252
+ )
2253
+
2254
+ # Build card classes using spacing tokens
2255
+ card_cls_parts = ["uk-card"]
2256
+ if self.spacing == SpacingTheme.NORMAL:
2257
+ card_cls_parts.append("uk-card-default")
2258
+
2259
+ # Add spacing-based classes
2260
+ card_spacing_cls = spacing_many(
2261
+ ["accordion_item_margin", "card_border_thin"], self.spacing
2262
+ )
2263
+ if card_spacing_cls:
2264
+ card_cls_parts.append(card_spacing_cls)
2265
+
2266
+ card_cls = " ".join(card_cls_parts)
2267
+
2268
+ return mui.AccordionItem(
2269
+ title_component, # Title as first positional argument
2270
+ content_wrapper, # Use the new padded wrapper for content
2271
+ actions, # More content elements
2272
+ cls=card_cls, # Use theme-aware card classes
2273
+ open=is_open,
2274
+ li_kwargs=li_attrs, # Pass remaining li attributes without cls
2275
+ )
2276
+
2277
+ except Exception as e:
2278
+ # Return error representation
2279
+
2280
+ # Still try to get metrics for error items
2281
+ item_path_segments = self.field_path + [str(idx)]
2282
+ path_string = _build_path_string_static(item_path_segments)
2283
+
2284
+ title_component = fh.Span(
2285
+ f"Error in item {idx}", cls="text-red-600 font-medium pl-3"
2286
+ )
2287
+
2288
+ # Apply metrics decoration even to error items (bullet only)
2289
+ title_component = self._decorate_metrics(
2290
+ title_component, item_metric_entry, scope=DecorationScope.BULLET
2291
+ )
2292
+
2293
+ content_component = mui.Alert(
2294
+ f"Error rendering item {idx}: {str(e)}", cls=mui.AlertT.error
2295
+ )
2296
+
2297
+ li_attrs = {"id": f"{self.field_name}_{idx}_error_card"}
2298
+
2299
+ # Add colored border for error items too if metrics present
2300
+ if self.show_item_border and item_metric_entry:
2301
+ border_color = self._metric_border_color(item_metric_entry)
2302
+ if border_color:
2303
+ li_attrs["style"] = (
2304
+ f"border-left: 4px solid {border_color}; padding-left: 0.25rem;"
2305
+ )
2306
+
2307
+ # Wrap error component in a div with consistent padding
2308
+ t = self.spacing
2309
+ content_wrapper = fh.Div(content_component, cls=spacing("card_body_pad", t))
2310
+
2311
+ # Build card classes using spacing tokens
2312
+ card_cls_parts = ["uk-card"]
2313
+ if self.spacing == SpacingTheme.NORMAL:
2314
+ card_cls_parts.append("uk-card-default")
2315
+
2316
+ # Add spacing-based classes
2317
+ card_spacing_cls = spacing_many(
2318
+ ["accordion_item_margin", "card_border_thin"], self.spacing
2319
+ )
2320
+ if card_spacing_cls:
2321
+ card_cls_parts.append(card_spacing_cls)
2322
+
2323
+ card_cls = " ".join(card_cls_parts)
2324
+
2325
+ return mui.AccordionItem(
2326
+ title_component, # Title as first positional argument
2327
+ content_wrapper, # Wrapped content element
2328
+ cls=card_cls, # Use theme-aware card classes
2329
+ li_kwargs=li_attrs, # Pass remaining li attributes without cls
2330
+ )