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