fh-pydantic-form 0.3.6__py3-none-any.whl → 0.3.8__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of fh-pydantic-form might be problematic. Click here for more details.
- fh_pydantic_form/comparison_form.py +994 -5
- fh_pydantic_form/field_renderers.py +242 -55
- fh_pydantic_form/form_parser.py +82 -12
- fh_pydantic_form/form_renderer.py +99 -43
- fh_pydantic_form/type_helpers.py +22 -2
- {fh_pydantic_form-0.3.6.dist-info → fh_pydantic_form-0.3.8.dist-info}/METADATA +114 -4
- {fh_pydantic_form-0.3.6.dist-info → fh_pydantic_form-0.3.8.dist-info}/RECORD +9 -9
- {fh_pydantic_form-0.3.6.dist-info → fh_pydantic_form-0.3.8.dist-info}/WHEEL +0 -0
- {fh_pydantic_form-0.3.6.dist-info → fh_pydantic_form-0.3.8.dist-info}/licenses/LICENSE +0 -0
|
@@ -14,7 +14,7 @@ from typing import (
|
|
|
14
14
|
import fasthtml.common as fh
|
|
15
15
|
import monsterui.all as mui
|
|
16
16
|
from fastcore.xml import FT
|
|
17
|
-
from pydantic import ValidationError
|
|
17
|
+
from pydantic import BaseModel, ValidationError
|
|
18
18
|
from pydantic.fields import FieldInfo
|
|
19
19
|
|
|
20
20
|
from fh_pydantic_form.color_utils import (
|
|
@@ -23,6 +23,7 @@ from fh_pydantic_form.color_utils import (
|
|
|
23
23
|
robust_color_to_rgba,
|
|
24
24
|
)
|
|
25
25
|
from fh_pydantic_form.constants import _UNSET
|
|
26
|
+
from fh_pydantic_form.defaults import default_dict_for_model, default_for_annotation
|
|
26
27
|
from fh_pydantic_form.registry import FieldRendererRegistry
|
|
27
28
|
from fh_pydantic_form.type_helpers import (
|
|
28
29
|
DecorationScope,
|
|
@@ -30,7 +31,9 @@ from fh_pydantic_form.type_helpers import (
|
|
|
30
31
|
MetricsDict,
|
|
31
32
|
_get_underlying_type_if_optional,
|
|
32
33
|
_is_optional_type,
|
|
34
|
+
_is_skip_json_schema_field,
|
|
33
35
|
get_default,
|
|
36
|
+
normalize_path_segments,
|
|
34
37
|
)
|
|
35
38
|
from fh_pydantic_form.ui_style import (
|
|
36
39
|
SpacingTheme,
|
|
@@ -379,6 +382,10 @@ class BaseFieldRenderer(MetricsRendererMixin):
|
|
|
379
382
|
metric_entry: Optional[MetricEntry] = None,
|
|
380
383
|
metrics_dict: Optional[MetricsDict] = None,
|
|
381
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,
|
|
382
389
|
**kwargs, # Accept additional kwargs for extensibility
|
|
383
390
|
):
|
|
384
391
|
"""
|
|
@@ -397,11 +404,22 @@ class BaseFieldRenderer(MetricsRendererMixin):
|
|
|
397
404
|
metric_entry: Optional metric entry for visual feedback
|
|
398
405
|
metrics_dict: Optional full metrics dict for auto-lookup
|
|
399
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)
|
|
400
410
|
**kwargs: Additional keyword arguments for extensibility
|
|
401
411
|
"""
|
|
402
412
|
self.field_name = f"{prefix}{field_name}" if prefix else field_name
|
|
403
413
|
self.original_field_name = field_name
|
|
404
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
|
|
405
423
|
self.value = value
|
|
406
424
|
self.prefix = prefix
|
|
407
425
|
self.field_path: List[str] = field_path or []
|
|
@@ -412,6 +430,10 @@ class BaseFieldRenderer(MetricsRendererMixin):
|
|
|
412
430
|
self.spacing = _normalize_spacing(spacing)
|
|
413
431
|
self.metrics_dict = metrics_dict
|
|
414
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
|
|
415
437
|
|
|
416
438
|
# Initialize metric entry attribute
|
|
417
439
|
self.metric_entry: Optional[MetricEntry] = None
|
|
@@ -447,6 +469,15 @@ class BaseFieldRenderer(MetricsRendererMixin):
|
|
|
447
469
|
parts.append(segment)
|
|
448
470
|
return ".".join(parts)
|
|
449
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
|
+
|
|
450
481
|
def _is_inline_color(self, color: str) -> bool:
|
|
451
482
|
"""
|
|
452
483
|
Determine if a color should be applied as an inline style or CSS class.
|
|
@@ -487,6 +518,38 @@ class BaseFieldRenderer(MetricsRendererMixin):
|
|
|
487
518
|
"""
|
|
488
519
|
return f"text-{color}-600"
|
|
489
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
|
+
|
|
490
553
|
def render_label(self) -> FT:
|
|
491
554
|
"""
|
|
492
555
|
Render label for the field
|
|
@@ -557,13 +620,16 @@ class BaseFieldRenderer(MetricsRendererMixin):
|
|
|
557
620
|
Returns:
|
|
558
621
|
A FastHTML component containing the complete field
|
|
559
622
|
"""
|
|
560
|
-
# 1. Get the label component
|
|
623
|
+
# 1. Get the label component (without copy button)
|
|
561
624
|
label_component = self.render_label()
|
|
562
625
|
|
|
563
626
|
# 2. Render the input field
|
|
564
627
|
input_component = self.render_input()
|
|
565
628
|
|
|
566
|
-
# 3.
|
|
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
|
|
567
633
|
if self.spacing == SpacingTheme.COMPACT:
|
|
568
634
|
# Horizontal layout for compact mode
|
|
569
635
|
field_element = fh.Div(
|
|
@@ -575,15 +641,25 @@ class BaseFieldRenderer(MetricsRendererMixin):
|
|
|
575
641
|
cls=f"{spacing('outer_margin', self.spacing)} w-full",
|
|
576
642
|
)
|
|
577
643
|
else:
|
|
578
|
-
# Vertical layout for normal mode
|
|
644
|
+
# Vertical layout for normal mode
|
|
579
645
|
field_element = fh.Div(
|
|
580
646
|
label_component,
|
|
581
647
|
input_component,
|
|
582
648
|
cls=spacing("outer_margin", self.spacing),
|
|
583
649
|
)
|
|
584
650
|
|
|
585
|
-
#
|
|
586
|
-
|
|
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
|
|
587
663
|
|
|
588
664
|
|
|
589
665
|
# ---- Specific Field Renderers ----
|
|
@@ -640,6 +716,7 @@ class StringFieldRenderer(BaseFieldRenderer):
|
|
|
640
716
|
"cls": " ".join(input_cls_parts),
|
|
641
717
|
"rows": rows,
|
|
642
718
|
"style": "resize: vertical; min-height: 2.5rem; padding: 0.5rem; line-height: 1.25;",
|
|
719
|
+
"data-field-path": self._build_path_string(),
|
|
643
720
|
}
|
|
644
721
|
|
|
645
722
|
# Only add the disabled attribute if the field should actually be disabled
|
|
@@ -697,6 +774,7 @@ class NumberFieldRenderer(BaseFieldRenderer):
|
|
|
697
774
|
if self.field_info.annotation is float
|
|
698
775
|
or get_origin(self.field_info.annotation) is float
|
|
699
776
|
else "1",
|
|
777
|
+
"data-field-path": self._build_path_string(),
|
|
700
778
|
}
|
|
701
779
|
|
|
702
780
|
# Only add the disabled attribute if the field should actually be disabled
|
|
@@ -757,6 +835,7 @@ class DecimalFieldRenderer(BaseFieldRenderer):
|
|
|
757
835
|
"required": is_field_required,
|
|
758
836
|
"cls": " ".join(input_cls_parts),
|
|
759
837
|
"step": "any", # Allow arbitrary decimal precision
|
|
838
|
+
"data-field-path": self._build_path_string(),
|
|
760
839
|
}
|
|
761
840
|
|
|
762
841
|
# Only add the disabled attribute if the field should actually be disabled
|
|
@@ -784,6 +863,7 @@ class BooleanFieldRenderer(BaseFieldRenderer):
|
|
|
784
863
|
"id": self.field_name,
|
|
785
864
|
"name": self.field_name,
|
|
786
865
|
"checked": bool(self.value),
|
|
866
|
+
"data-field-path": self._build_path_string(),
|
|
787
867
|
}
|
|
788
868
|
|
|
789
869
|
# Only add the disabled attribute if the field should actually be disabled
|
|
@@ -807,6 +887,9 @@ class BooleanFieldRenderer(BaseFieldRenderer):
|
|
|
807
887
|
# Get the checkbox component
|
|
808
888
|
checkbox_component = self.render_input()
|
|
809
889
|
|
|
890
|
+
# Get the copy button if enabled
|
|
891
|
+
copy_button = self._render_comparison_copy_button()
|
|
892
|
+
|
|
810
893
|
# Create a flex container to place label and checkbox side by side
|
|
811
894
|
field_element = fh.Div(
|
|
812
895
|
fh.Div(
|
|
@@ -818,10 +901,20 @@ class BooleanFieldRenderer(BaseFieldRenderer):
|
|
|
818
901
|
)
|
|
819
902
|
|
|
820
903
|
# Apply metrics decoration if available (border only, as bullet is in the label)
|
|
821
|
-
|
|
904
|
+
decorated_field = self._decorate_metrics(
|
|
822
905
|
field_element, self.metric_entry, scope=DecorationScope.BORDER
|
|
823
906
|
)
|
|
824
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
|
+
|
|
825
918
|
|
|
826
919
|
class DateFieldRenderer(BaseFieldRenderer):
|
|
827
920
|
"""Renderer for date fields"""
|
|
@@ -869,6 +962,7 @@ class DateFieldRenderer(BaseFieldRenderer):
|
|
|
869
962
|
"placeholder": placeholder_text,
|
|
870
963
|
"required": is_field_required,
|
|
871
964
|
"cls": " ".join(input_cls_parts),
|
|
965
|
+
"data-field-path": self._build_path_string(),
|
|
872
966
|
}
|
|
873
967
|
|
|
874
968
|
# Only add the disabled attribute if the field should actually be disabled
|
|
@@ -933,6 +1027,7 @@ class TimeFieldRenderer(BaseFieldRenderer):
|
|
|
933
1027
|
"placeholder": placeholder_text,
|
|
934
1028
|
"required": is_field_required,
|
|
935
1029
|
"cls": " ".join(input_cls_parts),
|
|
1030
|
+
"data-field-path": self._build_path_string(),
|
|
936
1031
|
}
|
|
937
1032
|
|
|
938
1033
|
# Only add the disabled attribute if the field should actually be disabled
|
|
@@ -1010,6 +1105,7 @@ class LiteralFieldRenderer(BaseFieldRenderer):
|
|
|
1010
1105
|
"required": is_field_required,
|
|
1011
1106
|
"placeholder": placeholder_text,
|
|
1012
1107
|
"cls": " ".join(select_cls_parts),
|
|
1108
|
+
"data-field-path": self._build_path_string(),
|
|
1013
1109
|
}
|
|
1014
1110
|
|
|
1015
1111
|
if self.disabled:
|
|
@@ -1098,6 +1194,7 @@ class EnumFieldRenderer(BaseFieldRenderer):
|
|
|
1098
1194
|
"w-full",
|
|
1099
1195
|
f"{spacing('input_size', self.spacing)} {spacing('input_padding', self.spacing)}".strip(),
|
|
1100
1196
|
),
|
|
1197
|
+
"data-field-path": self._build_path_string(),
|
|
1101
1198
|
}
|
|
1102
1199
|
|
|
1103
1200
|
# Only add the disabled attribute if the field should actually be disabled
|
|
@@ -1130,34 +1227,45 @@ class BaseModelFieldRenderer(BaseFieldRenderer):
|
|
|
1130
1227
|
if self.label_color:
|
|
1131
1228
|
if self._is_inline_color(self.label_color):
|
|
1132
1229
|
# Color value - apply as inline style
|
|
1133
|
-
|
|
1230
|
+
title_span = fh.Span(
|
|
1134
1231
|
label_text,
|
|
1135
1232
|
style=f"color: {self.label_color};",
|
|
1136
1233
|
cls="text-sm font-medium",
|
|
1137
1234
|
)
|
|
1138
1235
|
else:
|
|
1139
1236
|
# CSS class - apply as Tailwind class (includes emerald, amber, rose, teal, indigo, lime, violet, etc.)
|
|
1140
|
-
|
|
1237
|
+
title_span = fh.Span(
|
|
1141
1238
|
label_text,
|
|
1142
1239
|
cls=f"text-sm font-medium {self._get_color_class(self.label_color)}",
|
|
1143
1240
|
)
|
|
1144
1241
|
else:
|
|
1145
1242
|
# No color specified - use default styling
|
|
1146
|
-
|
|
1147
|
-
label_text, cls="text-sm font-medium text-gray-700"
|
|
1148
|
-
)
|
|
1243
|
+
title_span = fh.Span(label_text, cls="text-sm font-medium text-gray-700")
|
|
1149
1244
|
|
|
1150
1245
|
# Add tooltip if description is available
|
|
1151
1246
|
description = getattr(self.field_info, "description", None)
|
|
1152
1247
|
if description:
|
|
1153
|
-
|
|
1154
|
-
|
|
1248
|
+
title_span.attrs["uk-tooltip"] = description
|
|
1249
|
+
title_span.attrs["title"] = description
|
|
1155
1250
|
|
|
1156
1251
|
# Apply metrics decoration to title (bullet only, no border)
|
|
1157
|
-
|
|
1158
|
-
|
|
1252
|
+
title_with_metrics = self._decorate_metrics(
|
|
1253
|
+
title_span, self.metric_entry, scope=DecorationScope.BULLET
|
|
1159
1254
|
)
|
|
1160
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
|
+
|
|
1161
1269
|
# Compute border color for the top-level BaseModel card
|
|
1162
1270
|
border_color = self._metric_border_color(self.metric_entry)
|
|
1163
1271
|
li_style = {}
|
|
@@ -1249,18 +1357,26 @@ class BaseModelFieldRenderer(BaseFieldRenderer):
|
|
|
1249
1357
|
|
|
1250
1358
|
# Only use defaults if field wasn't provided
|
|
1251
1359
|
if not field_was_provided:
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
nested_field_value =
|
|
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
|
|
1264
1380
|
|
|
1265
1381
|
# Get renderer for this nested field
|
|
1266
1382
|
registry = FieldRendererRegistry() # Get singleton instance
|
|
@@ -1290,6 +1406,10 @@ class BaseModelFieldRenderer(BaseFieldRenderer):
|
|
|
1290
1406
|
metric_entry=None, # Let auto-lookup handle it
|
|
1291
1407
|
metrics_dict=self.metrics_dict, # Pass down the metrics dict
|
|
1292
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,
|
|
1293
1413
|
)
|
|
1294
1414
|
|
|
1295
1415
|
nested_inputs.append(renderer.render())
|
|
@@ -1469,7 +1589,10 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
1469
1589
|
|
|
1470
1590
|
# Metric decoration will be applied to the title_component below
|
|
1471
1591
|
|
|
1472
|
-
#
|
|
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
|
|
1473
1596
|
if form_name and not self.disabled:
|
|
1474
1597
|
# Create the smaller icon component
|
|
1475
1598
|
refresh_icon_component = mui.UkIcon(
|
|
@@ -1496,7 +1619,7 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
1496
1619
|
hx_include="closest form", # Include all form fields from the enclosing form
|
|
1497
1620
|
hx_preserve="scroll",
|
|
1498
1621
|
uk_tooltip="Refresh form display to update list summaries",
|
|
1499
|
-
style="all: unset; display: inline-flex; align-items: center; cursor: pointer; padding: 0 0.5rem;
|
|
1622
|
+
style="all: unset; display: inline-flex; align-items: center; cursor: pointer; padding: 0 0.5rem;",
|
|
1500
1623
|
**{
|
|
1501
1624
|
"hx-on::before-request": f"window.saveAccordionState && window.saveAccordionState('{container_id}')"
|
|
1502
1625
|
},
|
|
@@ -1504,32 +1627,47 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
1504
1627
|
"hx-on::after-swap": f"window.restoreAccordionState && window.restoreAccordionState('{container_id}')"
|
|
1505
1628
|
},
|
|
1506
1629
|
)
|
|
1630
|
+
action_buttons.append(refresh_icon_trigger)
|
|
1507
1631
|
|
|
1508
|
-
|
|
1509
|
-
|
|
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(
|
|
1510
1636
|
fh.Div(
|
|
1511
1637
|
label_span, # Use the properly styled label span
|
|
1512
1638
|
cls="flex-1", # Take up remaining space
|
|
1513
1639
|
),
|
|
1514
1640
|
fh.Div(
|
|
1515
|
-
|
|
1516
|
-
cls="flex-shrink-0 px-1", # Don't shrink, add horizontal padding
|
|
1517
|
-
onclick="event.stopPropagation();", # Isolate the
|
|
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
|
|
1518
1644
|
),
|
|
1519
1645
|
cls="flex items-center",
|
|
1520
1646
|
)
|
|
1521
1647
|
else:
|
|
1522
|
-
# If no
|
|
1523
|
-
|
|
1648
|
+
# If no action buttons, just use the styled label
|
|
1649
|
+
title_base = fh.Div(
|
|
1524
1650
|
label_span, # Use the properly styled label span
|
|
1525
|
-
cls="flex items-center",
|
|
1651
|
+
cls="flex items-center",
|
|
1526
1652
|
)
|
|
1527
1653
|
|
|
1528
1654
|
# Apply metrics decoration to title (bullet only, no border)
|
|
1529
|
-
|
|
1530
|
-
|
|
1655
|
+
title_with_metrics = self._decorate_metrics(
|
|
1656
|
+
title_base, self.metric_entry, scope=DecorationScope.BULLET
|
|
1531
1657
|
)
|
|
1532
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
|
+
|
|
1533
1671
|
# Compute border color for the wrapper accordion
|
|
1534
1672
|
border_color = self._metric_border_color(self.metric_entry)
|
|
1535
1673
|
li_style = {}
|
|
@@ -1843,6 +1981,10 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
1843
1981
|
metric_entry=None, # Let auto-lookup handle it
|
|
1844
1982
|
metrics_dict=self.metrics_dict, # Pass down the metrics dict
|
|
1845
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,
|
|
1846
1988
|
)
|
|
1847
1989
|
# Add the rendered input to content elements
|
|
1848
1990
|
item_content_elements.append(item_renderer.render_input())
|
|
@@ -1866,18 +2008,28 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
1866
2008
|
|
|
1867
2009
|
# Use defaults only if field not provided
|
|
1868
2010
|
if not field_was_provided:
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
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
|
|
1878
2022
|
)
|
|
1879
|
-
|
|
1880
|
-
|
|
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
|
|
1881
2033
|
|
|
1882
2034
|
# Get renderer and render field with error handling
|
|
1883
2035
|
renderer_cls = FieldRendererRegistry().get_renderer(
|
|
@@ -1902,6 +2054,10 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
1902
2054
|
metric_entry=None, # Let auto-lookup handle it
|
|
1903
2055
|
metrics_dict=self.metrics_dict, # Pass down the metrics dict
|
|
1904
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,
|
|
1905
2061
|
)
|
|
1906
2062
|
|
|
1907
2063
|
# Add rendered field to valid fields
|
|
@@ -2044,15 +2200,46 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
2044
2200
|
)
|
|
2045
2201
|
|
|
2046
2202
|
# Return the accordion item
|
|
2047
|
-
|
|
2203
|
+
title_span = fh.Span(
|
|
2048
2204
|
item_summary_text, cls="text-gray-700 font-medium pl-3"
|
|
2049
2205
|
)
|
|
2050
2206
|
|
|
2051
|
-
# Apply metrics decoration to the title (bullet only)
|
|
2052
|
-
|
|
2053
|
-
|
|
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
|
|
2054
2210
|
)
|
|
2055
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
|
+
|
|
2056
2243
|
# Prepare li attributes with optional border styling
|
|
2057
2244
|
li_attrs = {"id": full_card_id}
|
|
2058
2245
|
|