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.

@@ -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. Choose layout based on spacing theme
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 (existing behavior)
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
- # 4. Apply metrics decoration if available
608
- return self._decorate_metrics(field_element, self.metric_entry)
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
- return self._decorate_metrics(
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
- title_component = fh.Span(
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
- title_component = fh.Span(
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
- title_component = fh.Span(
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
- title_component.attrs["uk-tooltip"] = description
1176
- title_component.attrs["title"] = description
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
- title_component = self._decorate_metrics(
1180
- title_component, self.metric_entry, scope=DecorationScope.BULLET
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
- # Only add refresh icon if we have a form name and field is not disabled
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; margin-left: 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
- # Combine label and icon - put refresh icon in a separate div to isolate it
1540
- title_component = fh.Div(
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
- refresh_icon_trigger,
1547
- cls="flex-shrink-0 px-1", # Don't shrink, add horizontal padding for larger click area
1548
- onclick="event.stopPropagation();", # Isolate the refresh icon area
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 form name, just use the styled label
1554
- title_component = fh.Div(
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", # Remove cursor-pointer and click handler
1651
+ cls="flex items-center",
1557
1652
  )
1558
1653
 
1559
1654
  # Apply metrics decoration to title (bullet only, no border)
1560
- title_component = self._decorate_metrics(
1561
- title_component, self.metric_entry, scope=DecorationScope.BULLET
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
- title_component = fh.Span(
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
- title_component = self._decorate_metrics(
2096
- title_component, item_metric_entry, scope=DecorationScope.BULLET
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
- // Now it's safe to attach the HTMX event listener to document.body
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
- Ensures all model fields with defaults are present in data if missing.
712
- Handles excluded fields, SkipJsonSchema fields, and any other fields
713
- not rendered in the form.
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
- Priority order:
716
- 1. initial_values (if provided during form creation)
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
- # Special handling for SkipJsonSchema fields
728
- if _is_skip_json_schema_field(field_info):
729
- # If keep_skip_json_fields was specified and this field is not kept, always use defaults
730
- if self.keep_skip_json_fields and not self._is_kept_skip_field(
731
- [field_name]
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
- # Second priority: use model defaults
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
- # Check if this is a SkipJsonSchema field
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
- pass # Skip fields don't need defaults
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.7
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.19
21
+ Requires-Dist: monsterui>=1.0.29
22
22
  Requires-Dist: pydantic>=2.0
23
- Requires-Dist: python-fasthtml>=0.12.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=Y-OKAjTxMiixBC05SK1ofZ_A3zkvjUFp8tTE-_wUn60,21660
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=DLEWFGneyRktp_f4e4IbD3IAb8P_1odx87eZ2FOfHsk,85746
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=ZTxr6tQrnMwR9cKO0iSvjfGGV8PqPrqMYzCW1fD2X14,39083
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.7.dist-info/METADATA,sha256=l9jXcW7AXC1jBbVKw26f6Z6tpY7SDj9bs7QVgivc7iw,41200
15
- fh_pydantic_form-0.3.7.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
16
- fh_pydantic_form-0.3.7.dist-info/licenses/LICENSE,sha256=AOi2eNK3D2aDycRHfPRiuACZ7WPBsKHTV2tTYNl7cls,577
17
- fh_pydantic_form-0.3.7.dist-info/RECORD,,
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,,