fh-pydantic-form 0.3.7__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 +175 -31
- fh_pydantic_form/form_renderer.py +50 -45
- {fh_pydantic_form-0.3.7.dist-info → fh_pydantic_form-0.3.8.dist-info}/METADATA +35 -3
- {fh_pydantic_form-0.3.7.dist-info → fh_pydantic_form-0.3.8.dist-info}/RECORD +7 -7
- {fh_pydantic_form-0.3.7.dist-info → fh_pydantic_form-0.3.8.dist-info}/WHEEL +0 -0
- {fh_pydantic_form-0.3.7.dist-info → fh_pydantic_form-0.3.8.dist-info}/licenses/LICENSE +0 -0
|
@@ -383,6 +383,9 @@ class BaseFieldRenderer(MetricsRendererMixin):
|
|
|
383
383
|
metrics_dict: Optional[MetricsDict] = None,
|
|
384
384
|
refresh_endpoint_override: Optional[str] = None,
|
|
385
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,
|
|
386
389
|
**kwargs, # Accept additional kwargs for extensibility
|
|
387
390
|
):
|
|
388
391
|
"""
|
|
@@ -401,6 +404,9 @@ class BaseFieldRenderer(MetricsRendererMixin):
|
|
|
401
404
|
metric_entry: Optional metric entry for visual feedback
|
|
402
405
|
metrics_dict: Optional full metrics dict for auto-lookup
|
|
403
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)
|
|
404
410
|
**kwargs: Additional keyword arguments for extensibility
|
|
405
411
|
"""
|
|
406
412
|
self.field_name = f"{prefix}{field_name}" if prefix else field_name
|
|
@@ -425,6 +431,9 @@ class BaseFieldRenderer(MetricsRendererMixin):
|
|
|
425
431
|
self.metrics_dict = metrics_dict
|
|
426
432
|
self._refresh_endpoint_override = refresh_endpoint_override
|
|
427
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
|
|
428
437
|
|
|
429
438
|
# Initialize metric entry attribute
|
|
430
439
|
self.metric_entry: Optional[MetricEntry] = None
|
|
@@ -509,6 +518,38 @@ class BaseFieldRenderer(MetricsRendererMixin):
|
|
|
509
518
|
"""
|
|
510
519
|
return f"text-{color}-600"
|
|
511
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
|
+
|
|
512
553
|
def render_label(self) -> FT:
|
|
513
554
|
"""
|
|
514
555
|
Render label for the field
|
|
@@ -579,13 +620,16 @@ class BaseFieldRenderer(MetricsRendererMixin):
|
|
|
579
620
|
Returns:
|
|
580
621
|
A FastHTML component containing the complete field
|
|
581
622
|
"""
|
|
582
|
-
# 1. Get the label component
|
|
623
|
+
# 1. Get the label component (without copy button)
|
|
583
624
|
label_component = self.render_label()
|
|
584
625
|
|
|
585
626
|
# 2. Render the input field
|
|
586
627
|
input_component = self.render_input()
|
|
587
628
|
|
|
588
|
-
# 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
|
|
589
633
|
if self.spacing == SpacingTheme.COMPACT:
|
|
590
634
|
# Horizontal layout for compact mode
|
|
591
635
|
field_element = fh.Div(
|
|
@@ -597,15 +641,25 @@ class BaseFieldRenderer(MetricsRendererMixin):
|
|
|
597
641
|
cls=f"{spacing('outer_margin', self.spacing)} w-full",
|
|
598
642
|
)
|
|
599
643
|
else:
|
|
600
|
-
# Vertical layout for normal mode
|
|
644
|
+
# Vertical layout for normal mode
|
|
601
645
|
field_element = fh.Div(
|
|
602
646
|
label_component,
|
|
603
647
|
input_component,
|
|
604
648
|
cls=spacing("outer_margin", self.spacing),
|
|
605
649
|
)
|
|
606
650
|
|
|
607
|
-
#
|
|
608
|
-
|
|
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
|
|
609
663
|
|
|
610
664
|
|
|
611
665
|
# ---- Specific Field Renderers ----
|
|
@@ -662,6 +716,7 @@ class StringFieldRenderer(BaseFieldRenderer):
|
|
|
662
716
|
"cls": " ".join(input_cls_parts),
|
|
663
717
|
"rows": rows,
|
|
664
718
|
"style": "resize: vertical; min-height: 2.5rem; padding: 0.5rem; line-height: 1.25;",
|
|
719
|
+
"data-field-path": self._build_path_string(),
|
|
665
720
|
}
|
|
666
721
|
|
|
667
722
|
# Only add the disabled attribute if the field should actually be disabled
|
|
@@ -719,6 +774,7 @@ class NumberFieldRenderer(BaseFieldRenderer):
|
|
|
719
774
|
if self.field_info.annotation is float
|
|
720
775
|
or get_origin(self.field_info.annotation) is float
|
|
721
776
|
else "1",
|
|
777
|
+
"data-field-path": self._build_path_string(),
|
|
722
778
|
}
|
|
723
779
|
|
|
724
780
|
# Only add the disabled attribute if the field should actually be disabled
|
|
@@ -779,6 +835,7 @@ class DecimalFieldRenderer(BaseFieldRenderer):
|
|
|
779
835
|
"required": is_field_required,
|
|
780
836
|
"cls": " ".join(input_cls_parts),
|
|
781
837
|
"step": "any", # Allow arbitrary decimal precision
|
|
838
|
+
"data-field-path": self._build_path_string(),
|
|
782
839
|
}
|
|
783
840
|
|
|
784
841
|
# Only add the disabled attribute if the field should actually be disabled
|
|
@@ -806,6 +863,7 @@ class BooleanFieldRenderer(BaseFieldRenderer):
|
|
|
806
863
|
"id": self.field_name,
|
|
807
864
|
"name": self.field_name,
|
|
808
865
|
"checked": bool(self.value),
|
|
866
|
+
"data-field-path": self._build_path_string(),
|
|
809
867
|
}
|
|
810
868
|
|
|
811
869
|
# Only add the disabled attribute if the field should actually be disabled
|
|
@@ -829,6 +887,9 @@ class BooleanFieldRenderer(BaseFieldRenderer):
|
|
|
829
887
|
# Get the checkbox component
|
|
830
888
|
checkbox_component = self.render_input()
|
|
831
889
|
|
|
890
|
+
# Get the copy button if enabled
|
|
891
|
+
copy_button = self._render_comparison_copy_button()
|
|
892
|
+
|
|
832
893
|
# Create a flex container to place label and checkbox side by side
|
|
833
894
|
field_element = fh.Div(
|
|
834
895
|
fh.Div(
|
|
@@ -840,10 +901,20 @@ class BooleanFieldRenderer(BaseFieldRenderer):
|
|
|
840
901
|
)
|
|
841
902
|
|
|
842
903
|
# Apply metrics decoration if available (border only, as bullet is in the label)
|
|
843
|
-
|
|
904
|
+
decorated_field = self._decorate_metrics(
|
|
844
905
|
field_element, self.metric_entry, scope=DecorationScope.BORDER
|
|
845
906
|
)
|
|
846
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
|
+
|
|
847
918
|
|
|
848
919
|
class DateFieldRenderer(BaseFieldRenderer):
|
|
849
920
|
"""Renderer for date fields"""
|
|
@@ -891,6 +962,7 @@ class DateFieldRenderer(BaseFieldRenderer):
|
|
|
891
962
|
"placeholder": placeholder_text,
|
|
892
963
|
"required": is_field_required,
|
|
893
964
|
"cls": " ".join(input_cls_parts),
|
|
965
|
+
"data-field-path": self._build_path_string(),
|
|
894
966
|
}
|
|
895
967
|
|
|
896
968
|
# Only add the disabled attribute if the field should actually be disabled
|
|
@@ -955,6 +1027,7 @@ class TimeFieldRenderer(BaseFieldRenderer):
|
|
|
955
1027
|
"placeholder": placeholder_text,
|
|
956
1028
|
"required": is_field_required,
|
|
957
1029
|
"cls": " ".join(input_cls_parts),
|
|
1030
|
+
"data-field-path": self._build_path_string(),
|
|
958
1031
|
}
|
|
959
1032
|
|
|
960
1033
|
# Only add the disabled attribute if the field should actually be disabled
|
|
@@ -1032,6 +1105,7 @@ class LiteralFieldRenderer(BaseFieldRenderer):
|
|
|
1032
1105
|
"required": is_field_required,
|
|
1033
1106
|
"placeholder": placeholder_text,
|
|
1034
1107
|
"cls": " ".join(select_cls_parts),
|
|
1108
|
+
"data-field-path": self._build_path_string(),
|
|
1035
1109
|
}
|
|
1036
1110
|
|
|
1037
1111
|
if self.disabled:
|
|
@@ -1120,6 +1194,7 @@ class EnumFieldRenderer(BaseFieldRenderer):
|
|
|
1120
1194
|
"w-full",
|
|
1121
1195
|
f"{spacing('input_size', self.spacing)} {spacing('input_padding', self.spacing)}".strip(),
|
|
1122
1196
|
),
|
|
1197
|
+
"data-field-path": self._build_path_string(),
|
|
1123
1198
|
}
|
|
1124
1199
|
|
|
1125
1200
|
# Only add the disabled attribute if the field should actually be disabled
|
|
@@ -1152,34 +1227,45 @@ class BaseModelFieldRenderer(BaseFieldRenderer):
|
|
|
1152
1227
|
if self.label_color:
|
|
1153
1228
|
if self._is_inline_color(self.label_color):
|
|
1154
1229
|
# Color value - apply as inline style
|
|
1155
|
-
|
|
1230
|
+
title_span = fh.Span(
|
|
1156
1231
|
label_text,
|
|
1157
1232
|
style=f"color: {self.label_color};",
|
|
1158
1233
|
cls="text-sm font-medium",
|
|
1159
1234
|
)
|
|
1160
1235
|
else:
|
|
1161
1236
|
# CSS class - apply as Tailwind class (includes emerald, amber, rose, teal, indigo, lime, violet, etc.)
|
|
1162
|
-
|
|
1237
|
+
title_span = fh.Span(
|
|
1163
1238
|
label_text,
|
|
1164
1239
|
cls=f"text-sm font-medium {self._get_color_class(self.label_color)}",
|
|
1165
1240
|
)
|
|
1166
1241
|
else:
|
|
1167
1242
|
# No color specified - use default styling
|
|
1168
|
-
|
|
1169
|
-
label_text, cls="text-sm font-medium text-gray-700"
|
|
1170
|
-
)
|
|
1243
|
+
title_span = fh.Span(label_text, cls="text-sm font-medium text-gray-700")
|
|
1171
1244
|
|
|
1172
1245
|
# Add tooltip if description is available
|
|
1173
1246
|
description = getattr(self.field_info, "description", None)
|
|
1174
1247
|
if description:
|
|
1175
|
-
|
|
1176
|
-
|
|
1248
|
+
title_span.attrs["uk-tooltip"] = description
|
|
1249
|
+
title_span.attrs["title"] = description
|
|
1177
1250
|
|
|
1178
1251
|
# Apply metrics decoration to title (bullet only, no border)
|
|
1179
|
-
|
|
1180
|
-
|
|
1252
|
+
title_with_metrics = self._decorate_metrics(
|
|
1253
|
+
title_span, self.metric_entry, scope=DecorationScope.BULLET
|
|
1181
1254
|
)
|
|
1182
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
|
+
|
|
1183
1269
|
# Compute border color for the top-level BaseModel card
|
|
1184
1270
|
border_color = self._metric_border_color(self.metric_entry)
|
|
1185
1271
|
li_style = {}
|
|
@@ -1321,6 +1407,9 @@ class BaseModelFieldRenderer(BaseFieldRenderer):
|
|
|
1321
1407
|
metrics_dict=self.metrics_dict, # Pass down the metrics dict
|
|
1322
1408
|
refresh_endpoint_override=self._refresh_endpoint_override, # Propagate refresh override
|
|
1323
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,
|
|
1324
1413
|
)
|
|
1325
1414
|
|
|
1326
1415
|
nested_inputs.append(renderer.render())
|
|
@@ -1500,7 +1589,10 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
1500
1589
|
|
|
1501
1590
|
# Metric decoration will be applied to the title_component below
|
|
1502
1591
|
|
|
1503
|
-
#
|
|
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
|
|
1504
1596
|
if form_name and not self.disabled:
|
|
1505
1597
|
# Create the smaller icon component
|
|
1506
1598
|
refresh_icon_component = mui.UkIcon(
|
|
@@ -1527,7 +1619,7 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
1527
1619
|
hx_include="closest form", # Include all form fields from the enclosing form
|
|
1528
1620
|
hx_preserve="scroll",
|
|
1529
1621
|
uk_tooltip="Refresh form display to update list summaries",
|
|
1530
|
-
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;",
|
|
1531
1623
|
**{
|
|
1532
1624
|
"hx-on::before-request": f"window.saveAccordionState && window.saveAccordionState('{container_id}')"
|
|
1533
1625
|
},
|
|
@@ -1535,32 +1627,47 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
1535
1627
|
"hx-on::after-swap": f"window.restoreAccordionState && window.restoreAccordionState('{container_id}')"
|
|
1536
1628
|
},
|
|
1537
1629
|
)
|
|
1630
|
+
action_buttons.append(refresh_icon_trigger)
|
|
1538
1631
|
|
|
1539
|
-
|
|
1540
|
-
|
|
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(
|
|
1541
1636
|
fh.Div(
|
|
1542
1637
|
label_span, # Use the properly styled label span
|
|
1543
1638
|
cls="flex-1", # Take up remaining space
|
|
1544
1639
|
),
|
|
1545
1640
|
fh.Div(
|
|
1546
|
-
|
|
1547
|
-
cls="flex-shrink-0 px-1", # Don't shrink, add horizontal padding
|
|
1548
|
-
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
|
|
1549
1644
|
),
|
|
1550
1645
|
cls="flex items-center",
|
|
1551
1646
|
)
|
|
1552
1647
|
else:
|
|
1553
|
-
# If no
|
|
1554
|
-
|
|
1648
|
+
# If no action buttons, just use the styled label
|
|
1649
|
+
title_base = fh.Div(
|
|
1555
1650
|
label_span, # Use the properly styled label span
|
|
1556
|
-
cls="flex items-center",
|
|
1651
|
+
cls="flex items-center",
|
|
1557
1652
|
)
|
|
1558
1653
|
|
|
1559
1654
|
# Apply metrics decoration to title (bullet only, no border)
|
|
1560
|
-
|
|
1561
|
-
|
|
1655
|
+
title_with_metrics = self._decorate_metrics(
|
|
1656
|
+
title_base, self.metric_entry, scope=DecorationScope.BULLET
|
|
1562
1657
|
)
|
|
1563
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
|
+
|
|
1564
1671
|
# Compute border color for the wrapper accordion
|
|
1565
1672
|
border_color = self._metric_border_color(self.metric_entry)
|
|
1566
1673
|
li_style = {}
|
|
@@ -1875,6 +1982,9 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
1875
1982
|
metrics_dict=self.metrics_dict, # Pass down the metrics dict
|
|
1876
1983
|
refresh_endpoint_override=self._refresh_endpoint_override, # Propagate refresh override
|
|
1877
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,
|
|
1878
1988
|
)
|
|
1879
1989
|
# Add the rendered input to content elements
|
|
1880
1990
|
item_content_elements.append(item_renderer.render_input())
|
|
@@ -1945,6 +2055,9 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
1945
2055
|
metrics_dict=self.metrics_dict, # Pass down the metrics dict
|
|
1946
2056
|
refresh_endpoint_override=self._refresh_endpoint_override, # Propagate refresh override
|
|
1947
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,
|
|
1948
2061
|
)
|
|
1949
2062
|
|
|
1950
2063
|
# Add rendered field to valid fields
|
|
@@ -2087,15 +2200,46 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
2087
2200
|
)
|
|
2088
2201
|
|
|
2089
2202
|
# Return the accordion item
|
|
2090
|
-
|
|
2203
|
+
title_span = fh.Span(
|
|
2091
2204
|
item_summary_text, cls="text-gray-700 font-medium pl-3"
|
|
2092
2205
|
)
|
|
2093
2206
|
|
|
2094
|
-
# Apply metrics decoration to the title (bullet only)
|
|
2095
|
-
|
|
2096
|
-
|
|
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
|
|
2097
2210
|
)
|
|
2098
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
|
+
|
|
2099
2243
|
# Prepare li attributes with optional border styling
|
|
2100
2244
|
li_attrs = {"id": full_card_id}
|
|
2101
2245
|
|
|
@@ -215,18 +215,46 @@ window.restoreAccordionState = function(containerId) {
|
|
|
215
215
|
}
|
|
216
216
|
};
|
|
217
217
|
|
|
218
|
-
// Save all accordion states in the form
|
|
218
|
+
// Save all accordion states in the form (both lists and nested BaseModels)
|
|
219
219
|
window.saveAllAccordionStates = function() {
|
|
220
|
+
// Save list container states
|
|
220
221
|
document.querySelectorAll('[id$="_items_container"]').forEach(container => {
|
|
221
222
|
window.saveAccordionState(container.id);
|
|
222
223
|
});
|
|
224
|
+
|
|
225
|
+
// Save all UIkit accordion item states (nested BaseModels, etc.)
|
|
226
|
+
document.querySelectorAll('.uk-accordion > li').forEach(item => {
|
|
227
|
+
if (item.id) {
|
|
228
|
+
const isOpen = item.classList.contains('uk-open');
|
|
229
|
+
sessionStorage.setItem('accordion_state_' + item.id, isOpen ? 'open' : 'closed');
|
|
230
|
+
}
|
|
231
|
+
});
|
|
223
232
|
};
|
|
224
233
|
|
|
225
|
-
// Restore all accordion states in the form
|
|
234
|
+
// Restore all accordion states in the form (both lists and nested BaseModels)
|
|
226
235
|
window.restoreAllAccordionStates = function() {
|
|
236
|
+
// Restore list container states
|
|
227
237
|
document.querySelectorAll('[id$="_items_container"]').forEach(container => {
|
|
228
238
|
window.restoreAccordionState(container.id);
|
|
229
239
|
});
|
|
240
|
+
|
|
241
|
+
// Use requestAnimationFrame to ensure DOM has fully updated after swap
|
|
242
|
+
requestAnimationFrame(() => {
|
|
243
|
+
setTimeout(() => {
|
|
244
|
+
// Restore ALL UIkit accordion item states in the entire document (not just swapped area)
|
|
245
|
+
document.querySelectorAll('.uk-accordion > li').forEach(item => {
|
|
246
|
+
if (item.id) {
|
|
247
|
+
const savedState = sessionStorage.getItem('accordion_state_' + item.id);
|
|
248
|
+
|
|
249
|
+
if (savedState === 'open' && !item.classList.contains('uk-open')) {
|
|
250
|
+
item.classList.add('uk-open');
|
|
251
|
+
} else if (savedState === 'closed' && item.classList.contains('uk-open')) {
|
|
252
|
+
item.classList.remove('uk-open');
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
}, 150);
|
|
257
|
+
});
|
|
230
258
|
};
|
|
231
259
|
|
|
232
260
|
// Wait for the DOM to be fully loaded before initializing
|
|
@@ -235,8 +263,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
235
263
|
document.querySelectorAll('[id$="_items_container"]').forEach(container => {
|
|
236
264
|
updateMoveButtons(container);
|
|
237
265
|
});
|
|
238
|
-
|
|
239
|
-
//
|
|
266
|
+
|
|
267
|
+
// Attach HTMX event listener to document.body for list operations
|
|
240
268
|
document.body.addEventListener('htmx:afterSwap', function(event) {
|
|
241
269
|
// Check if this is an insert (afterend swap)
|
|
242
270
|
const targetElement = event.detail.target;
|
|
@@ -708,13 +736,14 @@ class PydanticForm(Generic[ModelType]):
|
|
|
708
736
|
|
|
709
737
|
def _inject_missing_defaults(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
710
738
|
"""
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
739
|
+
Ensure missing fields are filled following precedence:
|
|
740
|
+
1) form value (already in `data`)
|
|
741
|
+
2) initial_values
|
|
742
|
+
3) model/default_factory
|
|
743
|
+
4) sensible default (for SkipJsonSchema fields only)
|
|
714
744
|
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
2. model defaults/default_factory
|
|
745
|
+
For required fields without defaults or initial_values, they are left missing
|
|
746
|
+
so that Pydantic validation can properly surface the error.
|
|
718
747
|
|
|
719
748
|
Args:
|
|
720
749
|
data: Dictionary to modify in-place
|
|
@@ -722,43 +751,20 @@ class PydanticForm(Generic[ModelType]):
|
|
|
722
751
|
Returns:
|
|
723
752
|
The same dictionary instance for method chaining
|
|
724
753
|
"""
|
|
725
|
-
# Process ALL model fields, not just excluded ones
|
|
726
754
|
for field_name, field_info in self.model_class.model_fields.items():
|
|
727
|
-
#
|
|
728
|
-
if
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
# Remove any existing value and inject default
|
|
734
|
-
if field_name in data:
|
|
735
|
-
del data[field_name]
|
|
736
|
-
# Fall through to default injection logic below
|
|
737
|
-
elif field_name in data:
|
|
738
|
-
# This is either a kept SkipJsonSchema field or no keep list was specified, keep it
|
|
739
|
-
continue
|
|
740
|
-
# If it's a kept field but not in data, fall through to default injection
|
|
741
|
-
else:
|
|
742
|
-
# Skip if already present in parsed data (normal fields)
|
|
743
|
-
if field_name in data:
|
|
744
|
-
continue
|
|
745
|
-
|
|
746
|
-
# First priority: check if initial_values_dict has this field
|
|
747
|
-
# Use initial values for non-SkipJsonSchema fields, or SkipJsonSchema fields that are kept,
|
|
748
|
-
# or SkipJsonSchema fields when no keep_skip_json_fields list was specified
|
|
749
|
-
if field_name in self.initial_values_dict and (
|
|
750
|
-
not _is_skip_json_schema_field(field_info)
|
|
751
|
-
or not self.keep_skip_json_fields
|
|
752
|
-
or self._is_kept_skip_field([field_name])
|
|
753
|
-
):
|
|
755
|
+
# 1) Respect any value already parsed from the form (top priority)
|
|
756
|
+
if field_name in data:
|
|
757
|
+
continue
|
|
758
|
+
|
|
759
|
+
# 2) Prefer initial_values for ANY missing field (including hidden SkipJsonSchema fields)
|
|
760
|
+
if field_name in self.initial_values_dict:
|
|
754
761
|
initial_val = self.initial_values_dict[field_name]
|
|
755
|
-
# If the initial value is a BaseModel, convert to dict for consistency
|
|
756
762
|
if hasattr(initial_val, "model_dump"):
|
|
757
763
|
initial_val = initial_val.model_dump()
|
|
758
764
|
data[field_name] = initial_val
|
|
759
765
|
continue
|
|
760
766
|
|
|
761
|
-
#
|
|
767
|
+
# 3) Use model/default_factory if available
|
|
762
768
|
default_val = get_default(field_info)
|
|
763
769
|
if default_val is not _UNSET:
|
|
764
770
|
# If the default is a BaseModel, convert to dict for consistency
|
|
@@ -766,12 +772,11 @@ class PydanticForm(Generic[ModelType]):
|
|
|
766
772
|
default_val = default_val.model_dump()
|
|
767
773
|
data[field_name] = default_val
|
|
768
774
|
else:
|
|
769
|
-
#
|
|
775
|
+
# 4) For SkipJsonSchema fields without defaults, provide sensible defaults
|
|
776
|
+
# For regular required fields, leave them missing so validation catches them
|
|
770
777
|
if _is_skip_json_schema_field(field_info):
|
|
771
|
-
|
|
772
|
-
else:
|
|
773
|
-
# No default → leave missing; validation will surface error
|
|
774
|
-
pass
|
|
778
|
+
data[field_name] = default_for_annotation(field_info.annotation)
|
|
779
|
+
# else: leave missing, let validation fail
|
|
775
780
|
|
|
776
781
|
return data
|
|
777
782
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fh-pydantic-form
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.8
|
|
4
4
|
Summary: a library to turn any pydantic BaseModel object into a fasthtml/monsterui input form
|
|
5
5
|
Project-URL: Homepage, https://github.com/Marcura/fh-pydantic-form
|
|
6
6
|
Project-URL: Repository, https://github.com/Marcura/fh-pydantic-form
|
|
@@ -18,9 +18,9 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
18
18
|
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content :: Content Management System
|
|
19
19
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
20
|
Requires-Python: >=3.10
|
|
21
|
-
Requires-Dist: monsterui>=1.0.
|
|
21
|
+
Requires-Dist: monsterui>=1.0.29
|
|
22
22
|
Requires-Dist: pydantic>=2.0
|
|
23
|
-
Requires-Dist: python-fasthtml>=0.12.
|
|
23
|
+
Requires-Dist: python-fasthtml>=0.12.29
|
|
24
24
|
Description-Content-Type: text/markdown
|
|
25
25
|
|
|
26
26
|
# fh-pydantic-form
|
|
@@ -901,6 +901,38 @@ comparison_form.register_routes(app)
|
|
|
901
901
|
- **Metrics Integration**: Right side typically shows LLM output quality scores
|
|
902
902
|
- **Flexible Layout**: Responsive design works on desktop and mobile
|
|
903
903
|
- **Form Validation**: Standard validation works with either form
|
|
904
|
+
- **Intelligent List Copying**: Copy lists between forms with automatic length adjustment
|
|
905
|
+
|
|
906
|
+
### Copying Between Forms
|
|
907
|
+
|
|
908
|
+
The ComparisonForm provides granular copy functionality at multiple levels. When you enable copy buttons (via `copy_left=True` or `copy_right=True`), each field, nested model, and list item gets its own copy button for maximum flexibility:
|
|
909
|
+
|
|
910
|
+
```python
|
|
911
|
+
# Enable copy buttons (copy FROM right TO left)
|
|
912
|
+
comparison_form = ComparisonForm(
|
|
913
|
+
name="extraction_evaluation",
|
|
914
|
+
left_form=left_form,
|
|
915
|
+
right_form=right_form,
|
|
916
|
+
left_label="Ground Truth",
|
|
917
|
+
right_label="LLM Output",
|
|
918
|
+
copy_left=True, # Show copy buttons on right form to copy TO left
|
|
919
|
+
)
|
|
920
|
+
```
|
|
921
|
+
|
|
922
|
+
**Copy Granularity Levels:**
|
|
923
|
+
|
|
924
|
+
The copy feature works at five different levels of granularity:
|
|
925
|
+
|
|
926
|
+
1. **Individual Fields** - Copy a single field value (e.g., `name`, `price`, `status`)
|
|
927
|
+
|
|
928
|
+
2. **Nested BaseModel (Entire Object)** - Copy all fields within a nested model at once
|
|
929
|
+
|
|
930
|
+
3. **Individual Fields in Nested Models** - Copy a specific field within a nested object
|
|
931
|
+
|
|
932
|
+
4. **Full List Fields** - Copy entire lists with automatic length adjustment
|
|
933
|
+
|
|
934
|
+
5. **Individual List Items** - Add a single item from one list to another
|
|
935
|
+
|
|
904
936
|
|
|
905
937
|
### Common Patterns
|
|
906
938
|
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
fh_pydantic_form/__init__.py,sha256=uPN0pwErHoe69tDoCDdIYD_iCslMKRgfYw6pvL4McHE,4608
|
|
2
2
|
fh_pydantic_form/color_utils.py,sha256=M0HSXX0i-lSHkcsgesxw7d3PEAnLsZ46i_STymZAM_k,18271
|
|
3
|
-
fh_pydantic_form/comparison_form.py,sha256=
|
|
3
|
+
fh_pydantic_form/comparison_form.py,sha256=UuA9QOM9V1hsg3oxZrO99Jp4xIi1if7uEuPN9U-SLqg,63793
|
|
4
4
|
fh_pydantic_form/constants.py,sha256=-N9wzkibFNn-V6cO8iWTQ7_xBvwSr2hBdq-m3apmW4M,169
|
|
5
5
|
fh_pydantic_form/defaults.py,sha256=9vV0f4PapTOgqNsIxoW6rEbpYO66O4uiKvpd6hzR1-M,6189
|
|
6
|
-
fh_pydantic_form/field_renderers.py,sha256=
|
|
6
|
+
fh_pydantic_form/field_renderers.py,sha256=SYBXbFTrnZ9qQnEongrMUMaTu0OWKVluiZiA4lK8A3U,92747
|
|
7
7
|
fh_pydantic_form/form_parser.py,sha256=LKeGiCeyYJ0eIZE6oETcxl1DpKjk1MpoCSh_VAvUgGk,29701
|
|
8
|
-
fh_pydantic_form/form_renderer.py,sha256=
|
|
8
|
+
fh_pydantic_form/form_renderer.py,sha256=looOW7nabPeQH3R7t5ZO_QMpBfoQtTO48p0QqZIhpfA,39175
|
|
9
9
|
fh_pydantic_form/list_path.py,sha256=AA8bmDmaYy4rlGIvQOOZ0fP2tgcimNUB2Re5aVGnYc8,5182
|
|
10
10
|
fh_pydantic_form/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
11
|
fh_pydantic_form/registry.py,sha256=b5zIjOpfmCUCs2njgp4PdDu70ioDIAfl49oU-Nf2pg4,4810
|
|
12
12
|
fh_pydantic_form/type_helpers.py,sha256=C6Oem1frgj_T_CqiE-9yyVKI7YpFFEAKAGVXe04OkJU,8245
|
|
13
13
|
fh_pydantic_form/ui_style.py,sha256=UPK5OBwUVVTLnfvQ-yKukz2vbKZaT_GauaNB7OGc-Uw,3848
|
|
14
|
-
fh_pydantic_form-0.3.
|
|
15
|
-
fh_pydantic_form-0.3.
|
|
16
|
-
fh_pydantic_form-0.3.
|
|
17
|
-
fh_pydantic_form-0.3.
|
|
14
|
+
fh_pydantic_form-0.3.8.dist-info/METADATA,sha256=MdCq-9oxeFcKs3FZ6FY7MAQJWF55p-NiyMvJL9Nuu9w,42391
|
|
15
|
+
fh_pydantic_form-0.3.8.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
16
|
+
fh_pydantic_form-0.3.8.dist-info/licenses/LICENSE,sha256=AOi2eNK3D2aDycRHfPRiuACZ7WPBsKHTV2tTYNl7cls,577
|
|
17
|
+
fh_pydantic_form-0.3.8.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|