fh-pydantic-form 0.2.5__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of fh-pydantic-form might be problematic. Click here for more details.

@@ -1,4 +1,5 @@
1
1
  import logging
2
+ import re
2
3
  from datetime import date, time
3
4
  from enum import Enum
4
5
  from typing import (
@@ -15,9 +16,17 @@ from fastcore.xml import FT
15
16
  from pydantic import ValidationError
16
17
  from pydantic.fields import FieldInfo
17
18
 
19
+ from fh_pydantic_form.color_utils import (
20
+ DEFAULT_METRIC_GREY,
21
+ get_metric_colors,
22
+ robust_color_to_rgba,
23
+ )
18
24
  from fh_pydantic_form.constants import _UNSET
19
25
  from fh_pydantic_form.registry import FieldRendererRegistry
20
26
  from fh_pydantic_form.type_helpers import (
27
+ DecorationScope,
28
+ MetricEntry,
29
+ MetricsDict,
21
30
  _get_underlying_type_if_optional,
22
31
  _is_optional_type,
23
32
  get_default,
@@ -33,6 +42,51 @@ from fh_pydantic_form.ui_style import (
33
42
  logger = logging.getLogger(__name__)
34
43
 
35
44
 
45
+ def _is_form_control(node: Any) -> bool:
46
+ """
47
+ Returns True if this node is a form control element that should receive highlighting.
48
+
49
+ Detects both raw HTML form controls and MonsterUI wrapper components.
50
+ """
51
+ if not hasattr(node, "tag"):
52
+ return False
53
+
54
+ tag = str(getattr(node, "tag", "")).lower()
55
+
56
+ # Raw HTML controls
57
+ if tag in ("input", "select", "textarea"):
58
+ return True
59
+
60
+ # For MonsterUI components, highlight the outer div container instead of inner elements
61
+ # This provides better visual feedback since MonsterUI hides the actual select elements
62
+ if hasattr(node, "attrs") and hasattr(node, "children"):
63
+ classes = str(node.attrs.get("cls", "") or node.attrs.get("class", ""))
64
+
65
+ # Check if this div contains a MonsterUI component
66
+ if tag == "div" and node.children:
67
+ for child in node.children:
68
+ child_tag = str(getattr(child, "tag", "")).lower()
69
+ if child_tag.startswith("uk-") and any(
70
+ control in child_tag for control in ["select", "input", "checkbox"]
71
+ ):
72
+ return True
73
+
74
+ # Also check for direct MonsterUI wrapper classes
75
+ if tag == "div" and "uk-select" in classes:
76
+ return True
77
+
78
+ # MonsterUI typically uses uk- prefixed classes
79
+ if any(
80
+ c
81
+ for c in classes.split()
82
+ if c.startswith("uk-")
83
+ and any(t in c for t in ["input", "select", "checkbox"])
84
+ ):
85
+ return True
86
+
87
+ return False
88
+
89
+
36
90
  def _merge_cls(base: str, extra: str) -> str:
37
91
  """Return base plus extra class(es) separated by a single space (handles blanks)."""
38
92
  if extra:
@@ -42,7 +96,265 @@ def _merge_cls(base: str, extra: str) -> str:
42
96
  return base
43
97
 
44
98
 
45
- class BaseFieldRenderer:
99
+ class MetricsRendererMixin:
100
+ """Mixin to add metrics highlighting capabilities to field renderers"""
101
+
102
+ def _decorate_label(
103
+ self,
104
+ label: FT,
105
+ metric_entry: Optional[MetricEntry],
106
+ ) -> FT:
107
+ """
108
+ Decorate a label element with a metric badge (bullet) if applicable.
109
+ """
110
+ return self._decorate_metrics(label, metric_entry, scope=DecorationScope.BULLET)
111
+
112
+ def _decorate_metrics(
113
+ self,
114
+ element: FT,
115
+ metric_entry: Optional[MetricEntry],
116
+ *,
117
+ scope: DecorationScope = DecorationScope.BOTH,
118
+ ) -> FT:
119
+ """
120
+ Decorate an element with metrics visual feedback.
121
+
122
+ Args:
123
+ element: The FastHTML element to decorate
124
+ metric_entry: Optional metric entry with color, score, and comment
125
+ scope: Which decorations to apply (BORDER, BULLET, or BOTH)
126
+
127
+ Returns:
128
+ Decorated element with left color bar, tooltip, and optional metric badge
129
+ """
130
+ if not metric_entry:
131
+ return element
132
+
133
+ # Add tooltip with comment if available
134
+ comment = metric_entry.get("comment")
135
+ if comment and hasattr(element, "attrs"):
136
+ element.attrs["uk-tooltip"] = comment
137
+ element.attrs["title"] = comment # Fallback standard tooltip
138
+
139
+ # Add left color bar if requested
140
+ if scope in {DecorationScope.BORDER, DecorationScope.BOTH}:
141
+ border_color = self._metric_border_color(metric_entry)
142
+ if border_color and hasattr(element, "attrs"):
143
+ existing_style = element.attrs.get("style", "")
144
+ element.attrs["style"] = (
145
+ f"border-left: 4px solid {border_color}; padding-left: 0.25rem; position: relative; z-index: 0; {existing_style}"
146
+ )
147
+
148
+ # Add metric score badge if requested and present
149
+ score = metric_entry.get("metric")
150
+ color = metric_entry.get("color")
151
+ if (
152
+ scope in {DecorationScope.BULLET, DecorationScope.BOTH}
153
+ and score is not None
154
+ ):
155
+ # Determine bullet colors based on LangSmith-style system when no color provided
156
+ if color:
157
+ # Use provided color - convert to full opacity for badge
158
+ badge_bg_rgba = robust_color_to_rgba(color, 1.0)
159
+ # Extract RGB values and use them for badge background
160
+ rgb_match = re.match(
161
+ r"rgba\((\d+),\s*(\d+),\s*(\d+),\s*[\d.]+\)", badge_bg_rgba
162
+ )
163
+ if rgb_match:
164
+ r, g, b = rgb_match.groups()
165
+ badge_bg = f"rgb({r}, {g}, {b})"
166
+ else:
167
+ badge_bg = color
168
+ text_color = "white"
169
+ else:
170
+ # Use metric-based color system
171
+ badge_bg, text_color = get_metric_colors(score)
172
+
173
+ # Create custom styled span that looks like a bullet/pill
174
+ metric_badge = fh.Span(
175
+ str(score),
176
+ style=f"""
177
+ background-color: {badge_bg};
178
+ color: {text_color};
179
+ padding: 0.125rem 0.5rem;
180
+ border-radius: 9999px;
181
+ font-size: 0.75rem;
182
+ font-weight: 500;
183
+ display: inline-block;
184
+ margin-left: 0.5rem;
185
+ vertical-align: top;
186
+ line-height: 1.25;
187
+ white-space: nowrap;
188
+ text-shadow: 0 1px 2px rgba(0,0,0,0.1);
189
+ """,
190
+ cls="uk-text-nowrap",
191
+ )
192
+
193
+ # Use helper to attach badge properly
194
+ return self._attach_metric_badge(element, metric_badge)
195
+
196
+ return element
197
+
198
+ def _attach_metric_badge(self, element: FT, badge: FT) -> FT:
199
+ """
200
+ Attach a metric badge to an element in the most appropriate way.
201
+
202
+ Args:
203
+ element: The element to attach the badge to
204
+ badge: The badge element to attach
205
+
206
+ Returns:
207
+ Element with badge attached
208
+ """
209
+ # Check if element is an inline-capable tag
210
+ tag = str(getattr(element, "tag", "")).lower()
211
+ inline_tags = {"span", "a", "h1", "h2", "h3", "h4", "h5", "h6", "label"}
212
+
213
+ if tag in inline_tags and hasattr(element, "children"):
214
+ # For inline elements, append badge directly to children
215
+ if isinstance(element.children, list):
216
+ element.children.append(badge)
217
+ else:
218
+ # Convert to list if needed
219
+ element.children = list(element.children) + [badge]
220
+ return element
221
+
222
+ # For other elements, wrap in a flex container
223
+ return fh.Div(element, badge, cls="relative inline-flex items-center w-full")
224
+
225
+ def _highlight_input_fields(self, element: FT, metric_entry: MetricEntry) -> FT:
226
+ """
227
+ Find nested form controls and add a colored box-shadow to them
228
+ based on the metric entry color.
229
+
230
+ Args:
231
+ element: The FT element to search within
232
+ metric_entry: The metric entry containing color information
233
+
234
+ Returns:
235
+ The element with highlighted input fields
236
+ """
237
+ if not metric_entry:
238
+ return element
239
+
240
+ # Determine the color to use for highlighting
241
+ color = metric_entry.get("color")
242
+ score = metric_entry.get("metric")
243
+
244
+ if color:
245
+ # Use the provided color
246
+ highlight_color = color
247
+ elif score is not None:
248
+ # Use metric-based color system (background color from the helper)
249
+ highlight_color, _ = get_metric_colors(score)
250
+ else:
251
+ # No color or metric available
252
+ return element
253
+
254
+ # Create the highlight CSS with appropriate opacity for both border and background
255
+ # Use !important to ensure our styles override MonsterUI defaults
256
+ # Focus on border highlighting since background might conflict with MonsterUI styling
257
+ border_rgba = robust_color_to_rgba(highlight_color, 0.8)
258
+ background_rgba = robust_color_to_rgba(highlight_color, 0.1)
259
+ 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;"
260
+
261
+ # Track how many elements we highlight
262
+ highlight_count = 0
263
+
264
+ # Recursively find and style input elements
265
+ def apply_highlight(node):
266
+ """Recursively apply highlighting to input elements"""
267
+ nonlocal highlight_count
268
+
269
+ if _is_form_control(node):
270
+ # Add or update the style attribute
271
+ if hasattr(node, "attrs"):
272
+ existing_style = node.attrs.get("style", "")
273
+ node.attrs["style"] = highlight_css + " " + existing_style
274
+ highlight_count += 1
275
+ logger.debug(
276
+ f"Applied highlight to tag={getattr(node, 'tag', 'unknown')}, attrs={list(node.attrs.keys()) if hasattr(node, 'attrs') else []}"
277
+ )
278
+
279
+ # Process children if they exist
280
+ if hasattr(node, "children") and node.children:
281
+ for child in node.children:
282
+ apply_highlight(child)
283
+
284
+ # Apply highlighting to the element tree
285
+ apply_highlight(element)
286
+
287
+ if highlight_count == 0:
288
+ logger.debug("No form controls found to highlight in element tree")
289
+
290
+ return element
291
+
292
+ def _metric_border_color(
293
+ self, metric_entry: Optional[MetricEntry]
294
+ ) -> Optional[str]:
295
+ """
296
+ Get an RGBA color string for a metric entry's left border bar.
297
+
298
+ Args:
299
+ metric_entry: The metric entry containing color/score information
300
+
301
+ Returns:
302
+ RGBA color string for left border bar, or None if no metric
303
+ """
304
+ if not metric_entry:
305
+ return None
306
+
307
+ # Use provided color if available
308
+ if metric_entry.get("color"):
309
+ return robust_color_to_rgba(metric_entry["color"], 0.8)
310
+
311
+ # Otherwise derive from metric score
312
+ metric = metric_entry.get("metric")
313
+ if metric is not None:
314
+ color, _ = get_metric_colors(metric)
315
+ # If get_metric_colors returns the fallback grey, use our unified light grey
316
+ if color == DEFAULT_METRIC_GREY:
317
+ return DEFAULT_METRIC_GREY
318
+ return robust_color_to_rgba(color, 0.8)
319
+
320
+ # If only a comment is present, return the unified light grey color
321
+ if metric_entry.get("comment"):
322
+ return DEFAULT_METRIC_GREY
323
+
324
+ return None
325
+
326
+
327
+ def _build_path_string_static(path_segments: List[str]) -> str:
328
+ """
329
+ Static version of BaseFieldRenderer._build_path_string for use without instance.
330
+
331
+ Convert field_path list to dot/bracket notation string for metric lookup.
332
+
333
+ Examples:
334
+ ['experience', '0', 'company'] -> 'experience[0].company'
335
+ ['skills', 'programming_languages', '2'] -> 'skills.programming_languages[2]'
336
+
337
+ Args:
338
+ path_segments: List of path segments
339
+
340
+ Returns:
341
+ Path string in dot/bracket notation
342
+ """
343
+ parts: List[str] = []
344
+ for segment in path_segments:
345
+ # Check if segment is numeric or a list index pattern
346
+ if segment.isdigit() or segment.startswith("new_"):
347
+ # Interpret as list index
348
+ if parts:
349
+ parts[-1] += f"[{segment}]"
350
+ else: # Defensive fallback
351
+ parts.append(f"[{segment}]")
352
+ else:
353
+ parts.append(segment)
354
+ return ".".join(parts)
355
+
356
+
357
+ class BaseFieldRenderer(MetricsRendererMixin):
46
358
  """
47
359
  Base class for field renderers
48
360
 
@@ -50,6 +362,7 @@ class BaseFieldRenderer:
50
362
  - Rendering a label for the field
51
363
  - Rendering an appropriate input element for the field
52
364
  - Combining the label and input with proper spacing
365
+ - Optionally applying comparison visual feedback
53
366
 
54
367
  Subclasses must implement render_input()
55
368
  """
@@ -65,6 +378,10 @@ class BaseFieldRenderer:
65
378
  spacing: SpacingValue = SpacingTheme.NORMAL,
66
379
  field_path: Optional[List[str]] = None,
67
380
  form_name: Optional[str] = None,
381
+ metric_entry: Optional[MetricEntry] = None,
382
+ metrics_dict: Optional[MetricsDict] = None,
383
+ refresh_endpoint_override: Optional[str] = None,
384
+ **kwargs, # Accept additional kwargs for extensibility
68
385
  ):
69
386
  """
70
387
  Initialize the field renderer
@@ -79,6 +396,10 @@ class BaseFieldRenderer:
79
396
  spacing: Spacing theme to use for layout ("normal", "compact", or SpacingTheme enum)
80
397
  field_path: Path segments from root to this field (for nested list support)
81
398
  form_name: Explicit form name (used for nested list URLs)
399
+ metric_entry: Optional metric entry for visual feedback
400
+ metrics_dict: Optional full metrics dict for auto-lookup
401
+ refresh_endpoint_override: Optional override URL for refresh actions (used in ComparisonForm)
402
+ **kwargs: Additional keyword arguments for extensibility
82
403
  """
83
404
  self.field_name = f"{prefix}{field_name}" if prefix else field_name
84
405
  self.original_field_name = field_name
@@ -91,6 +412,42 @@ class BaseFieldRenderer:
91
412
  self.disabled = disabled
92
413
  self.label_color = label_color
93
414
  self.spacing = _normalize_spacing(spacing)
415
+ self.metrics_dict = metrics_dict
416
+ self._refresh_endpoint_override = refresh_endpoint_override
417
+
418
+ # Initialize metric entry attribute
419
+ self.metric_entry: Optional[MetricEntry] = None
420
+
421
+ # Auto-resolve metric entry if not explicitly provided
422
+ if metric_entry is not None:
423
+ self.metric_entry = metric_entry
424
+ elif metrics_dict:
425
+ path_string = self._build_path_string()
426
+ self.metric_entry = metrics_dict.get(path_string)
427
+
428
+ def _build_path_string(self) -> str:
429
+ """
430
+ Convert field_path list to dot/bracket notation string for comparison lookup.
431
+
432
+ Examples:
433
+ ['experience', '0', 'company'] -> 'experience[0].company'
434
+ ['skills', 'programming_languages', '2'] -> 'skills.programming_languages[2]'
435
+
436
+ Returns:
437
+ Path string in dot/bracket notation
438
+ """
439
+ parts: List[str] = []
440
+ for segment in self.field_path:
441
+ # Check if segment is numeric or a list index pattern
442
+ if segment.isdigit() or segment.startswith("new_"):
443
+ # Interpret as list index
444
+ if parts:
445
+ parts[-1] += f"[{segment}]"
446
+ else: # Defensive fallback
447
+ parts.append(f"[{segment}]")
448
+ else:
449
+ parts.append(segment)
450
+ return ".".join(parts)
94
451
 
95
452
  def _is_inline_color(self, color: str) -> bool:
96
453
  """
@@ -211,7 +568,7 @@ class BaseFieldRenderer:
211
568
  # 3. Choose layout based on spacing theme
212
569
  if self.spacing == SpacingTheme.COMPACT:
213
570
  # Horizontal layout for compact mode
214
- return fh.Div(
571
+ field_element = fh.Div(
215
572
  fh.Div(
216
573
  label_component,
217
574
  input_component,
@@ -221,12 +578,15 @@ class BaseFieldRenderer:
221
578
  )
222
579
  else:
223
580
  # Vertical layout for normal mode (existing behavior)
224
- return fh.Div(
581
+ field_element = fh.Div(
225
582
  label_component,
226
583
  input_component,
227
584
  cls=spacing("outer_margin", self.spacing),
228
585
  )
229
586
 
587
+ # 4. Apply metrics decoration if available
588
+ return self._decorate_metrics(field_element, self.metric_entry)
589
+
230
590
 
231
591
  # ---- Specific Field Renderers ----
232
592
 
@@ -234,6 +594,10 @@ class BaseFieldRenderer:
234
594
  class StringFieldRenderer(BaseFieldRenderer):
235
595
  """Renderer for string fields"""
236
596
 
597
+ def __init__(self, *args, **kwargs):
598
+ """Initialize string field renderer, passing all arguments to parent"""
599
+ super().__init__(*args, **kwargs)
600
+
237
601
  def render_input(self) -> FT:
238
602
  """
239
603
  Render input element for the field
@@ -281,6 +645,10 @@ class StringFieldRenderer(BaseFieldRenderer):
281
645
  class NumberFieldRenderer(BaseFieldRenderer):
282
646
  """Renderer for number fields (int, float)"""
283
647
 
648
+ def __init__(self, *args, **kwargs):
649
+ """Initialize number field renderer, passing all arguments to parent"""
650
+ super().__init__(*args, **kwargs)
651
+
284
652
  def render_input(self) -> FT:
285
653
  """
286
654
  Render input element for the field
@@ -328,6 +696,10 @@ class NumberFieldRenderer(BaseFieldRenderer):
328
696
  class BooleanFieldRenderer(BaseFieldRenderer):
329
697
  """Renderer for boolean fields"""
330
698
 
699
+ def __init__(self, *args, **kwargs):
700
+ """Initialize boolean field renderer, passing all arguments to parent"""
701
+ super().__init__(*args, **kwargs)
702
+
331
703
  def render_input(self) -> FT:
332
704
  """
333
705
  Render input element for the field
@@ -356,12 +728,14 @@ class BooleanFieldRenderer(BaseFieldRenderer):
356
728
  """
357
729
  # Get the label component
358
730
  label_component = self.render_label()
731
+ # Decorate the label with the metric badge (bullet)
732
+ label_component = self._decorate_label(label_component, self.metric_entry)
359
733
 
360
734
  # Get the checkbox component
361
735
  checkbox_component = self.render_input()
362
736
 
363
737
  # Create a flex container to place label and checkbox side by side
364
- return fh.Div(
738
+ field_element = fh.Div(
365
739
  fh.Div(
366
740
  label_component,
367
741
  checkbox_component,
@@ -370,10 +744,19 @@ class BooleanFieldRenderer(BaseFieldRenderer):
370
744
  cls=f"{spacing('outer_margin', self.spacing)} w-full",
371
745
  )
372
746
 
747
+ # Apply metrics decoration if available (border only, as bullet is in the label)
748
+ return self._decorate_metrics(
749
+ field_element, self.metric_entry, scope=DecorationScope.BORDER
750
+ )
751
+
373
752
 
374
753
  class DateFieldRenderer(BaseFieldRenderer):
375
754
  """Renderer for date fields"""
376
755
 
756
+ def __init__(self, *args, **kwargs):
757
+ """Initialize date field renderer, passing all arguments to parent"""
758
+ super().__init__(*args, **kwargs)
759
+
377
760
  def render_input(self) -> FT:
378
761
  """
379
762
  Render input element for the field
@@ -425,6 +808,10 @@ class DateFieldRenderer(BaseFieldRenderer):
425
808
  class TimeFieldRenderer(BaseFieldRenderer):
426
809
  """Renderer for time fields"""
427
810
 
811
+ def __init__(self, *args, **kwargs):
812
+ """Initialize time field renderer, passing all arguments to parent"""
813
+ super().__init__(*args, **kwargs)
814
+
428
815
  def render_input(self) -> FT:
429
816
  """
430
817
  Render input element for the field
@@ -477,6 +864,10 @@ class TimeFieldRenderer(BaseFieldRenderer):
477
864
  class LiteralFieldRenderer(BaseFieldRenderer):
478
865
  """Renderer for Literal fields as dropdown selects"""
479
866
 
867
+ def __init__(self, *args, **kwargs):
868
+ """Initialize literal field renderer, passing all arguments to parent"""
869
+ super().__init__(*args, **kwargs)
870
+
480
871
  def render_input(self) -> FT:
481
872
  """
482
873
  Render input element for the field as a select dropdown
@@ -550,6 +941,10 @@ class LiteralFieldRenderer(BaseFieldRenderer):
550
941
  class EnumFieldRenderer(BaseFieldRenderer):
551
942
  """Renderer for Enum fields as dropdown selects"""
552
943
 
944
+ def __init__(self, *args, **kwargs):
945
+ """Initialize enum field renderer, passing all arguments to parent"""
946
+ super().__init__(*args, **kwargs)
947
+
553
948
  def render_input(self) -> FT:
554
949
  """
555
950
  Render input element for the field as a select dropdown
@@ -635,6 +1030,10 @@ class EnumFieldRenderer(BaseFieldRenderer):
635
1030
  class BaseModelFieldRenderer(BaseFieldRenderer):
636
1031
  """Renderer for nested Pydantic BaseModel fields"""
637
1032
 
1033
+ def __init__(self, *args, **kwargs):
1034
+ """Initialize base model field renderer, passing all arguments to parent"""
1035
+ super().__init__(*args, **kwargs)
1036
+
638
1037
  def render(self) -> FT:
639
1038
  """
640
1039
  Render the nested BaseModel field as a single-item accordion using mui.Accordion.
@@ -673,6 +1072,19 @@ class BaseModelFieldRenderer(BaseFieldRenderer):
673
1072
  title_component.attrs["uk-tooltip"] = description
674
1073
  title_component.attrs["title"] = description
675
1074
 
1075
+ # Apply metrics decoration to title (bullet only, no border)
1076
+ title_component = self._decorate_metrics(
1077
+ title_component, self.metric_entry, scope=DecorationScope.BULLET
1078
+ )
1079
+
1080
+ # Compute border color for the top-level BaseModel card
1081
+ border_color = self._metric_border_color(self.metric_entry)
1082
+ li_style = {}
1083
+ if border_color:
1084
+ li_style["style"] = (
1085
+ f"border-left: 4px solid {border_color}; padding-left: 0.25rem;"
1086
+ )
1087
+
676
1088
  # 2. Render the nested input fields that will be the accordion content
677
1089
  input_component = self.render_input()
678
1090
 
@@ -685,7 +1097,10 @@ class BaseModelFieldRenderer(BaseFieldRenderer):
685
1097
  title_component, # Title component with proper color styling
686
1098
  input_component, # Content component (the Card with nested fields)
687
1099
  open=True, # Open by default
688
- li_kwargs={"id": item_id}, # Pass the specific ID for the <li>
1100
+ li_kwargs={
1101
+ "id": item_id,
1102
+ **li_style,
1103
+ }, # Pass the specific ID and style for the <li>
689
1104
  cls=spacing(
690
1105
  "outer_margin", self.spacing
691
1106
  ), # Add bottom margin to the <li> element
@@ -703,6 +1118,8 @@ class BaseModelFieldRenderer(BaseFieldRenderer):
703
1118
  cls=f"{accordion_cls} w-full".strip(),
704
1119
  )
705
1120
 
1121
+ # 6. Apply metrics decoration to the title only (bullet), not the container
1122
+ # The parent list renderer handles the border decoration
706
1123
  return accordion_container
707
1124
 
708
1125
  def render_input(self) -> FT:
@@ -786,8 +1203,12 @@ class BaseModelFieldRenderer(BaseFieldRenderer):
786
1203
  prefix=nested_prefix,
787
1204
  disabled=self.disabled, # Propagate disabled state to nested fields
788
1205
  spacing=self.spacing, # Propagate spacing to nested fields
789
- field_path=self.field_path + [nested_field_name], # Propagate path
1206
+ field_path=self.field_path
1207
+ + [nested_field_name], # Propagate path with field name
790
1208
  form_name=self.explicit_form_name, # Propagate form name
1209
+ metric_entry=None, # Let auto-lookup handle it
1210
+ metrics_dict=self.metrics_dict, # Pass down the metrics dict
1211
+ refresh_endpoint_override=self._refresh_endpoint_override, # Propagate refresh override
791
1212
  )
792
1213
 
793
1214
  nested_inputs.append(renderer.render())
@@ -822,6 +1243,56 @@ class BaseModelFieldRenderer(BaseFieldRenderer):
822
1243
  class ListFieldRenderer(BaseFieldRenderer):
823
1244
  """Renderer for list fields containing any type"""
824
1245
 
1246
+ def __init__(
1247
+ self,
1248
+ field_name: str,
1249
+ field_info: FieldInfo,
1250
+ value: Any = None,
1251
+ prefix: str = "",
1252
+ disabled: bool = False,
1253
+ label_color: Optional[str] = None,
1254
+ spacing: SpacingValue = SpacingTheme.NORMAL,
1255
+ field_path: Optional[List[str]] = None,
1256
+ form_name: Optional[str] = None,
1257
+ metric_entry: Optional[MetricEntry] = None,
1258
+ metrics_dict: Optional[MetricsDict] = None,
1259
+ show_item_border: bool = True,
1260
+ **kwargs, # Accept additional kwargs
1261
+ ):
1262
+ """
1263
+ Initialize the list field renderer
1264
+
1265
+ Args:
1266
+ field_name: The name of the field
1267
+ field_info: The FieldInfo for the field
1268
+ value: The current value of the field (optional)
1269
+ prefix: Optional prefix for the field name (used for nested fields)
1270
+ disabled: Whether the field should be rendered as disabled
1271
+ label_color: Optional CSS color value for the field label
1272
+ spacing: Spacing theme to use for layout
1273
+ field_path: Path segments from root to this field
1274
+ form_name: Explicit form name
1275
+ metric_entry: Optional metric entry for visual feedback
1276
+ metrics_dict: Optional full metrics dict for auto-lookup
1277
+ show_item_border: Whether to show colored borders on list items based on metrics
1278
+ **kwargs: Additional keyword arguments passed to parent
1279
+ """
1280
+ super().__init__(
1281
+ field_name=field_name,
1282
+ field_info=field_info,
1283
+ value=value,
1284
+ prefix=prefix,
1285
+ disabled=disabled,
1286
+ label_color=label_color,
1287
+ spacing=spacing,
1288
+ field_path=field_path,
1289
+ form_name=form_name,
1290
+ metric_entry=metric_entry,
1291
+ metrics_dict=metrics_dict,
1292
+ **kwargs, # Pass kwargs to parent
1293
+ )
1294
+ self.show_item_border = show_item_border
1295
+
825
1296
  def _container_id(self) -> str:
826
1297
  """
827
1298
  Return a DOM-unique ID for the list's <ul> / <div> wrapper.
@@ -882,8 +1353,10 @@ class ListFieldRenderer(BaseFieldRenderer):
882
1353
  # form_name = self.prefix.rstrip("_") if self.prefix else None
883
1354
  form_name = self._form_name or None
884
1355
 
885
- # Create the label text with proper color styling
886
- label_text = self.original_field_name.replace("_", " ").title()
1356
+ # Create the label text with proper color styling and item count
1357
+ items = [] if not isinstance(self.value, list) else self.value
1358
+ item_count = len(items)
1359
+ label_text = f"{self.original_field_name.replace('_', ' ').title()} ({item_count} item{'s' if item_count != 1 else ''})"
887
1360
 
888
1361
  # Create the styled label span
889
1362
  if self.label_color:
@@ -913,6 +1386,9 @@ class ListFieldRenderer(BaseFieldRenderer):
913
1386
  label_span.attrs["uk-tooltip"] = description
914
1387
  label_span.attrs["title"] = description
915
1388
 
1389
+ # Decorate the label span with the metric badge (bullet)
1390
+ label_span = self._decorate_label(label_span, self.metric_entry)
1391
+
916
1392
  # Construct the container ID that will be generated by render_input()
917
1393
  container_id = self._container_id()
918
1394
 
@@ -925,13 +1401,23 @@ class ListFieldRenderer(BaseFieldRenderer):
925
1401
  )
926
1402
 
927
1403
  # Create the clickable span wrapper for the icon
1404
+ # Use prefix-based selector to include only fields from this form
1405
+ hx_include_selector = (
1406
+ f"form [name^='{self.prefix}']" if self.prefix else "closest form"
1407
+ )
1408
+
1409
+ # Use override endpoint if provided (for ComparisonForm), otherwise use standard form refresh
1410
+ refresh_url = (
1411
+ self._refresh_endpoint_override or f"/form/{form_name}/refresh"
1412
+ )
1413
+
928
1414
  refresh_icon_trigger = fh.Span(
929
1415
  refresh_icon_component,
930
1416
  cls="ml-1 inline-block align-middle cursor-pointer", # Add margin, ensure inline-like behavior
931
- hx_post=f"/form/{form_name}/refresh",
1417
+ hx_post=refresh_url,
932
1418
  hx_target=f"#{form_name}-inputs-wrapper",
933
1419
  hx_swap="innerHTML",
934
- hx_include="closest form", # key change
1420
+ hx_include=hx_include_selector, # Use prefix-based selector
935
1421
  uk_tooltip="Refresh form display to update list summaries",
936
1422
  # Prevent 'toggleListItems' on the parent from firing
937
1423
  onclick="event.stopPropagation();",
@@ -954,12 +1440,19 @@ class ListFieldRenderer(BaseFieldRenderer):
954
1440
  )
955
1441
 
956
1442
  # Return container with label+icon and input
957
- return fh.Div(
1443
+ field_element = fh.Div(
958
1444
  label_with_icon,
959
1445
  self.render_input(),
960
1446
  cls=f"{spacing('outer_margin', self.spacing)} w-full",
961
1447
  )
962
1448
 
1449
+ # Apply metrics decoration if available
1450
+ return self._decorate_metrics(
1451
+ field_element,
1452
+ self.metric_entry,
1453
+ scope=DecorationScope.BORDER,
1454
+ )
1455
+
963
1456
  def render_input(self) -> FT:
964
1457
  """
965
1458
  Render a list of items with add/delete/move capabilities
@@ -971,6 +1464,7 @@ class ListFieldRenderer(BaseFieldRenderer):
971
1464
  items = [] if not isinstance(self.value, list) else self.value
972
1465
 
973
1466
  annotation = getattr(self.field_info, "annotation", None)
1467
+ item_type = None # Initialize here to avoid UnboundLocalError
974
1468
 
975
1469
  if (
976
1470
  annotation is not None
@@ -1085,6 +1579,13 @@ class ListFieldRenderer(BaseFieldRenderer):
1085
1579
  item_id = f"{self.field_name}_{idx}"
1086
1580
  item_card_id = f"{item_id}_card"
1087
1581
 
1582
+ # Look up metrics for this list item
1583
+ item_path_segments = self.field_path + [str(idx)]
1584
+ path_string = _build_path_string_static(item_path_segments)
1585
+ item_metric_entry: Optional[MetricEntry] = (
1586
+ self.metrics_dict.get(path_string) if self.metrics_dict else None
1587
+ )
1588
+
1088
1589
  # Check if it's a simple type or BaseModel
1089
1590
  is_model = hasattr(item_type, "model_fields")
1090
1591
 
@@ -1109,9 +1610,7 @@ class ListFieldRenderer(BaseFieldRenderer):
1109
1610
 
1110
1611
  if model_for_display is not None:
1111
1612
  # Use the model's __str__ method
1112
- item_summary_text = (
1113
- f"{item_type.__name__}: {str(model_for_display)}"
1114
- )
1613
+ item_summary_text = f"{idx}: {str(model_for_display)}"
1115
1614
  else:
1116
1615
  # Fallback for None or unexpected types
1117
1616
  item_summary_text = f"{item_type.__name__}: (Unknown format: {type(item).__name__})"
@@ -1136,7 +1635,7 @@ class ListFieldRenderer(BaseFieldRenderer):
1136
1635
  )
1137
1636
  item_summary_text = f"{item_type.__name__}: (Error displaying item)"
1138
1637
  else:
1139
- item_summary_text = str(item)
1638
+ item_summary_text = f"{idx}: {str(item)}"
1140
1639
 
1141
1640
  # --- Render item content elements ---
1142
1641
  item_content_elements = []
@@ -1179,6 +1678,13 @@ class ListFieldRenderer(BaseFieldRenderer):
1179
1678
  value=item,
1180
1679
  prefix=self.prefix,
1181
1680
  disabled=self.disabled, # Propagate disabled state
1681
+ spacing=self.spacing, # Propagate spacing
1682
+ field_path=self.field_path
1683
+ + [str(idx)], # Propagate path with index
1684
+ form_name=self.explicit_form_name, # Propagate form name
1685
+ metric_entry=None, # Let auto-lookup handle it
1686
+ metrics_dict=self.metrics_dict, # Pass down the metrics dict
1687
+ refresh_endpoint_override=self._refresh_endpoint_override, # Propagate refresh override
1182
1688
  )
1183
1689
  # Add the rendered input to content elements
1184
1690
  item_content_elements.append(item_renderer.render_input())
@@ -1234,6 +1740,10 @@ class ListFieldRenderer(BaseFieldRenderer):
1234
1740
  str(idx),
1235
1741
  nested_field_name,
1236
1742
  ], # Propagate path with index
1743
+ form_name=self.explicit_form_name, # Propagate form name
1744
+ metric_entry=None, # Let auto-lookup handle it
1745
+ metrics_dict=self.metrics_dict, # Pass down the metrics dict
1746
+ refresh_endpoint_override=self._refresh_endpoint_override, # Propagate refresh override
1237
1747
  )
1238
1748
 
1239
1749
  # Add rendered field to valid fields
@@ -1271,9 +1781,15 @@ class ListFieldRenderer(BaseFieldRenderer):
1271
1781
  spacing=self.spacing, # Propagate spacing
1272
1782
  field_path=self.field_path
1273
1783
  + [str(idx)], # Propagate path with index
1784
+ form_name=self.explicit_form_name, # Propagate form name
1785
+ metric_entry=None, # Let auto-lookup handle it
1786
+ metrics_dict=self.metrics_dict, # Pass down the metrics dict
1787
+ refresh_endpoint_override=self._refresh_endpoint_override, # Propagate refresh override
1274
1788
  )
1275
1789
  input_element = simple_renderer.render_input()
1276
- item_content_elements.append(fh.Div(input_element))
1790
+ wrapper = fh.Div(input_element)
1791
+ # Don't apply metrics decoration here - the card border handles it
1792
+ item_content_elements.append(wrapper)
1277
1793
 
1278
1794
  # --- Create action buttons with form-specific URLs ---
1279
1795
  # Generate HTMX endpoints using hierarchical paths
@@ -1373,8 +1889,23 @@ class ListFieldRenderer(BaseFieldRenderer):
1373
1889
  title_component = fh.Span(
1374
1890
  item_summary_text, cls="text-gray-700 font-medium pl-3"
1375
1891
  )
1892
+
1893
+ # Apply metrics decoration to the title (bullet only)
1894
+ title_component = self._decorate_metrics(
1895
+ title_component, item_metric_entry, scope=DecorationScope.BULLET
1896
+ )
1897
+
1898
+ # Prepare li attributes with optional border styling
1376
1899
  li_attrs = {"id": full_card_id}
1377
1900
 
1901
+ # Add colored border based on metrics if enabled
1902
+ if self.show_item_border and item_metric_entry:
1903
+ border_color = self._metric_border_color(item_metric_entry)
1904
+ if border_color:
1905
+ li_attrs["style"] = (
1906
+ f"border-left: 4px solid {border_color}; padding-left: 0.25rem;"
1907
+ )
1908
+
1378
1909
  # Build card classes using spacing tokens
1379
1910
  card_cls_parts = ["uk-card"]
1380
1911
  if self.spacing == SpacingTheme.NORMAL:
@@ -1400,12 +1931,34 @@ class ListFieldRenderer(BaseFieldRenderer):
1400
1931
 
1401
1932
  except Exception as e:
1402
1933
  # Return error representation
1403
- title_component = f"Error in item {idx}"
1934
+
1935
+ # Still try to get metrics for error items
1936
+ item_path_segments = self.field_path + [str(idx)]
1937
+ path_string = _build_path_string_static(item_path_segments)
1938
+
1939
+ title_component = fh.Span(
1940
+ f"Error in item {idx}", cls="text-red-600 font-medium pl-3"
1941
+ )
1942
+
1943
+ # Apply metrics decoration even to error items (bullet only)
1944
+ title_component = self._decorate_metrics(
1945
+ title_component, item_metric_entry, scope=DecorationScope.BULLET
1946
+ )
1947
+
1404
1948
  content_component = mui.Alert(
1405
1949
  f"Error rendering item {idx}: {str(e)}", cls=mui.AlertT.error
1406
1950
  )
1951
+
1407
1952
  li_attrs = {"id": f"{self.field_name}_{idx}_error_card"}
1408
1953
 
1954
+ # Add colored border for error items too if metrics present
1955
+ if self.show_item_border and item_metric_entry:
1956
+ border_color = self._metric_border_color(item_metric_entry)
1957
+ if border_color:
1958
+ li_attrs["style"] = (
1959
+ f"border-left: 4px solid {border_color}; padding-left: 0.25rem;"
1960
+ )
1961
+
1409
1962
  # Wrap error component in a div with consistent padding
1410
1963
  t = self.spacing
1411
1964
  content_wrapper = fh.Div(content_component, cls=spacing("card_body_pad", t))