fh-pydantic-form 0.3.9__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1004 @@
1
+ import logging
2
+ import time as pytime
3
+ from typing import (
4
+ Any,
5
+ Dict,
6
+ Generic,
7
+ List,
8
+ Optional,
9
+ Tuple,
10
+ Type,
11
+ TypeVar,
12
+ Union,
13
+ )
14
+
15
+ import fasthtml.common as fh
16
+ import monsterui.all as mui
17
+ from fastcore.xml import FT
18
+ from pydantic import BaseModel
19
+
20
+ from fh_pydantic_form.constants import _UNSET
21
+ from fh_pydantic_form.defaults import default_dict_for_model, default_for_annotation
22
+ from fh_pydantic_form.field_renderers import (
23
+ BaseFieldRenderer,
24
+ ListFieldRenderer,
25
+ StringFieldRenderer,
26
+ )
27
+ from fh_pydantic_form.form_parser import (
28
+ _identify_list_fields,
29
+ _parse_list_fields,
30
+ _parse_non_list_fields,
31
+ )
32
+ from fh_pydantic_form.list_path import walk_path
33
+ from fh_pydantic_form.registry import FieldRendererRegistry
34
+ from fh_pydantic_form.type_helpers import (
35
+ _is_skip_json_schema_field,
36
+ get_default,
37
+ normalize_path_segments,
38
+ )
39
+ from fh_pydantic_form.ui_style import (
40
+ SpacingTheme,
41
+ SpacingValue,
42
+ _normalize_spacing,
43
+ spacing,
44
+ )
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+ # TypeVar for generic model typing
49
+ ModelType = TypeVar("ModelType", bound=BaseModel)
50
+
51
+
52
+ def _compile_keep_paths(paths: Optional[List[str]]) -> set[str]:
53
+ """Normalize and compile keep paths for fast membership tests."""
54
+ if not paths:
55
+ return set()
56
+
57
+ compiled: set[str] = set()
58
+ for raw_path in paths:
59
+ if not raw_path:
60
+ continue
61
+ normalized = raw_path.strip()
62
+ if normalized:
63
+ compiled.add(normalized)
64
+ return compiled
65
+
66
+
67
+ def list_manipulation_js():
68
+ return fh.Script("""
69
+ function moveItem(buttonElement, direction) {
70
+ // Find the accordion item (list item)
71
+ const item = buttonElement.closest('li');
72
+ if (!item) return;
73
+
74
+ const container = item.parentElement;
75
+ if (!container) return;
76
+
77
+ // Find the sibling in the direction we want to move
78
+ const sibling = direction === 'up' ? item.previousElementSibling : item.nextElementSibling;
79
+
80
+ if (sibling) {
81
+ if (direction === 'up') {
82
+ container.insertBefore(item, sibling);
83
+ } else {
84
+ // Insert item after the next sibling
85
+ container.insertBefore(item, sibling.nextElementSibling);
86
+ }
87
+ // Update button states after move
88
+ updateMoveButtons(container);
89
+ }
90
+ }
91
+
92
+ function moveItemUp(buttonElement) {
93
+ moveItem(buttonElement, 'up');
94
+ }
95
+
96
+ function moveItemDown(buttonElement) {
97
+ moveItem(buttonElement, 'down');
98
+ }
99
+
100
+ // Function to update button states (disable if at top/bottom)
101
+ function updateMoveButtons(container) {
102
+ const items = container.querySelectorAll(':scope > li');
103
+ items.forEach((item, index) => {
104
+ const upButton = item.querySelector('button[onclick^="moveItemUp"]');
105
+ const downButton = item.querySelector('button[onclick^="moveItemDown"]');
106
+
107
+ if (upButton) upButton.disabled = (index === 0);
108
+ if (downButton) downButton.disabled = (index === items.length - 1);
109
+ });
110
+ }
111
+
112
+ // Function to toggle all list items open or closed
113
+ function toggleListItems(containerId) {
114
+ const containerElement = document.getElementById(containerId);
115
+ if (!containerElement) {
116
+ console.warn('Accordion container not found:', containerId);
117
+ return;
118
+ }
119
+
120
+ // Find all direct li children (the accordion items)
121
+ const items = Array.from(containerElement.children).filter(el => el.tagName === 'LI');
122
+ if (!items.length) {
123
+ return; // No items to toggle
124
+ }
125
+
126
+ // Determine if we should open all (if any are closed) or close all (if all are open)
127
+ const shouldOpen = items.some(item => !item.classList.contains('uk-open'));
128
+
129
+ // Toggle each item accordingly
130
+ items.forEach(item => {
131
+ if (shouldOpen) {
132
+ // Open the item if it's not already open
133
+ if (!item.classList.contains('uk-open')) {
134
+ item.classList.add('uk-open');
135
+ // Make sure the content is expanded
136
+ const content = item.querySelector('.uk-accordion-content');
137
+ if (content) {
138
+ content.style.height = 'auto';
139
+ content.hidden = false;
140
+ }
141
+ }
142
+ } else {
143
+ // Close the item
144
+ item.classList.remove('uk-open');
145
+ // Hide the content
146
+ const content = item.querySelector('.uk-accordion-content');
147
+ if (content) {
148
+ content.hidden = true;
149
+ }
150
+ }
151
+ });
152
+
153
+ // Attempt to use UIkit's API if available (more reliable)
154
+ if (window.UIkit && UIkit.accordion) {
155
+ try {
156
+ const accordion = UIkit.accordion(containerElement);
157
+ if (accordion) {
158
+ // In UIkit, indices typically start at 0
159
+ items.forEach((item, index) => {
160
+ const isOpen = item.classList.contains('uk-open');
161
+ if (shouldOpen && !isOpen) {
162
+ accordion.toggle(index, false); // Open item without animation
163
+ } else if (!shouldOpen && isOpen) {
164
+ accordion.toggle(index, false); // Close item without animation
165
+ }
166
+ });
167
+ }
168
+ } catch (e) {
169
+ console.warn('UIkit accordion API failed, falling back to manual toggle', e);
170
+ // The manual toggle above should have handled it
171
+ }
172
+ }
173
+ }
174
+
175
+ // Simple accordion state preservation using item IDs
176
+ window.saveAccordionState = function(containerId) {
177
+ const container = document.getElementById(containerId);
178
+ if (!container) return;
179
+
180
+ const openItemIds = [];
181
+ container.querySelectorAll('li.uk-open').forEach(item => {
182
+ if (item.id) {
183
+ openItemIds.push(item.id);
184
+ }
185
+ });
186
+
187
+ // Store in sessionStorage with container-specific key
188
+ sessionStorage.setItem(`accordion_state_${containerId}`, JSON.stringify(openItemIds));
189
+ };
190
+
191
+ window.restoreAccordionState = function(containerId) {
192
+ const container = document.getElementById(containerId);
193
+ if (!container) return;
194
+
195
+ const savedState = sessionStorage.getItem(`accordion_state_${containerId}`);
196
+ if (!savedState) return;
197
+
198
+ try {
199
+ const openItemIds = JSON.parse(savedState);
200
+
201
+ // Restore open state for each saved item by ID
202
+ openItemIds.forEach(itemId => {
203
+ const item = document.getElementById(itemId);
204
+ if (item && container.contains(item)) {
205
+ item.classList.add('uk-open');
206
+ const content = item.querySelector('.uk-accordion-content');
207
+ if (content) {
208
+ content.hidden = false;
209
+ content.style.height = 'auto';
210
+ }
211
+ }
212
+ });
213
+ } catch (e) {
214
+ console.warn('Failed to restore accordion state:', e);
215
+ }
216
+ };
217
+
218
+ // Save all accordion states in the form (both lists and nested BaseModels)
219
+ window.saveAllAccordionStates = function() {
220
+ // Save list container states
221
+ document.querySelectorAll('[id$="_items_container"]').forEach(container => {
222
+ window.saveAccordionState(container.id);
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
+ });
232
+ };
233
+
234
+ // Restore all accordion states in the form (both lists and nested BaseModels)
235
+ window.restoreAllAccordionStates = function() {
236
+ // Restore list container states
237
+ document.querySelectorAll('[id$="_items_container"]').forEach(container => {
238
+ window.restoreAccordionState(container.id);
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
+ });
258
+ };
259
+
260
+ // Wait for the DOM to be fully loaded before initializing
261
+ document.addEventListener('DOMContentLoaded', () => {
262
+ // Initialize button states for elements present on initial load
263
+ document.querySelectorAll('[id$="_items_container"]').forEach(container => {
264
+ updateMoveButtons(container);
265
+ });
266
+
267
+ // Attach HTMX event listener to document.body for list operations
268
+ document.body.addEventListener('htmx:afterSwap', function(event) {
269
+ // Check if this is an insert (afterend swap)
270
+ const targetElement = event.detail.target;
271
+ const requestElement = event.detail.requestConfig?.elt;
272
+ const swapStrategy = requestElement ? requestElement.getAttribute('hx-swap') : null;
273
+
274
+ if (swapStrategy === 'afterend') {
275
+ // For insertions, get the parent container of the original target
276
+ const listContainer = targetElement.closest('[id$="_items_container"]');
277
+ if (listContainer) {
278
+ updateMoveButtons(listContainer);
279
+ }
280
+ } else {
281
+ // Original logic for other swap types
282
+ const containers = event.detail.target.querySelectorAll('[id$="_items_container"]');
283
+ containers.forEach(container => {
284
+ updateMoveButtons(container);
285
+ });
286
+
287
+ // If the target itself is a container
288
+ if (event.detail.target.id && event.detail.target.id.endsWith('_items_container')) {
289
+ updateMoveButtons(event.detail.target);
290
+ }
291
+ }
292
+ });
293
+ });
294
+ """)
295
+
296
+
297
+ class PydanticForm(Generic[ModelType]):
298
+ """
299
+ Renders a form from a Pydantic model class with robust schema drift handling
300
+
301
+ Accepts initial values as either BaseModel instances or dictionaries.
302
+ Gracefully handles missing fields and schema mismatches by rendering
303
+ available fields and skipping problematic ones.
304
+
305
+ This class handles:
306
+ - Finding appropriate renderers for each field
307
+ - Managing field prefixes for proper form submission
308
+ - Creating the overall form structure
309
+ - Registering HTMX routes for list manipulation
310
+ - Parsing form data back to Pydantic model format
311
+ - Handling refresh and reset requests
312
+ - providing refresh and reset buttons
313
+ - validating request data against the model
314
+ """
315
+
316
+ # --- module-level flag (add near top of file) ---
317
+
318
+ def __init__(
319
+ self,
320
+ form_name: str,
321
+ model_class: Type[ModelType],
322
+ initial_values: Optional[Union[ModelType, Dict[str, Any]]] = None,
323
+ custom_renderers: Optional[List[Tuple[Type, Type[BaseFieldRenderer]]]] = None,
324
+ disabled: bool = False,
325
+ disabled_fields: Optional[List[str]] = None,
326
+ label_colors: Optional[Dict[str, str]] = None,
327
+ exclude_fields: Optional[List[str]] = None,
328
+ keep_skip_json_fields: Optional[List[str]] = None,
329
+ spacing: SpacingValue = SpacingTheme.NORMAL,
330
+ metrics_dict: Optional[Dict[str, Any]] = None,
331
+ ):
332
+ """
333
+ Initialize the form renderer
334
+
335
+ Args:
336
+ form_name: Unique name for this form
337
+ model_class: The Pydantic model class to render
338
+ initial_values: Initial values as BaseModel instance or dict.
339
+ Missing fields will not be auto-filled with defaults.
340
+ Supports robust handling of schema drift.
341
+ custom_renderers: Optional list of tuples (field_type, renderer_cls) to register
342
+ disabled: Whether all form inputs should be disabled
343
+ disabled_fields: Optional list of top-level field names to disable specifically
344
+ label_colors: Optional dictionary mapping field names to label colors (CSS color values)
345
+ exclude_fields: Optional list of top-level field names to exclude from the form
346
+ keep_skip_json_fields: Optional list of dot-paths for SkipJsonSchema fields to force-keep
347
+ spacing: Spacing theme to use for form layout ("normal", "compact", or SpacingTheme enum)
348
+ metrics_dict: Optional metrics dictionary for field-level visual feedback
349
+ """
350
+ self.name = form_name
351
+ self.model_class = model_class
352
+
353
+ self.initial_values_dict: Dict[str, Any] = {}
354
+
355
+ # Store initial values as dict for robustness to schema drift
356
+ if initial_values is None:
357
+ self.initial_values_dict = {}
358
+ elif isinstance(initial_values, dict):
359
+ self.initial_values_dict = initial_values.copy()
360
+ elif hasattr(initial_values, "model_dump"):
361
+ self.initial_values_dict = initial_values.model_dump()
362
+ else:
363
+ # Fallback - attempt dict conversion
364
+ try:
365
+ temp_dict = dict(initial_values)
366
+ model_field_names = set(self.model_class.model_fields.keys())
367
+ # Only accept if all keys are in the model's field names
368
+ if not isinstance(temp_dict, dict) or not set(
369
+ temp_dict.keys()
370
+ ).issubset(model_field_names):
371
+ raise ValueError("Converted to dict with keys not in model fields")
372
+ self.initial_values_dict = temp_dict
373
+ except (TypeError, ValueError):
374
+ logger.warning(
375
+ "Could not convert initial_values to dict, using empty dict"
376
+ )
377
+ self.initial_values_dict = {}
378
+
379
+ # Use copy for rendering to avoid mutations
380
+ self.values_dict: Dict[str, Any] = self.initial_values_dict.copy()
381
+
382
+ self.base_prefix = f"{form_name}_"
383
+ self.disabled = disabled
384
+ self.disabled_fields = (
385
+ disabled_fields or []
386
+ ) # Store as list for easier checking
387
+ self.label_colors = label_colors or {} # Store label colors mapping
388
+ self.exclude_fields = exclude_fields or [] # Store excluded fields list
389
+ self.spacing = _normalize_spacing(spacing) # Store normalized spacing
390
+ self.metrics_dict = metrics_dict or {} # Store metrics dictionary
391
+ self.keep_skip_json_fields = keep_skip_json_fields or []
392
+ self._keep_skip_json_pathset = _compile_keep_paths(self.keep_skip_json_fields)
393
+
394
+ # Register custom renderers with the global registry if provided
395
+ if custom_renderers:
396
+ registry = FieldRendererRegistry() # Get singleton instance
397
+ for field_type, renderer_cls in custom_renderers:
398
+ registry.register_type_renderer(field_type, renderer_cls)
399
+
400
+ @property
401
+ def form_name(self) -> str:
402
+ """
403
+ LLMs like to hallucinate this property, so might as well make it real.
404
+ """
405
+ return self.name
406
+
407
+ def _compact_wrapper(self, inner: FT) -> FT:
408
+ """
409
+ Wrap inner markup in a wrapper div.
410
+ """
411
+ wrapper_cls = "fhpf-wrapper w-full flex-1"
412
+ return fh.Div(inner, cls=wrapper_cls)
413
+
414
+ def _normalized_dot_path(self, path_segments: List[str]) -> str:
415
+ """Normalize path segments by dropping indices and joining with dots."""
416
+ return normalize_path_segments(path_segments)
417
+
418
+ def _is_kept_skip_field(self, full_path: List[str]) -> bool:
419
+ """Return True if a SkipJsonSchema field should be kept based on keep list."""
420
+ normalized = self._normalized_dot_path(full_path)
421
+ return bool(normalized) and normalized in self._keep_skip_json_pathset
422
+
423
+ def reset_state(self) -> None:
424
+ """
425
+ Restore the live state of the form to its immutable baseline.
426
+ Call this *before* rendering if you truly want a factory-fresh view.
427
+ """
428
+ self.values_dict = self.initial_values_dict.copy()
429
+
430
+ def with_initial_values(
431
+ self,
432
+ initial_values: Optional[Union[ModelType, Dict[str, Any]]] = None,
433
+ metrics_dict: Optional[Dict[str, Any]] = None,
434
+ ) -> "PydanticForm":
435
+ """
436
+ Create a new PydanticForm instance with the same configuration but different initial values.
437
+
438
+ This preserves all constructor arguments (label_colors, custom_renderers, spacing, etc.)
439
+ while allowing you to specify new initial values. This is useful for reusing form
440
+ configurations with different data.
441
+
442
+ Args:
443
+ initial_values: New initial values as BaseModel instance or dict.
444
+ Same format as the constructor accepts.
445
+ metrics_dict: Optional metrics dictionary for field-level visual feedback
446
+
447
+ Returns:
448
+ A new PydanticForm instance with identical configuration but updated initial values
449
+ """
450
+ # Create the new instance with the same configuration
451
+ clone = PydanticForm(
452
+ form_name=self.name,
453
+ model_class=self.model_class,
454
+ initial_values=initial_values, # Pass through to constructor for proper handling
455
+ custom_renderers=None, # Registry is global, no need to re-register
456
+ disabled=self.disabled,
457
+ disabled_fields=self.disabled_fields,
458
+ label_colors=self.label_colors,
459
+ exclude_fields=self.exclude_fields,
460
+ keep_skip_json_fields=self.keep_skip_json_fields,
461
+ spacing=self.spacing,
462
+ metrics_dict=metrics_dict
463
+ if metrics_dict is not None
464
+ else self.metrics_dict,
465
+ )
466
+
467
+ return clone
468
+
469
+ def render_inputs(self) -> FT:
470
+ """
471
+ Render just the form inputs based on the model class (no form tag)
472
+
473
+ Returns:
474
+ A component containing the rendered form input fields
475
+ """
476
+ form_inputs = []
477
+ registry = FieldRendererRegistry() # Get singleton instance
478
+
479
+ for field_name, field_info in self.model_class.model_fields.items():
480
+ # Skip excluded fields
481
+ if field_name in self.exclude_fields:
482
+ continue
483
+
484
+ # Skip SkipJsonSchema fields unless explicitly kept
485
+ if _is_skip_json_schema_field(field_info) and not self._is_kept_skip_field(
486
+ [field_name]
487
+ ):
488
+ continue
489
+
490
+ # Only use what was explicitly provided in initial values
491
+ initial_value = (
492
+ self.values_dict.get(field_name) if self.values_dict else None
493
+ )
494
+
495
+ # Only use model defaults if field was not provided at all
496
+ # (not if it was provided as None/empty)
497
+ field_was_provided = (
498
+ field_name in self.values_dict if self.values_dict else False
499
+ )
500
+
501
+ # Only use defaults if field was not provided at all
502
+ if not field_was_provided:
503
+ # Field not provided - use model defaults in order of priority
504
+ # 1. Try explicit field default
505
+ default_val = get_default(field_info)
506
+ if default_val is not _UNSET:
507
+ initial_value = default_val
508
+ else:
509
+ # 2. Fall back to smart defaults for the type
510
+ initial_value = default_for_annotation(field_info.annotation)
511
+ # If field was provided (even as None), respect that value
512
+
513
+ # Get renderer from global registry
514
+ renderer_cls = registry.get_renderer(field_name, field_info)
515
+
516
+ if not renderer_cls:
517
+ # Fall back to StringFieldRenderer if no renderer found
518
+ renderer_cls = StringFieldRenderer
519
+ logger.warning(
520
+ f" - No renderer found for '{field_name}', falling back to StringFieldRenderer"
521
+ )
522
+
523
+ # Determine if this specific field should be disabled
524
+ is_field_disabled = self.disabled or (field_name in self.disabled_fields)
525
+
526
+ # Get label color for this field if specified
527
+ label_color = self.label_colors.get(field_name)
528
+
529
+ # Create and render the field
530
+ renderer = renderer_cls(
531
+ field_name=field_name,
532
+ field_info=field_info,
533
+ value=initial_value,
534
+ prefix=self.base_prefix,
535
+ disabled=is_field_disabled, # Pass the calculated disabled state
536
+ label_color=label_color, # Pass the label color if specified
537
+ spacing=self.spacing, # Pass the spacing
538
+ field_path=[field_name], # Set top-level field path
539
+ form_name=self.name, # Pass form name
540
+ metrics_dict=self.metrics_dict, # Pass the metrics dict
541
+ keep_skip_json_pathset=self._keep_skip_json_pathset,
542
+ )
543
+
544
+ rendered_field = renderer.render()
545
+ form_inputs.append(rendered_field)
546
+
547
+ # Create container for inputs, ensuring items stretch to full width
548
+ inputs_container = mui.DivVStacked(
549
+ *form_inputs,
550
+ cls=f"{spacing('stack_gap', self.spacing)} items-stretch",
551
+ )
552
+
553
+ # Define the ID for the wrapper div - this is what the HTMX request targets
554
+ form_content_wrapper_id = f"{self.name}-inputs-wrapper"
555
+
556
+ # Create the wrapper div and apply compact styling if needed
557
+ wrapped = self._compact_wrapper(
558
+ fh.Div(inputs_container, id=form_content_wrapper_id)
559
+ )
560
+
561
+ return wrapped
562
+
563
+ def _filter_by_prefix(self, data: Dict[str, Any]) -> Dict[str, Any]:
564
+ """
565
+ Filter form data to include only keys that start with this form's base_prefix.
566
+
567
+ This prevents cross-contamination when multiple forms share the same HTML form element.
568
+
569
+ Args:
570
+ data: Raw form data dictionary
571
+
572
+ Returns:
573
+ Filtered dictionary containing only keys with matching prefix
574
+ """
575
+ if not self.base_prefix:
576
+ return data # No prefix = no filtering needed
577
+
578
+ filtered = {
579
+ key: value
580
+ for key, value in data.items()
581
+ if key.startswith(self.base_prefix)
582
+ }
583
+
584
+ return filtered
585
+
586
+ # ---- Form Renderer Methods (continued) ----
587
+
588
+ async def handle_refresh_request(self, req):
589
+ """
590
+ Handles the POST request for refreshing this form instance.
591
+
592
+ Args:
593
+ req: The request object
594
+
595
+ Returns:
596
+ HTML response with refreshed form inputs
597
+ """
598
+ form_data = await req.form()
599
+ form_dict = dict(form_data)
600
+
601
+ # Filter to only this form's fields
602
+ form_dict = self._filter_by_prefix(form_dict)
603
+
604
+ logger.info(f"Refresh request for form '{self.name}'")
605
+
606
+ parsed_data = {}
607
+ alert_ft = None # Changed to hold an FT object instead of a string
608
+ try:
609
+ # Use the instance's parse method directly
610
+
611
+ parsed_data = self.parse(form_dict)
612
+
613
+ except Exception as e:
614
+ logger.error(
615
+ f"Error parsing form data for refresh on form '{self.name}': {e}",
616
+ exc_info=True,
617
+ )
618
+
619
+ # Merge strategy - preserve existing values for unparseable fields
620
+ # Start with current values
621
+ parsed_data = self.values_dict.copy() if self.values_dict else {}
622
+
623
+ # Try to extract any simple fields that don't require complex parsing
624
+ for key, value in form_dict.items():
625
+ if key.startswith(self.base_prefix):
626
+ field_name = key[len(self.base_prefix) :]
627
+ # Only update simple fields to avoid corruption
628
+ if "_" not in field_name: # Not a nested field
629
+ parsed_data[field_name] = value
630
+
631
+ alert_ft = mui.Alert(
632
+ f"Warning: Some fields could not be refreshed. Preserved previous values. Error: {str(e)}",
633
+ cls=mui.AlertT.warning + " mb-4", # Add margin bottom
634
+ )
635
+
636
+ # Parsed successfully (or merged best effort) – make it the new truth
637
+ self.values_dict = parsed_data.copy()
638
+
639
+ # Create temporary renderer with same configuration but updated values
640
+ temp_renderer = self.with_initial_values(parsed_data)
641
+
642
+ refreshed_inputs_component = temp_renderer.render_inputs()
643
+
644
+ if refreshed_inputs_component is None:
645
+ logger.error("render_inputs() returned None!")
646
+ alert_ft = mui.Alert(
647
+ "Critical error: Form refresh failed to generate content",
648
+ cls=mui.AlertT.error + " mb-4",
649
+ )
650
+ # Emergency fallback - use original renderer's inputs
651
+ refreshed_inputs_component = self.render_inputs()
652
+
653
+ # Return the FT components directly instead of creating a Response object
654
+ if alert_ft:
655
+ # Return both the alert and the form inputs as a tuple
656
+ return (alert_ft, refreshed_inputs_component)
657
+ else:
658
+ # Return just the form inputs
659
+ return refreshed_inputs_component
660
+
661
+ async def handle_reset_request(self) -> FT:
662
+ """
663
+ Handles the POST request for resetting this form instance to its initial values.
664
+
665
+ Returns:
666
+ HTML response with reset form inputs
667
+ """
668
+ # Rewind internal state to the immutable baseline
669
+ self.reset_state()
670
+
671
+ logger.info(f"Resetting form '{self.name}' to initial values")
672
+
673
+ # Create temporary renderer with original initial dict
674
+ temp_renderer = self.with_initial_values(self.initial_values_dict)
675
+
676
+ reset_inputs_component = temp_renderer.render_inputs()
677
+
678
+ if reset_inputs_component is None:
679
+ logger.error(f"Reset for form '{self.name}' failed to render inputs.")
680
+ return mui.Alert("Error resetting form.", cls=mui.AlertT.error)
681
+
682
+ logger.info(f"Reset form '{self.name}' successful")
683
+ return reset_inputs_component
684
+
685
+ def parse(self, form_dict: Dict[str, Any]) -> Dict[str, Any]:
686
+ """
687
+ Parse form data into a structure that matches the model.
688
+
689
+ This method processes form data that includes the form's base_prefix
690
+ and reconstructs the structure expected by the Pydantic model.
691
+
692
+ Args:
693
+ form_dict: Dictionary containing form field data (name -> value)
694
+
695
+ Returns:
696
+ Dictionary with parsed data in a structure matching the model
697
+ """
698
+
699
+ list_field_defs = _identify_list_fields(self.model_class)
700
+
701
+ # Filter out excluded fields from list field definitions
702
+ filtered_list_field_defs = {
703
+ field_name: field_def
704
+ for field_name, field_def in list_field_defs.items()
705
+ if field_name not in self.exclude_fields
706
+ }
707
+
708
+ # Parse non-list fields first - pass the base_prefix, exclude_fields, and keep paths
709
+ result = _parse_non_list_fields(
710
+ form_dict,
711
+ self.model_class,
712
+ list_field_defs,
713
+ self.base_prefix,
714
+ self.exclude_fields,
715
+ self._keep_skip_json_pathset,
716
+ None, # Top-level parsing, no field path
717
+ )
718
+
719
+ # Parse list fields based on keys present in form_dict - pass the base_prefix and keep paths
720
+ # Use filtered list field definitions to skip excluded list fields
721
+ list_results = _parse_list_fields(
722
+ form_dict,
723
+ filtered_list_field_defs,
724
+ self.base_prefix,
725
+ self.exclude_fields,
726
+ self._keep_skip_json_pathset,
727
+ )
728
+
729
+ # Merge list results into the main result
730
+ result.update(list_results)
731
+
732
+ # Inject defaults for missing fields before returning
733
+ self._inject_missing_defaults(result)
734
+
735
+ return result
736
+
737
+ def _inject_missing_defaults(self, data: Dict[str, Any]) -> Dict[str, Any]:
738
+ """
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)
744
+
745
+ For required fields without defaults or initial_values, they are left missing
746
+ so that Pydantic validation can properly surface the error.
747
+
748
+ Args:
749
+ data: Dictionary to modify in-place
750
+
751
+ Returns:
752
+ The same dictionary instance for method chaining
753
+ """
754
+ for field_name, field_info in self.model_class.model_fields.items():
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:
761
+ initial_val = self.initial_values_dict[field_name]
762
+ if hasattr(initial_val, "model_dump"):
763
+ initial_val = initial_val.model_dump()
764
+ data[field_name] = initial_val
765
+ continue
766
+
767
+ # 3) Use model/default_factory if available
768
+ default_val = get_default(field_info)
769
+ if default_val is not _UNSET:
770
+ # If the default is a BaseModel, convert to dict for consistency
771
+ if hasattr(default_val, "model_dump"):
772
+ default_val = default_val.model_dump()
773
+ data[field_name] = default_val
774
+ else:
775
+ # 4) For SkipJsonSchema fields without defaults, provide sensible defaults
776
+ # For regular required fields, leave them missing so validation catches them
777
+ if _is_skip_json_schema_field(field_info):
778
+ data[field_name] = default_for_annotation(field_info.annotation)
779
+ # else: leave missing, let validation fail
780
+
781
+ return data
782
+
783
+ def register_routes(self, app):
784
+ """
785
+ Register HTMX routes for list manipulation and form refresh
786
+
787
+ Args:
788
+ rt: The route registrar function from the application
789
+ """
790
+
791
+ # --- Register the form-specific refresh route ---
792
+ refresh_route_path = f"/form/{self.name}/refresh"
793
+
794
+ @app.route(refresh_route_path, methods=["POST"])
795
+ async def _instance_specific_refresh_handler(req):
796
+ """Handle form refresh request for this specific form instance"""
797
+ # Add entry point logging to confirm the route is being hit
798
+ # Calls the instance method to handle the logic
799
+ return await self.handle_refresh_request(req)
800
+
801
+ # --- Register the form-specific reset route ---
802
+ reset_route_path = f"/form/{self.name}/reset"
803
+
804
+ @app.route(reset_route_path, methods=["POST"])
805
+ async def _instance_specific_reset_handler(req):
806
+ """Handle form reset request for this specific form instance"""
807
+ # Calls the instance method to handle the logic
808
+ return await self.handle_reset_request()
809
+
810
+ # Try the route with a more explicit pattern
811
+ route_pattern = f"/form/{self.name}/list/{{action}}/{{list_path:path}}"
812
+
813
+ @app.route(route_pattern, methods=["POST", "DELETE"])
814
+ async def list_action(req, action: str, list_path: str):
815
+ """
816
+ Handle list actions (add/delete) for nested lists in this specific form
817
+
818
+ Args:
819
+ req: The request object
820
+ action: Either "add" or "delete"
821
+ list_path: Path to the list field (e.g., "tags" or "main_address/tags" or "other_addresses/1/tags")
822
+
823
+ Returns:
824
+ A component for the new list item (add) or empty response (delete)
825
+ """
826
+ if action not in {"add", "delete"}:
827
+ return fh.Response(status_code=400, content="Unknown list action")
828
+
829
+ segments = list_path.split("/")
830
+ try:
831
+ list_field_info, html_parts, item_type = walk_path(
832
+ self.model_class, segments
833
+ )
834
+ except ValueError as exc:
835
+ logger.warning("Bad list path %s – %s", list_path, exc)
836
+ return mui.Alert(str(exc), cls=mui.AlertT.error)
837
+
838
+ if req.method == "DELETE":
839
+ return fh.Response(status_code=200, content="")
840
+
841
+ # === add (POST) ===
842
+ default_item = (
843
+ default_dict_for_model(item_type)
844
+ if hasattr(item_type, "model_fields")
845
+ else default_for_annotation(item_type)
846
+ )
847
+
848
+ # Build prefix **without** the list field itself to avoid duplication
849
+ parts_before_list = html_parts[:-1] # drop final segment
850
+ if parts_before_list:
851
+ html_prefix = f"{self.base_prefix}{'_'.join(parts_before_list)}_"
852
+ else:
853
+ html_prefix = self.base_prefix
854
+
855
+ # Create renderer for the list field
856
+ renderer = ListFieldRenderer(
857
+ field_name=segments[-1],
858
+ field_info=list_field_info,
859
+ value=[],
860
+ prefix=html_prefix,
861
+ spacing=self.spacing,
862
+ disabled=self.disabled,
863
+ field_path=segments, # Pass the full path segments
864
+ form_name=self.name, # Pass the explicit form name
865
+ metrics_dict=self.metrics_dict, # Pass the metrics dict
866
+ keep_skip_json_pathset=self._keep_skip_json_pathset,
867
+ )
868
+
869
+ # Generate a unique placeholder index
870
+ placeholder_idx = f"new_{int(pytime.time() * 1000)}"
871
+
872
+ # Render the new item card, set is_open=True to make it expanded by default
873
+ new_card = renderer._render_item_card(
874
+ default_item, placeholder_idx, item_type, is_open=True
875
+ )
876
+
877
+ return new_card
878
+
879
+ def refresh_button(self, text: Optional[str] = None, **kwargs) -> FT:
880
+ """
881
+ Generates the HTML component for the form's refresh button.
882
+
883
+ Args:
884
+ text: Optional custom text for the button. Defaults to "Refresh Form Display".
885
+ **kwargs: Additional attributes to pass to the mui.Button component.
886
+
887
+ Returns:
888
+ A FastHTML component (mui.Button) representing the refresh button.
889
+ """
890
+ # Use provided text or default
891
+ button_text = text if text is not None else " Refresh Form Display"
892
+
893
+ # Define the target wrapper ID
894
+ form_content_wrapper_id = f"{self.name}-inputs-wrapper"
895
+
896
+ # Define the target URL
897
+ refresh_url = f"/form/{self.name}/refresh"
898
+
899
+ # Base button attributes
900
+ button_attrs = {
901
+ "type": "button", # Prevent form submission
902
+ "hx_post": refresh_url, # Target the instance-specific route
903
+ "hx_target": f"#{form_content_wrapper_id}", # Target the wrapper Div ID
904
+ "hx_swap": "innerHTML",
905
+ "hx_trigger": "click", # Explicit trigger on click
906
+ "hx_include": "closest form", # Include all form fields from the enclosing form
907
+ "hx_preserve": "scroll",
908
+ "uk_tooltip": "Update the form display based on current values (e.g., list item titles)",
909
+ "cls": mui.ButtonT.secondary,
910
+ **{
911
+ "hx-on::before-request": "window.saveAllAccordionStates && window.saveAllAccordionStates()"
912
+ },
913
+ **{
914
+ "hx-on::after-swap": "window.restoreAllAccordionStates && window.restoreAllAccordionStates()"
915
+ },
916
+ }
917
+
918
+ # Update with any additional attributes
919
+ button_attrs.update(kwargs)
920
+
921
+ # Create and return the button
922
+ return mui.Button(mui.UkIcon("refresh-ccw"), button_text, **button_attrs)
923
+
924
+ def reset_button(self, text: Optional[str] = None, **kwargs) -> FT:
925
+ """
926
+ Generates the HTML component for the form's reset button.
927
+
928
+ Args:
929
+ text: Optional custom text for the button. Defaults to "Reset to Initial".
930
+ **kwargs: Additional attributes to pass to the mui.Button component.
931
+
932
+ Returns:
933
+ A FastHTML component (mui.Button) representing the reset button.
934
+ """
935
+ # Use provided text or default
936
+ button_text = text if text is not None else " Reset to Initial"
937
+
938
+ # Define the target wrapper ID
939
+ form_content_wrapper_id = f"{self.name}-inputs-wrapper"
940
+
941
+ # Define the target URL
942
+ reset_url = f"/form/{self.name}/reset"
943
+
944
+ # Base button attributes
945
+ button_attrs = {
946
+ "type": "button", # Prevent form submission
947
+ "hx_post": reset_url, # Target the instance-specific route
948
+ "hx_target": f"#{form_content_wrapper_id}", # Target the wrapper Div ID
949
+ "hx_swap": "innerHTML",
950
+ "hx_confirm": "Are you sure you want to reset the form to its initial values? Any unsaved changes will be lost.",
951
+ "hx_preserve": "scroll",
952
+ "uk_tooltip": "Reset the form fields to their original values",
953
+ "cls": mui.ButtonT.destructive, # Use danger style to indicate destructive action
954
+ }
955
+
956
+ # Update with any additional attributes
957
+ button_attrs.update(kwargs)
958
+
959
+ # Create and return the button
960
+ return mui.Button(
961
+ mui.UkIcon("history"), # Icon representing reset/history
962
+ button_text,
963
+ **button_attrs,
964
+ )
965
+
966
+ async def model_validate_request(self, req: Any) -> ModelType:
967
+ """
968
+ Extracts form data from a request, parses it, and validates against the model.
969
+
970
+ This method encapsulates the common pattern of:
971
+ 1. Extracting form data from a request
972
+ 2. Converting it to a dictionary
973
+ 3. Parsing with the renderer's logic (handling prefixes, etc.)
974
+ 4. Validating against the Pydantic model
975
+
976
+ Args:
977
+ req: The request object (must have an awaitable .form() method)
978
+
979
+ Returns:
980
+ A validated instance of the model class
981
+
982
+ Raises:
983
+ ValidationError: If validation fails based on the model's rules
984
+ """
985
+ form_data = await req.form()
986
+ form_dict = dict(form_data)
987
+
988
+ # Parse the form data using the renderer's logic
989
+ parsed_data = self.parse(form_dict)
990
+
991
+ # Validate against the model - allow ValidationError to propagate
992
+ validated_model = self.model_class.model_validate(parsed_data)
993
+ logger.info(f"Request validation successful for form '{self.name}'")
994
+
995
+ return validated_model
996
+
997
+ def form_id(self) -> str:
998
+ """
999
+ Get the standard form ID for this renderer.
1000
+
1001
+ Returns:
1002
+ The form ID string that should be used for the HTML form element
1003
+ """
1004
+ return f"{self.name}-form"