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.
- fh_pydantic_form/__init__.py +16 -4
- fh_pydantic_form/color_utils.py +598 -0
- fh_pydantic_form/comparison_form.py +599 -0
- fh_pydantic_form/field_renderers.py +570 -17
- fh_pydantic_form/form_renderer.py +111 -46
- fh_pydantic_form/type_helpers.py +37 -6
- fh_pydantic_form/ui_style.py +2 -0
- {fh_pydantic_form-0.2.5.dist-info → fh_pydantic_form-0.3.0.dist-info}/METADATA +359 -6
- fh_pydantic_form-0.3.0.dist-info/RECORD +17 -0
- fh_pydantic_form-0.2.5.dist-info/RECORD +0 -15
- {fh_pydantic_form-0.2.5.dist-info → fh_pydantic_form-0.3.0.dist-info}/WHEEL +0 -0
- {fh_pydantic_form-0.2.5.dist-info → fh_pydantic_form-0.3.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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={
|
|
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
|
|
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
|
-
|
|
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=
|
|
1417
|
+
hx_post=refresh_url,
|
|
932
1418
|
hx_target=f"#{form_name}-inputs-wrapper",
|
|
933
1419
|
hx_swap="innerHTML",
|
|
934
|
-
hx_include=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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))
|