fh-pydantic-form 0.1.3__py3-none-any.whl → 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of fh-pydantic-form might be problematic. Click here for more details.

@@ -9,6 +9,7 @@ from typing import (
9
9
  Tuple,
10
10
  Type,
11
11
  TypeVar,
12
+ Union,
12
13
  )
13
14
 
14
15
  import fasthtml.common as fh
@@ -16,6 +17,7 @@ import monsterui.all as mui
16
17
  from fastcore.xml import FT
17
18
  from pydantic import BaseModel
18
19
 
20
+ from fh_pydantic_form.defaults import default_dict_for_model, default_for_annotation
19
21
  from fh_pydantic_form.field_renderers import (
20
22
  BaseFieldRenderer,
21
23
  ListFieldRenderer,
@@ -26,7 +28,16 @@ from fh_pydantic_form.form_parser import (
26
28
  _parse_list_fields,
27
29
  _parse_non_list_fields,
28
30
  )
31
+ from fh_pydantic_form.list_path import walk_path
29
32
  from fh_pydantic_form.registry import FieldRendererRegistry
33
+ from fh_pydantic_form.type_helpers import _UNSET, get_default
34
+ from fh_pydantic_form.ui_style import (
35
+ COMPACT_EXTRA_CSS,
36
+ SpacingTheme,
37
+ SpacingValue,
38
+ _normalize_spacing,
39
+ spacing,
40
+ )
30
41
 
31
42
  logger = logging.getLogger(__name__)
32
43
 
@@ -181,7 +192,11 @@ document.addEventListener('DOMContentLoaded', () => {
181
192
 
182
193
  class PydanticForm(Generic[ModelType]):
183
194
  """
184
- Renders a form from a Pydantic model class
195
+ Renders a form from a Pydantic model class with robust schema drift handling
196
+
197
+ Accepts initial values as either BaseModel instances or dictionaries.
198
+ Gracefully handles missing fields and schema mismatches by rendering
199
+ available fields and skipping problematic ones.
185
200
 
186
201
  This class handles:
187
202
  - Finding appropriate renderers for each field
@@ -194,15 +209,68 @@ class PydanticForm(Generic[ModelType]):
194
209
  - validating request data against the model
195
210
  """
196
211
 
212
+ # --- module-level flag (add near top of file) ---
213
+
214
+ def _compact_wrapper(self, inner: FT) -> FT:
215
+ """
216
+ Wrap inner markup in a namespaced div.
217
+ Auto-inject the compact CSS the *first* time any compact form is rendered.
218
+ """
219
+ wrapper_cls = "fhpf-wrapper w-full flex-1"
220
+
221
+ if self.spacing != SpacingTheme.COMPACT:
222
+ return fh.Div(inner, cls=wrapper_cls)
223
+
224
+ return fh.Div(
225
+ COMPACT_EXTRA_CSS,
226
+ fh.Div(inner, cls="fhpf-fields fhpf-compact"),
227
+ cls=wrapper_cls,
228
+ )
229
+
230
+ def _clone_with_values(self, values: Dict[str, Any]) -> "PydanticForm":
231
+ """
232
+ Create a copy of this renderer with the same configuration but different values.
233
+
234
+ This preserves all constructor arguments (label_colors, custom_renderers, etc.)
235
+ to avoid configuration drift during refresh operations.
236
+
237
+ Args:
238
+ values: New values dictionary to use in the cloned renderer
239
+
240
+ Returns:
241
+ A new PydanticForm instance with identical configuration but updated values
242
+ """
243
+ # Get custom renderers if they were registered (not stored directly on instance)
244
+ # We'll rely on global registry state being preserved
245
+
246
+ clone = PydanticForm(
247
+ form_name=self.name,
248
+ model_class=self.model_class,
249
+ initial_values=None, # Will be set via values_dict below
250
+ custom_renderers=None, # Registry is global, no need to re-register
251
+ disabled=self.disabled,
252
+ disabled_fields=self.disabled_fields,
253
+ label_colors=self.label_colors,
254
+ exclude_fields=self.exclude_fields,
255
+ spacing=self.spacing,
256
+ )
257
+
258
+ # Set the values directly
259
+ clone.values_dict = values
260
+
261
+ return clone
262
+
197
263
  def __init__(
198
264
  self,
199
265
  form_name: str,
200
266
  model_class: Type[ModelType],
201
- initial_values: Optional[ModelType] = None,
267
+ initial_values: Optional[Union[ModelType, Dict[str, Any]]] = None,
202
268
  custom_renderers: Optional[List[Tuple[Type, Type[BaseFieldRenderer]]]] = None,
203
269
  disabled: bool = False,
204
270
  disabled_fields: Optional[List[str]] = None,
205
271
  label_colors: Optional[Dict[str, str]] = None,
272
+ exclude_fields: Optional[List[str]] = None,
273
+ spacing: SpacingValue = SpacingTheme.NORMAL,
206
274
  ):
207
275
  """
208
276
  Initialize the form renderer
@@ -210,22 +278,56 @@ class PydanticForm(Generic[ModelType]):
210
278
  Args:
211
279
  form_name: Unique name for this form
212
280
  model_class: The Pydantic model class to render
213
- initial_values: Optional initial Pydantic model instance
281
+ initial_values: Initial values as BaseModel instance or dict.
282
+ Missing fields will not be auto-filled with defaults.
283
+ Supports robust handling of schema drift.
214
284
  custom_renderers: Optional list of tuples (field_type, renderer_cls) to register
215
285
  disabled: Whether all form inputs should be disabled
216
286
  disabled_fields: Optional list of top-level field names to disable specifically
217
287
  label_colors: Optional dictionary mapping field names to label colors (CSS color values)
288
+ exclude_fields: Optional list of top-level field names to exclude from the form
289
+ spacing: Spacing theme to use for form layout ("normal", "compact", or SpacingTheme enum)
218
290
  """
219
291
  self.name = form_name
220
292
  self.model_class = model_class
221
- self.initial_data_model = initial_values # Store original model for fallback
222
- self.values_dict = initial_values.model_dump() if initial_values else {}
293
+
294
+ self.initial_values_dict: Dict[str, Any] = {}
295
+
296
+ # Store initial values as dict for robustness to schema drift
297
+ if initial_values is None:
298
+ self.initial_values_dict = {}
299
+ elif isinstance(initial_values, dict):
300
+ self.initial_values_dict = initial_values.copy()
301
+ elif hasattr(initial_values, "model_dump"):
302
+ self.initial_values_dict = initial_values.model_dump()
303
+ else:
304
+ # Fallback - attempt dict conversion
305
+ try:
306
+ temp_dict = dict(initial_values)
307
+ model_field_names = set(self.model_class.model_fields.keys())
308
+ # Only accept if all keys are in the model's field names
309
+ if not isinstance(temp_dict, dict) or not set(
310
+ temp_dict.keys()
311
+ ).issubset(model_field_names):
312
+ raise ValueError("Converted to dict with keys not in model fields")
313
+ self.initial_values_dict = temp_dict
314
+ except (TypeError, ValueError):
315
+ logger.warning(
316
+ "Could not convert initial_values to dict, using empty dict"
317
+ )
318
+ self.initial_values_dict = {}
319
+
320
+ # Use copy for rendering to avoid mutations
321
+ self.values_dict: Dict[str, Any] = self.initial_values_dict.copy()
322
+
223
323
  self.base_prefix = f"{form_name}_"
224
324
  self.disabled = disabled
225
325
  self.disabled_fields = (
226
326
  disabled_fields or []
227
327
  ) # Store as list for easier checking
228
328
  self.label_colors = label_colors or {} # Store label colors mapping
329
+ self.exclude_fields = exclude_fields or [] # Store excluded fields list
330
+ self.spacing = _normalize_spacing(spacing) # Store normalized spacing
229
331
 
230
332
  # Register custom renderers with the global registry if provided
231
333
  if custom_renderers:
@@ -247,11 +349,22 @@ class PydanticForm(Generic[ModelType]):
247
349
  )
248
350
 
249
351
  for field_name, field_info in self.model_class.model_fields.items():
250
- # Determine initial value
352
+ # Skip excluded fields
353
+ if field_name in self.exclude_fields:
354
+ logger.debug(f"Skipping excluded field: {field_name}")
355
+ continue
356
+
357
+ # Only use what was explicitly provided in initial values
251
358
  initial_value = (
252
359
  self.values_dict.get(field_name) if self.values_dict else None
253
360
  )
254
361
 
362
+ # Only use model defaults if field was not provided at all
363
+ # (not if it was provided as None/empty)
364
+ field_was_provided = (
365
+ field_name in self.values_dict if self.values_dict else False
366
+ )
367
+
255
368
  # Log the initial value type and a summary for debugging
256
369
  if initial_value is not None:
257
370
  value_type = type(initial_value).__name__
@@ -259,26 +372,39 @@ class PydanticForm(Generic[ModelType]):
259
372
  value_size = f"size={len(initial_value)}"
260
373
  else:
261
374
  value_size = ""
262
- logger.debug(f"Field '{field_name}': {value_type} {value_size}")
375
+ logger.debug(
376
+ f"Field '{field_name}': {value_type} {value_size} (provided: {field_was_provided})"
377
+ )
263
378
  else:
264
379
  logger.debug(
265
- f"Field '{field_name}': None (will use default if available)"
380
+ f"Field '{field_name}': None (provided: {field_was_provided})"
266
381
  )
267
382
 
268
- # Use default if no value is provided
269
- if initial_value is None:
383
+ # Only use defaults if field was not provided at all
384
+ if not field_was_provided:
385
+ # Field not provided - use model defaults
270
386
  if field_info.default is not None:
271
387
  initial_value = field_info.default
272
388
  logger.debug(f" - Using default value for '{field_name}'")
273
389
  elif getattr(field_info, "default_factory", None) is not None:
274
390
  try:
275
- initial_value = field_info.default_factory()
276
- logger.debug(f" - Using default_factory for '{field_name}'")
391
+ default_factory = field_info.default_factory
392
+ if callable(default_factory):
393
+ initial_value = default_factory()
394
+ logger.debug(
395
+ f" - Using default_factory for '{field_name}'"
396
+ )
397
+ else:
398
+ initial_value = None
399
+ logger.warning(
400
+ f" - default_factory for '{field_name}' is not callable"
401
+ )
277
402
  except Exception as e:
278
403
  initial_value = None
279
404
  logger.warning(
280
405
  f" - Error in default_factory for '{field_name}': {e}"
281
406
  )
407
+ # If field was provided (even as None), respect that value
282
408
 
283
409
  # Get renderer from global registry
284
410
  renderer_cls = registry.get_renderer(field_name, field_info)
@@ -307,21 +433,29 @@ class PydanticForm(Generic[ModelType]):
307
433
  prefix=self.base_prefix,
308
434
  disabled=is_field_disabled, # Pass the calculated disabled state
309
435
  label_color=label_color, # Pass the label color if specified
436
+ spacing=self.spacing, # Pass the spacing
437
+ field_path=[field_name], # Set top-level field path
310
438
  )
311
439
 
312
440
  rendered_field = renderer.render()
313
441
  form_inputs.append(rendered_field)
314
442
 
315
443
  # Create container for inputs, ensuring items stretch to full width
316
- inputs_container = mui.DivVStacked(*form_inputs, cls="space-y-3 items-stretch")
444
+ inputs_container = mui.DivVStacked(
445
+ *form_inputs,
446
+ cls=f"{spacing('stack_gap', self.spacing)} items-stretch",
447
+ )
317
448
 
318
449
  # Define the ID for the wrapper div - this is what the HTMX request targets
319
450
  form_content_wrapper_id = f"{self.name}-inputs-wrapper"
320
451
  logger.debug(f"Creating form inputs wrapper with ID: {form_content_wrapper_id}")
321
452
 
322
- # Return only the inner container without the wrapper div
323
- # The wrapper will be added by the main route handler instead
324
- return fh.Div(inputs_container, id=form_content_wrapper_id)
453
+ # Create the wrapper div and apply compact styling if needed
454
+ wrapped = self._compact_wrapper(
455
+ fh.Div(inputs_container, id=form_content_wrapper_id)
456
+ )
457
+
458
+ return wrapped
325
459
 
326
460
  # ---- Form Renderer Methods (continued) ----
327
461
 
@@ -351,23 +485,17 @@ class PydanticForm(Generic[ModelType]):
351
485
  f"Error parsing form data for refresh on form '{self.name}': {e}",
352
486
  exc_info=True,
353
487
  )
354
- # Fallback: Use original initial data model dump if available, otherwise empty dict
488
+ # Fallback: Use original initial values dict if available, otherwise empty dict
355
489
  parsed_data = (
356
- self.initial_data_model.model_dump() if self.initial_data_model else {}
490
+ self.initial_values_dict.copy() if self.initial_values_dict else {}
357
491
  )
358
492
  alert_ft = mui.Alert(
359
493
  f"Warning: Could not fully process current form values for refresh. Display might not be fully updated. Error: {str(e)}",
360
494
  cls=mui.AlertT.warning + " mb-4", # Add margin bottom
361
495
  )
362
496
 
363
- # Create Temporary Renderer instance
364
- temp_renderer = PydanticForm(
365
- form_name=self.name,
366
- model_class=self.model_class,
367
- # No initial_data needed here, we set values_dict below
368
- )
369
- # Set the values based on the parsed (or fallback) data
370
- temp_renderer.values_dict = parsed_data
497
+ # Create temporary renderer with same configuration but updated values
498
+ temp_renderer = self._clone_with_values(parsed_data)
371
499
 
372
500
  refreshed_inputs_component = temp_renderer.render_inputs()
373
501
 
@@ -395,27 +523,28 @@ class PydanticForm(Generic[ModelType]):
395
523
  Returns:
396
524
  HTML response with reset form inputs
397
525
  """
398
- logger.info(
399
- f"Resetting form '{self.name}' to initial values. Initial model: {self.initial_data_model}"
400
- )
526
+ logger.info(f"Resetting form '{self.name}' to initial values")
401
527
 
402
- # Create a temporary renderer with the original initial data
528
+ # Create temporary renderer with original initial dict
403
529
  temp_renderer = PydanticForm(
404
530
  form_name=self.name,
405
531
  model_class=self.model_class,
406
- initial_values=self.initial_data_model, # Use the originally stored model
532
+ initial_values=self.initial_values_dict, # Use dict instead of BaseModel
533
+ custom_renderers=getattr(self, "custom_renderers", None),
534
+ disabled=self.disabled,
535
+ disabled_fields=self.disabled_fields,
536
+ label_colors=self.label_colors,
537
+ exclude_fields=self.exclude_fields,
538
+ spacing=self.spacing,
407
539
  )
408
540
 
409
- # Render inputs with the initial data
410
541
  reset_inputs_component = temp_renderer.render_inputs()
411
542
 
412
543
  if reset_inputs_component is None:
413
544
  logger.error(f"Reset for form '{self.name}' failed to render inputs.")
414
545
  return mui.Alert("Error resetting form.", cls=mui.AlertT.error)
415
546
 
416
- logger.info(
417
- f"Reset form '{self.name}' successful. Component: {reset_inputs_component}"
418
- )
547
+ logger.info(f"Reset form '{self.name}' successful")
419
548
  return reset_inputs_component
420
549
 
421
550
  def parse(self, form_dict: Dict[str, Any]) -> Dict[str, Any]:
@@ -434,20 +563,112 @@ class PydanticForm(Generic[ModelType]):
434
563
 
435
564
  list_field_defs = _identify_list_fields(self.model_class)
436
565
 
437
- # Parse non-list fields first - pass the base_prefix
566
+ # Filter out excluded fields from list field definitions
567
+ filtered_list_field_defs = {
568
+ field_name: field_def
569
+ for field_name, field_def in list_field_defs.items()
570
+ if field_name not in self.exclude_fields
571
+ }
438
572
 
573
+ # Parse non-list fields first - pass the base_prefix and exclude_fields
439
574
  result = _parse_non_list_fields(
440
- form_dict, self.model_class, list_field_defs, self.base_prefix
575
+ form_dict,
576
+ self.model_class,
577
+ list_field_defs,
578
+ self.base_prefix,
579
+ self.exclude_fields,
441
580
  )
442
581
 
443
582
  # Parse list fields based on keys present in form_dict - pass the base_prefix
444
- list_results = _parse_list_fields(form_dict, list_field_defs, self.base_prefix)
583
+ # Use filtered list field definitions to skip excluded list fields
584
+ list_results = _parse_list_fields(
585
+ form_dict, filtered_list_field_defs, self.base_prefix
586
+ )
445
587
 
446
588
  # Merge list results into the main result
447
589
  result.update(list_results)
448
590
 
591
+ # Inject defaults for excluded fields before returning
592
+ self._inject_default_values_for_excluded(result)
593
+
449
594
  return result
450
595
 
596
+ def _inject_default_values_for_excluded(
597
+ self, data: Dict[str, Any]
598
+ ) -> Dict[str, Any]:
599
+ """
600
+ Ensures that every field listed in self.exclude_fields is present in data
601
+ if the model defines a default or default_factory, or if initial_values were provided.
602
+
603
+ Also ensures all model fields have appropriate defaults if missing.
604
+
605
+ Priority order:
606
+ 1. initial_values (if provided during form creation)
607
+ 2. model defaults/default_factory
608
+
609
+ Operates top-level only (exclude_fields spec is top-level names).
610
+
611
+ Args:
612
+ data: Dictionary to modify in-place
613
+
614
+ Returns:
615
+ The same dictionary instance for method chaining
616
+ """
617
+ # Handle excluded fields first
618
+ for field_name in self.exclude_fields:
619
+ # Skip if already present (e.g., user provided initial_values)
620
+ if field_name in data:
621
+ continue
622
+
623
+ # First priority: check if initial_values_dict has this field
624
+ if field_name in self.initial_values_dict:
625
+ initial_val = self.initial_values_dict[field_name]
626
+ # If the initial value is a BaseModel, convert to dict for consistency
627
+ if hasattr(initial_val, "model_dump"):
628
+ initial_val = initial_val.model_dump()
629
+ data[field_name] = initial_val
630
+ logger.debug(
631
+ f"Injected initial value for excluded field '{field_name}'"
632
+ )
633
+ continue
634
+
635
+ # Second priority: use model defaults
636
+ field_info = self.model_class.model_fields.get(field_name)
637
+ if field_info is None:
638
+ logger.warning(f"exclude_fields contains unknown field '{field_name}'")
639
+ continue
640
+
641
+ default_val = get_default(field_info)
642
+ if default_val is not _UNSET:
643
+ # If the default is a BaseModel, convert to dict for consistency
644
+ if hasattr(default_val, "model_dump"):
645
+ default_val = default_val.model_dump()
646
+ data[field_name] = default_val
647
+ logger.debug(
648
+ f"Injected model default value for excluded field '{field_name}'"
649
+ )
650
+ else:
651
+ # No default → leave missing; validation will surface error
652
+ logger.debug(f"No default found for excluded field '{field_name}'")
653
+
654
+ # Also handle any other missing fields that should have defaults
655
+ for field_name, field_info in self.model_class.model_fields.items():
656
+ if field_name not in data:
657
+ # Try to inject defaults for missing fields
658
+ if field_name in self.initial_values_dict:
659
+ initial_val = self.initial_values_dict[field_name]
660
+ if hasattr(initial_val, "model_dump"):
661
+ initial_val = initial_val.model_dump()
662
+ data[field_name] = initial_val
663
+ else:
664
+ default_val = get_default(field_info)
665
+ if default_val is not _UNSET:
666
+ if hasattr(default_val, "model_dump"):
667
+ default_val = default_val.model_dump()
668
+ data[field_name] = default_val
669
+
670
+ return data
671
+
451
672
  def register_routes(self, app):
452
673
  """
453
674
  Register HTMX routes for list manipulation and form refresh
@@ -485,143 +706,76 @@ class PydanticForm(Generic[ModelType]):
485
706
  f"Registered reset route for form '{self.name}' at {reset_route_path}"
486
707
  )
487
708
 
488
- @app.route(f"/form/{self.name}/list/add/{{field_name}}")
489
- async def post_list_add(req, field_name: str):
709
+ # Try the route with a more explicit pattern
710
+ route_pattern = f"/form/{self.name}/list/{{action}}/{{list_path:path}}"
711
+ logger.debug(f"Registering list action route: {route_pattern}")
712
+
713
+ @app.route(route_pattern, methods=["POST", "DELETE"])
714
+ async def list_action(req, action: str, list_path: str):
490
715
  """
491
- Handle adding an item to a list for this specific form
716
+ Handle list actions (add/delete) for nested lists in this specific form
492
717
 
493
718
  Args:
494
719
  req: The request object
495
- field_name: The name of the list field
720
+ action: Either "add" or "delete"
721
+ list_path: Path to the list field (e.g., "tags" or "main_address/tags" or "other_addresses/1/tags")
496
722
 
497
723
  Returns:
498
- A component for the new list item
724
+ A component for the new list item (add) or empty response (delete)
499
725
  """
500
- # Find field info
501
- field_info = None
502
- item_type = None
503
-
504
- if field_name in self.model_class.model_fields:
505
- field_info = self.model_class.model_fields[field_name]
506
- annotation = getattr(field_info, "annotation", None)
507
-
508
- if (
509
- annotation is not None
510
- and hasattr(annotation, "__origin__")
511
- and annotation.__origin__ is list
512
- ):
513
- item_type = annotation.__args__[0]
514
-
515
- if not item_type:
516
- logger.error(
517
- f"Cannot determine item type for list field {field_name}"
518
- )
519
- return mui.Alert(
520
- f"Cannot determine item type for list field {field_name}",
521
- cls=mui.AlertT.error,
522
- )
523
-
524
- # Create a default item
525
- default_item = None # Initialize default_item
726
+ if action not in {"add", "delete"}:
727
+ return fh.Response(status_code=400, content="Unknown list action")
728
+
729
+ segments = list_path.split("/")
526
730
  try:
527
- # Ensure item_type is not None before checking attributes or type
528
- if item_type:
529
- # For Pydantic models, try to use model_construct for default values
530
- if hasattr(item_type, "model_construct"):
531
- try:
532
- default_item = item_type.model_construct()
533
- except Exception as e:
534
- logger.error(
535
- f"Error constructing model for {field_name}: {e}",
536
- exc_info=True,
537
- )
538
- return fh.Li(
539
- mui.Alert(
540
- f"Error creating model instance: {str(e)}",
541
- cls=mui.AlertT.error,
542
- ),
543
- cls="mb-2",
544
- )
545
- # Handle simple types with appropriate defaults
546
- elif item_type is str:
547
- default_item = ""
548
- elif item_type is int:
549
- default_item = 0
550
- elif item_type is float:
551
- default_item = 0.0
552
- elif item_type is bool:
553
- default_item = False
554
- else:
555
- default_item = None # Other simple types or complex non-models
556
- else:
557
- # Case where item_type itself was None (should ideally be caught earlier)
558
- default_item = None
559
- logger.warning(
560
- f"item_type was None when trying to create default for {field_name}"
561
- )
562
- except Exception as e:
563
- logger.error(
564
- f"Error creating default item for {field_name}: {e}", exc_info=True
565
- )
566
- return fh.Li(
567
- mui.Alert(
568
- f"Error creating default item: {str(e)}", cls=mui.AlertT.error
569
- ),
570
- cls="mb-2",
731
+ list_field_info, html_parts, item_type = walk_path(
732
+ self.model_class, segments
571
733
  )
734
+ except ValueError as exc:
735
+ logger.warning("Bad list path %s – %s", list_path, exc)
736
+ return mui.Alert(str(exc), cls=mui.AlertT.error)
572
737
 
573
- # Generate a unique placeholder index
574
- placeholder_idx = f"new_{int(pytime.time() * 1000)}"
575
-
576
- # Create a list renderer
577
- list_renderer = ListFieldRenderer(
578
- field_name=field_name,
579
- field_info=field_info,
580
- value=[], # Empty list, we only need to render one item
581
- prefix=self.base_prefix, # Use the form's base prefix
582
- )
583
-
584
- # Ensure the item data passed to the renderer is a dict if it's a model instance
585
- item_data_for_renderer = None
586
- if isinstance(default_item, BaseModel):
587
- item_data_for_renderer = default_item.model_dump()
738
+ if req.method == "DELETE":
588
739
  logger.debug(
589
- f"Add item: Converted model instance to dict for renderer: {item_data_for_renderer}"
740
+ f"Received DELETE request for {list_path} for form '{self.name}'"
590
741
  )
591
- elif default_item is not None: # Handle simple types directly
592
- item_data_for_renderer = default_item
593
- logger.debug(
594
- f"Add item: Passing simple type directly to renderer: {item_data_for_renderer}"
595
- )
596
- # else: item_data_for_renderer remains None if default_item was None
742
+ return fh.Response(status_code=200, content="")
597
743
 
598
- # Render the new item card, set is_open=True to make it expanded by default
599
- new_item_card = list_renderer._render_item_card(
600
- item_data_for_renderer, # Pass the dictionary or simple value
601
- placeholder_idx,
602
- item_type,
603
- is_open=True,
744
+ # === add (POST) ===
745
+ default_item = (
746
+ default_dict_for_model(item_type)
747
+ if hasattr(item_type, "model_fields")
748
+ else default_for_annotation(item_type)
604
749
  )
605
750
 
606
- return new_item_card
607
-
608
- @app.route(f"/form/{self.name}/list/delete/{{field_name}}", methods=["DELETE"])
609
- async def delete_list_item(req, field_name: str):
610
- """
611
- Handle deleting an item from a list for this specific form
751
+ # Build prefix **without** the list field itself to avoid duplication
752
+ parts_before_list = html_parts[:-1] # drop final segment
753
+ if parts_before_list:
754
+ html_prefix = f"{self.base_prefix}{'_'.join(parts_before_list)}_"
755
+ else:
756
+ html_prefix = self.base_prefix
757
+
758
+ # Create renderer for the list field
759
+ renderer = ListFieldRenderer(
760
+ field_name=segments[-1],
761
+ field_info=list_field_info,
762
+ value=[],
763
+ prefix=html_prefix,
764
+ spacing=self.spacing,
765
+ disabled=self.disabled,
766
+ field_path=segments, # Pass the full path segments
767
+ form_name=self.name, # Pass the explicit form name
768
+ )
612
769
 
613
- Args:
614
- req: The request object
615
- field_name: The name of the list field
770
+ # Generate a unique placeholder index
771
+ placeholder_idx = f"new_{int(pytime.time() * 1000)}"
616
772
 
617
- Returns:
618
- Empty string to delete the target element
619
- """
620
- # Return empty string to delete the target element
621
- logger.debug(
622
- f"Received DELETE request for {field_name} for form '{self.name}'"
773
+ # Render the new item card, set is_open=True to make it expanded by default
774
+ new_card = renderer._render_item_card(
775
+ default_item, placeholder_idx, item_type, is_open=True
623
776
  )
624
- return fh.Response(status_code=200, content="")
777
+
778
+ return new_card
625
779
 
626
780
  def refresh_button(self, text: Optional[str] = None, **kwargs) -> FT:
627
781
  """
@@ -640,9 +794,6 @@ class PydanticForm(Generic[ModelType]):
640
794
  # Define the target wrapper ID
641
795
  form_content_wrapper_id = f"{self.name}-inputs-wrapper"
642
796
 
643
- # Define the form ID to include
644
- form_id = f"{self.name}-form"
645
-
646
797
  # Define the target URL
647
798
  refresh_url = f"/form/{self.name}/refresh"
648
799
 
@@ -653,7 +804,7 @@ class PydanticForm(Generic[ModelType]):
653
804
  "hx_target": f"#{form_content_wrapper_id}", # Target the wrapper Div ID
654
805
  "hx_swap": "innerHTML",
655
806
  "hx_trigger": "click", # Explicit trigger on click
656
- "hx_include": f"#{form_id}", # Include all form fields in the request
807
+ "hx_include": "closest form", # Include all form fields from the enclosing form
657
808
  "uk_tooltip": "Update the form display based on current values (e.g., list item titles)",
658
809
  "cls": mui.ButtonT.secondary,
659
810
  }
@@ -736,3 +887,12 @@ class PydanticForm(Generic[ModelType]):
736
887
  logger.info(f"Request validation successful for form '{self.name}'")
737
888
 
738
889
  return validated_model
890
+
891
+ def form_id(self) -> str:
892
+ """
893
+ Get the standard form ID for this renderer.
894
+
895
+ Returns:
896
+ The form ID string that should be used for the HTML form element
897
+ """
898
+ return f"{self.name}-form"