fh-pydantic-form 0.2.5__py3-none-any.whl → 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of fh-pydantic-form might be problematic. Click here for more details.
- fh_pydantic_form/__init__.py +16 -4
- fh_pydantic_form/color_utils.py +598 -0
- fh_pydantic_form/comparison_form.py +599 -0
- fh_pydantic_form/field_renderers.py +583 -22
- 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.1.dist-info}/METADATA +359 -6
- fh_pydantic_form-0.3.1.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.1.dist-info}/WHEEL +0 -0
- {fh_pydantic_form-0.2.5.dist-info → fh_pydantic_form-0.3.1.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
|
|
@@ -433,11 +820,19 @@ class TimeFieldRenderer(BaseFieldRenderer):
|
|
|
433
820
|
A TimeInput component appropriate for time values
|
|
434
821
|
"""
|
|
435
822
|
formatted_value = ""
|
|
436
|
-
if (
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
823
|
+
if isinstance(self.value, str):
|
|
824
|
+
# Try to parse the time string using various formats
|
|
825
|
+
time_formats = ["%H:%M", "%H:%M:%S", "%H:%M:%S.%f"]
|
|
826
|
+
|
|
827
|
+
for fmt in time_formats:
|
|
828
|
+
try:
|
|
829
|
+
from datetime import datetime
|
|
830
|
+
|
|
831
|
+
parsed_time = datetime.strptime(self.value, fmt).time()
|
|
832
|
+
formatted_value = parsed_time.strftime("%H:%M")
|
|
833
|
+
break
|
|
834
|
+
except ValueError:
|
|
835
|
+
continue
|
|
441
836
|
elif isinstance(self.value, time):
|
|
442
837
|
formatted_value = self.value.strftime("%H:%M") # HH:MM
|
|
443
838
|
|
|
@@ -477,6 +872,10 @@ class TimeFieldRenderer(BaseFieldRenderer):
|
|
|
477
872
|
class LiteralFieldRenderer(BaseFieldRenderer):
|
|
478
873
|
"""Renderer for Literal fields as dropdown selects"""
|
|
479
874
|
|
|
875
|
+
def __init__(self, *args, **kwargs):
|
|
876
|
+
"""Initialize literal field renderer, passing all arguments to parent"""
|
|
877
|
+
super().__init__(*args, **kwargs)
|
|
878
|
+
|
|
480
879
|
def render_input(self) -> FT:
|
|
481
880
|
"""
|
|
482
881
|
Render input element for the field as a select dropdown
|
|
@@ -550,6 +949,10 @@ class LiteralFieldRenderer(BaseFieldRenderer):
|
|
|
550
949
|
class EnumFieldRenderer(BaseFieldRenderer):
|
|
551
950
|
"""Renderer for Enum fields as dropdown selects"""
|
|
552
951
|
|
|
952
|
+
def __init__(self, *args, **kwargs):
|
|
953
|
+
"""Initialize enum field renderer, passing all arguments to parent"""
|
|
954
|
+
super().__init__(*args, **kwargs)
|
|
955
|
+
|
|
553
956
|
def render_input(self) -> FT:
|
|
554
957
|
"""
|
|
555
958
|
Render input element for the field as a select dropdown
|
|
@@ -635,6 +1038,10 @@ class EnumFieldRenderer(BaseFieldRenderer):
|
|
|
635
1038
|
class BaseModelFieldRenderer(BaseFieldRenderer):
|
|
636
1039
|
"""Renderer for nested Pydantic BaseModel fields"""
|
|
637
1040
|
|
|
1041
|
+
def __init__(self, *args, **kwargs):
|
|
1042
|
+
"""Initialize base model field renderer, passing all arguments to parent"""
|
|
1043
|
+
super().__init__(*args, **kwargs)
|
|
1044
|
+
|
|
638
1045
|
def render(self) -> FT:
|
|
639
1046
|
"""
|
|
640
1047
|
Render the nested BaseModel field as a single-item accordion using mui.Accordion.
|
|
@@ -673,6 +1080,19 @@ class BaseModelFieldRenderer(BaseFieldRenderer):
|
|
|
673
1080
|
title_component.attrs["uk-tooltip"] = description
|
|
674
1081
|
title_component.attrs["title"] = description
|
|
675
1082
|
|
|
1083
|
+
# Apply metrics decoration to title (bullet only, no border)
|
|
1084
|
+
title_component = self._decorate_metrics(
|
|
1085
|
+
title_component, self.metric_entry, scope=DecorationScope.BULLET
|
|
1086
|
+
)
|
|
1087
|
+
|
|
1088
|
+
# Compute border color for the top-level BaseModel card
|
|
1089
|
+
border_color = self._metric_border_color(self.metric_entry)
|
|
1090
|
+
li_style = {}
|
|
1091
|
+
if border_color:
|
|
1092
|
+
li_style["style"] = (
|
|
1093
|
+
f"border-left: 4px solid {border_color}; padding-left: 0.25rem;"
|
|
1094
|
+
)
|
|
1095
|
+
|
|
676
1096
|
# 2. Render the nested input fields that will be the accordion content
|
|
677
1097
|
input_component = self.render_input()
|
|
678
1098
|
|
|
@@ -685,7 +1105,10 @@ class BaseModelFieldRenderer(BaseFieldRenderer):
|
|
|
685
1105
|
title_component, # Title component with proper color styling
|
|
686
1106
|
input_component, # Content component (the Card with nested fields)
|
|
687
1107
|
open=True, # Open by default
|
|
688
|
-
li_kwargs={
|
|
1108
|
+
li_kwargs={
|
|
1109
|
+
"id": item_id,
|
|
1110
|
+
**li_style,
|
|
1111
|
+
}, # Pass the specific ID and style for the <li>
|
|
689
1112
|
cls=spacing(
|
|
690
1113
|
"outer_margin", self.spacing
|
|
691
1114
|
), # Add bottom margin to the <li> element
|
|
@@ -703,6 +1126,8 @@ class BaseModelFieldRenderer(BaseFieldRenderer):
|
|
|
703
1126
|
cls=f"{accordion_cls} w-full".strip(),
|
|
704
1127
|
)
|
|
705
1128
|
|
|
1129
|
+
# 6. Apply metrics decoration to the title only (bullet), not the container
|
|
1130
|
+
# The parent list renderer handles the border decoration
|
|
706
1131
|
return accordion_container
|
|
707
1132
|
|
|
708
1133
|
def render_input(self) -> FT:
|
|
@@ -786,8 +1211,12 @@ class BaseModelFieldRenderer(BaseFieldRenderer):
|
|
|
786
1211
|
prefix=nested_prefix,
|
|
787
1212
|
disabled=self.disabled, # Propagate disabled state to nested fields
|
|
788
1213
|
spacing=self.spacing, # Propagate spacing to nested fields
|
|
789
|
-
field_path=self.field_path
|
|
1214
|
+
field_path=self.field_path
|
|
1215
|
+
+ [nested_field_name], # Propagate path with field name
|
|
790
1216
|
form_name=self.explicit_form_name, # Propagate form name
|
|
1217
|
+
metric_entry=None, # Let auto-lookup handle it
|
|
1218
|
+
metrics_dict=self.metrics_dict, # Pass down the metrics dict
|
|
1219
|
+
refresh_endpoint_override=self._refresh_endpoint_override, # Propagate refresh override
|
|
791
1220
|
)
|
|
792
1221
|
|
|
793
1222
|
nested_inputs.append(renderer.render())
|
|
@@ -822,6 +1251,56 @@ class BaseModelFieldRenderer(BaseFieldRenderer):
|
|
|
822
1251
|
class ListFieldRenderer(BaseFieldRenderer):
|
|
823
1252
|
"""Renderer for list fields containing any type"""
|
|
824
1253
|
|
|
1254
|
+
def __init__(
|
|
1255
|
+
self,
|
|
1256
|
+
field_name: str,
|
|
1257
|
+
field_info: FieldInfo,
|
|
1258
|
+
value: Any = None,
|
|
1259
|
+
prefix: str = "",
|
|
1260
|
+
disabled: bool = False,
|
|
1261
|
+
label_color: Optional[str] = None,
|
|
1262
|
+
spacing: SpacingValue = SpacingTheme.NORMAL,
|
|
1263
|
+
field_path: Optional[List[str]] = None,
|
|
1264
|
+
form_name: Optional[str] = None,
|
|
1265
|
+
metric_entry: Optional[MetricEntry] = None,
|
|
1266
|
+
metrics_dict: Optional[MetricsDict] = None,
|
|
1267
|
+
show_item_border: bool = True,
|
|
1268
|
+
**kwargs, # Accept additional kwargs
|
|
1269
|
+
):
|
|
1270
|
+
"""
|
|
1271
|
+
Initialize the list field renderer
|
|
1272
|
+
|
|
1273
|
+
Args:
|
|
1274
|
+
field_name: The name of the field
|
|
1275
|
+
field_info: The FieldInfo for the field
|
|
1276
|
+
value: The current value of the field (optional)
|
|
1277
|
+
prefix: Optional prefix for the field name (used for nested fields)
|
|
1278
|
+
disabled: Whether the field should be rendered as disabled
|
|
1279
|
+
label_color: Optional CSS color value for the field label
|
|
1280
|
+
spacing: Spacing theme to use for layout
|
|
1281
|
+
field_path: Path segments from root to this field
|
|
1282
|
+
form_name: Explicit form name
|
|
1283
|
+
metric_entry: Optional metric entry for visual feedback
|
|
1284
|
+
metrics_dict: Optional full metrics dict for auto-lookup
|
|
1285
|
+
show_item_border: Whether to show colored borders on list items based on metrics
|
|
1286
|
+
**kwargs: Additional keyword arguments passed to parent
|
|
1287
|
+
"""
|
|
1288
|
+
super().__init__(
|
|
1289
|
+
field_name=field_name,
|
|
1290
|
+
field_info=field_info,
|
|
1291
|
+
value=value,
|
|
1292
|
+
prefix=prefix,
|
|
1293
|
+
disabled=disabled,
|
|
1294
|
+
label_color=label_color,
|
|
1295
|
+
spacing=spacing,
|
|
1296
|
+
field_path=field_path,
|
|
1297
|
+
form_name=form_name,
|
|
1298
|
+
metric_entry=metric_entry,
|
|
1299
|
+
metrics_dict=metrics_dict,
|
|
1300
|
+
**kwargs, # Pass kwargs to parent
|
|
1301
|
+
)
|
|
1302
|
+
self.show_item_border = show_item_border
|
|
1303
|
+
|
|
825
1304
|
def _container_id(self) -> str:
|
|
826
1305
|
"""
|
|
827
1306
|
Return a DOM-unique ID for the list's <ul> / <div> wrapper.
|
|
@@ -882,8 +1361,10 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
882
1361
|
# form_name = self.prefix.rstrip("_") if self.prefix else None
|
|
883
1362
|
form_name = self._form_name or None
|
|
884
1363
|
|
|
885
|
-
# Create the label text with proper color styling
|
|
886
|
-
|
|
1364
|
+
# Create the label text with proper color styling and item count
|
|
1365
|
+
items = [] if not isinstance(self.value, list) else self.value
|
|
1366
|
+
item_count = len(items)
|
|
1367
|
+
label_text = f"{self.original_field_name.replace('_', ' ').title()} ({item_count} item{'s' if item_count != 1 else ''})"
|
|
887
1368
|
|
|
888
1369
|
# Create the styled label span
|
|
889
1370
|
if self.label_color:
|
|
@@ -913,6 +1394,9 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
913
1394
|
label_span.attrs["uk-tooltip"] = description
|
|
914
1395
|
label_span.attrs["title"] = description
|
|
915
1396
|
|
|
1397
|
+
# Decorate the label span with the metric badge (bullet)
|
|
1398
|
+
label_span = self._decorate_label(label_span, self.metric_entry)
|
|
1399
|
+
|
|
916
1400
|
# Construct the container ID that will be generated by render_input()
|
|
917
1401
|
container_id = self._container_id()
|
|
918
1402
|
|
|
@@ -925,13 +1409,23 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
925
1409
|
)
|
|
926
1410
|
|
|
927
1411
|
# Create the clickable span wrapper for the icon
|
|
1412
|
+
# Use prefix-based selector to include only fields from this form
|
|
1413
|
+
hx_include_selector = (
|
|
1414
|
+
f"form [name^='{self.prefix}']" if self.prefix else "closest form"
|
|
1415
|
+
)
|
|
1416
|
+
|
|
1417
|
+
# Use override endpoint if provided (for ComparisonForm), otherwise use standard form refresh
|
|
1418
|
+
refresh_url = (
|
|
1419
|
+
self._refresh_endpoint_override or f"/form/{form_name}/refresh"
|
|
1420
|
+
)
|
|
1421
|
+
|
|
928
1422
|
refresh_icon_trigger = fh.Span(
|
|
929
1423
|
refresh_icon_component,
|
|
930
1424
|
cls="ml-1 inline-block align-middle cursor-pointer", # Add margin, ensure inline-like behavior
|
|
931
|
-
hx_post=
|
|
1425
|
+
hx_post=refresh_url,
|
|
932
1426
|
hx_target=f"#{form_name}-inputs-wrapper",
|
|
933
1427
|
hx_swap="innerHTML",
|
|
934
|
-
hx_include=
|
|
1428
|
+
hx_include=hx_include_selector, # Use prefix-based selector
|
|
935
1429
|
uk_tooltip="Refresh form display to update list summaries",
|
|
936
1430
|
# Prevent 'toggleListItems' on the parent from firing
|
|
937
1431
|
onclick="event.stopPropagation();",
|
|
@@ -954,12 +1448,19 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
954
1448
|
)
|
|
955
1449
|
|
|
956
1450
|
# Return container with label+icon and input
|
|
957
|
-
|
|
1451
|
+
field_element = fh.Div(
|
|
958
1452
|
label_with_icon,
|
|
959
1453
|
self.render_input(),
|
|
960
1454
|
cls=f"{spacing('outer_margin', self.spacing)} w-full",
|
|
961
1455
|
)
|
|
962
1456
|
|
|
1457
|
+
# Apply metrics decoration if available
|
|
1458
|
+
return self._decorate_metrics(
|
|
1459
|
+
field_element,
|
|
1460
|
+
self.metric_entry,
|
|
1461
|
+
scope=DecorationScope.BORDER,
|
|
1462
|
+
)
|
|
1463
|
+
|
|
963
1464
|
def render_input(self) -> FT:
|
|
964
1465
|
"""
|
|
965
1466
|
Render a list of items with add/delete/move capabilities
|
|
@@ -971,6 +1472,7 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
971
1472
|
items = [] if not isinstance(self.value, list) else self.value
|
|
972
1473
|
|
|
973
1474
|
annotation = getattr(self.field_info, "annotation", None)
|
|
1475
|
+
item_type = None # Initialize here to avoid UnboundLocalError
|
|
974
1476
|
|
|
975
1477
|
if (
|
|
976
1478
|
annotation is not None
|
|
@@ -1085,6 +1587,13 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
1085
1587
|
item_id = f"{self.field_name}_{idx}"
|
|
1086
1588
|
item_card_id = f"{item_id}_card"
|
|
1087
1589
|
|
|
1590
|
+
# Look up metrics for this list item
|
|
1591
|
+
item_path_segments = self.field_path + [str(idx)]
|
|
1592
|
+
path_string = _build_path_string_static(item_path_segments)
|
|
1593
|
+
item_metric_entry: Optional[MetricEntry] = (
|
|
1594
|
+
self.metrics_dict.get(path_string) if self.metrics_dict else None
|
|
1595
|
+
)
|
|
1596
|
+
|
|
1088
1597
|
# Check if it's a simple type or BaseModel
|
|
1089
1598
|
is_model = hasattr(item_type, "model_fields")
|
|
1090
1599
|
|
|
@@ -1109,9 +1618,7 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
1109
1618
|
|
|
1110
1619
|
if model_for_display is not None:
|
|
1111
1620
|
# Use the model's __str__ method
|
|
1112
|
-
item_summary_text = (
|
|
1113
|
-
f"{item_type.__name__}: {str(model_for_display)}"
|
|
1114
|
-
)
|
|
1621
|
+
item_summary_text = f"{idx}: {str(model_for_display)}"
|
|
1115
1622
|
else:
|
|
1116
1623
|
# Fallback for None or unexpected types
|
|
1117
1624
|
item_summary_text = f"{item_type.__name__}: (Unknown format: {type(item).__name__})"
|
|
@@ -1136,7 +1643,7 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
1136
1643
|
)
|
|
1137
1644
|
item_summary_text = f"{item_type.__name__}: (Error displaying item)"
|
|
1138
1645
|
else:
|
|
1139
|
-
item_summary_text = str(item)
|
|
1646
|
+
item_summary_text = f"{idx}: {str(item)}"
|
|
1140
1647
|
|
|
1141
1648
|
# --- Render item content elements ---
|
|
1142
1649
|
item_content_elements = []
|
|
@@ -1179,6 +1686,13 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
1179
1686
|
value=item,
|
|
1180
1687
|
prefix=self.prefix,
|
|
1181
1688
|
disabled=self.disabled, # Propagate disabled state
|
|
1689
|
+
spacing=self.spacing, # Propagate spacing
|
|
1690
|
+
field_path=self.field_path
|
|
1691
|
+
+ [str(idx)], # Propagate path with index
|
|
1692
|
+
form_name=self.explicit_form_name, # Propagate form name
|
|
1693
|
+
metric_entry=None, # Let auto-lookup handle it
|
|
1694
|
+
metrics_dict=self.metrics_dict, # Pass down the metrics dict
|
|
1695
|
+
refresh_endpoint_override=self._refresh_endpoint_override, # Propagate refresh override
|
|
1182
1696
|
)
|
|
1183
1697
|
# Add the rendered input to content elements
|
|
1184
1698
|
item_content_elements.append(item_renderer.render_input())
|
|
@@ -1234,6 +1748,10 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
1234
1748
|
str(idx),
|
|
1235
1749
|
nested_field_name,
|
|
1236
1750
|
], # Propagate path with index
|
|
1751
|
+
form_name=self.explicit_form_name, # Propagate form name
|
|
1752
|
+
metric_entry=None, # Let auto-lookup handle it
|
|
1753
|
+
metrics_dict=self.metrics_dict, # Pass down the metrics dict
|
|
1754
|
+
refresh_endpoint_override=self._refresh_endpoint_override, # Propagate refresh override
|
|
1237
1755
|
)
|
|
1238
1756
|
|
|
1239
1757
|
# Add rendered field to valid fields
|
|
@@ -1271,9 +1789,15 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
1271
1789
|
spacing=self.spacing, # Propagate spacing
|
|
1272
1790
|
field_path=self.field_path
|
|
1273
1791
|
+ [str(idx)], # Propagate path with index
|
|
1792
|
+
form_name=self.explicit_form_name, # Propagate form name
|
|
1793
|
+
metric_entry=None, # Let auto-lookup handle it
|
|
1794
|
+
metrics_dict=self.metrics_dict, # Pass down the metrics dict
|
|
1795
|
+
refresh_endpoint_override=self._refresh_endpoint_override, # Propagate refresh override
|
|
1274
1796
|
)
|
|
1275
1797
|
input_element = simple_renderer.render_input()
|
|
1276
|
-
|
|
1798
|
+
wrapper = fh.Div(input_element)
|
|
1799
|
+
# Don't apply metrics decoration here - the card border handles it
|
|
1800
|
+
item_content_elements.append(wrapper)
|
|
1277
1801
|
|
|
1278
1802
|
# --- Create action buttons with form-specific URLs ---
|
|
1279
1803
|
# Generate HTMX endpoints using hierarchical paths
|
|
@@ -1373,8 +1897,23 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
1373
1897
|
title_component = fh.Span(
|
|
1374
1898
|
item_summary_text, cls="text-gray-700 font-medium pl-3"
|
|
1375
1899
|
)
|
|
1900
|
+
|
|
1901
|
+
# Apply metrics decoration to the title (bullet only)
|
|
1902
|
+
title_component = self._decorate_metrics(
|
|
1903
|
+
title_component, item_metric_entry, scope=DecorationScope.BULLET
|
|
1904
|
+
)
|
|
1905
|
+
|
|
1906
|
+
# Prepare li attributes with optional border styling
|
|
1376
1907
|
li_attrs = {"id": full_card_id}
|
|
1377
1908
|
|
|
1909
|
+
# Add colored border based on metrics if enabled
|
|
1910
|
+
if self.show_item_border and item_metric_entry:
|
|
1911
|
+
border_color = self._metric_border_color(item_metric_entry)
|
|
1912
|
+
if border_color:
|
|
1913
|
+
li_attrs["style"] = (
|
|
1914
|
+
f"border-left: 4px solid {border_color}; padding-left: 0.25rem;"
|
|
1915
|
+
)
|
|
1916
|
+
|
|
1378
1917
|
# Build card classes using spacing tokens
|
|
1379
1918
|
card_cls_parts = ["uk-card"]
|
|
1380
1919
|
if self.spacing == SpacingTheme.NORMAL:
|
|
@@ -1400,12 +1939,34 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
1400
1939
|
|
|
1401
1940
|
except Exception as e:
|
|
1402
1941
|
# Return error representation
|
|
1403
|
-
|
|
1942
|
+
|
|
1943
|
+
# Still try to get metrics for error items
|
|
1944
|
+
item_path_segments = self.field_path + [str(idx)]
|
|
1945
|
+
path_string = _build_path_string_static(item_path_segments)
|
|
1946
|
+
|
|
1947
|
+
title_component = fh.Span(
|
|
1948
|
+
f"Error in item {idx}", cls="text-red-600 font-medium pl-3"
|
|
1949
|
+
)
|
|
1950
|
+
|
|
1951
|
+
# Apply metrics decoration even to error items (bullet only)
|
|
1952
|
+
title_component = self._decorate_metrics(
|
|
1953
|
+
title_component, item_metric_entry, scope=DecorationScope.BULLET
|
|
1954
|
+
)
|
|
1955
|
+
|
|
1404
1956
|
content_component = mui.Alert(
|
|
1405
1957
|
f"Error rendering item {idx}: {str(e)}", cls=mui.AlertT.error
|
|
1406
1958
|
)
|
|
1959
|
+
|
|
1407
1960
|
li_attrs = {"id": f"{self.field_name}_{idx}_error_card"}
|
|
1408
1961
|
|
|
1962
|
+
# Add colored border for error items too if metrics present
|
|
1963
|
+
if self.show_item_border and item_metric_entry:
|
|
1964
|
+
border_color = self._metric_border_color(item_metric_entry)
|
|
1965
|
+
if border_color:
|
|
1966
|
+
li_attrs["style"] = (
|
|
1967
|
+
f"border-left: 4px solid {border_color}; padding-left: 0.25rem;"
|
|
1968
|
+
)
|
|
1969
|
+
|
|
1409
1970
|
# Wrap error component in a div with consistent padding
|
|
1410
1971
|
t = self.spacing
|
|
1411
1972
|
content_wrapper = fh.Div(content_component, cls=spacing("card_body_pad", t))
|