fh-pydantic-form 0.1.2__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.

@@ -0,0 +1,713 @@
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
+ )
13
+
14
+ import fasthtml.common as fh
15
+ import monsterui.all as mui
16
+ from fastcore.xml import FT
17
+ from pydantic import BaseModel
18
+
19
+ from fh_pydantic_form.field_renderers import (
20
+ BaseFieldRenderer,
21
+ ListFieldRenderer,
22
+ StringFieldRenderer,
23
+ )
24
+ from fh_pydantic_form.form_parser import (
25
+ _identify_list_fields,
26
+ _parse_list_fields,
27
+ _parse_non_list_fields,
28
+ )
29
+ from fh_pydantic_form.registry import FieldRendererRegistry
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ # TypeVar for generic model typing
34
+ ModelType = TypeVar("ModelType", bound=BaseModel)
35
+
36
+
37
+ def list_manipulation_js():
38
+ return fh.Script("""
39
+ function moveItem(buttonElement, direction) {
40
+ // Find the accordion item (list item)
41
+ const item = buttonElement.closest('li');
42
+ if (!item) return;
43
+
44
+ const container = item.parentElement;
45
+ if (!container) return;
46
+
47
+ // Find the sibling in the direction we want to move
48
+ const sibling = direction === 'up' ? item.previousElementSibling : item.nextElementSibling;
49
+
50
+ if (sibling) {
51
+ if (direction === 'up') {
52
+ container.insertBefore(item, sibling);
53
+ } else {
54
+ // Insert item after the next sibling
55
+ container.insertBefore(item, sibling.nextElementSibling);
56
+ }
57
+ // Update button states after move
58
+ updateMoveButtons(container);
59
+ }
60
+ }
61
+
62
+ function moveItemUp(buttonElement) {
63
+ moveItem(buttonElement, 'up');
64
+ }
65
+
66
+ function moveItemDown(buttonElement) {
67
+ moveItem(buttonElement, 'down');
68
+ }
69
+
70
+ // Function to update button states (disable if at top/bottom)
71
+ function updateMoveButtons(container) {
72
+ const items = container.querySelectorAll(':scope > li');
73
+ items.forEach((item, index) => {
74
+ const upButton = item.querySelector('button[onclick^="moveItemUp"]');
75
+ const downButton = item.querySelector('button[onclick^="moveItemDown"]');
76
+
77
+ if (upButton) upButton.disabled = (index === 0);
78
+ if (downButton) downButton.disabled = (index === items.length - 1);
79
+ });
80
+ }
81
+
82
+ // Function to toggle all list items open or closed
83
+ function toggleListItems(containerId) {
84
+ const containerElement = document.getElementById(containerId);
85
+ if (!containerElement) {
86
+ console.warn('Accordion container not found:', containerId);
87
+ return;
88
+ }
89
+
90
+ // Find all direct li children (the accordion items)
91
+ const items = Array.from(containerElement.children).filter(el => el.tagName === 'LI');
92
+ if (!items.length) {
93
+ return; // No items to toggle
94
+ }
95
+
96
+ // Determine if we should open all (if any are closed) or close all (if all are open)
97
+ const shouldOpen = items.some(item => !item.classList.contains('uk-open'));
98
+
99
+ // Toggle each item accordingly
100
+ items.forEach(item => {
101
+ if (shouldOpen) {
102
+ // Open the item if it's not already open
103
+ if (!item.classList.contains('uk-open')) {
104
+ item.classList.add('uk-open');
105
+ // Make sure the content is expanded
106
+ const content = item.querySelector('.uk-accordion-content');
107
+ if (content) {
108
+ content.style.height = 'auto';
109
+ content.hidden = false;
110
+ }
111
+ }
112
+ } else {
113
+ // Close the item
114
+ item.classList.remove('uk-open');
115
+ // Hide the content
116
+ const content = item.querySelector('.uk-accordion-content');
117
+ if (content) {
118
+ content.hidden = true;
119
+ }
120
+ }
121
+ });
122
+
123
+ // Attempt to use UIkit's API if available (more reliable)
124
+ if (window.UIkit && UIkit.accordion) {
125
+ try {
126
+ const accordion = UIkit.accordion(containerElement);
127
+ if (accordion) {
128
+ // In UIkit, indices typically start at 0
129
+ items.forEach((item, index) => {
130
+ const isOpen = item.classList.contains('uk-open');
131
+ if (shouldOpen && !isOpen) {
132
+ accordion.toggle(index, false); // Open item without animation
133
+ } else if (!shouldOpen && isOpen) {
134
+ accordion.toggle(index, false); // Close item without animation
135
+ }
136
+ });
137
+ }
138
+ } catch (e) {
139
+ console.warn('UIkit accordion API failed, falling back to manual toggle', e);
140
+ // The manual toggle above should have handled it
141
+ }
142
+ }
143
+ }
144
+
145
+ // Wait for the DOM to be fully loaded before initializing
146
+ document.addEventListener('DOMContentLoaded', () => {
147
+ // Initialize button states for elements present on initial load
148
+ document.querySelectorAll('[id$="_items_container"]').forEach(container => {
149
+ updateMoveButtons(container);
150
+ });
151
+
152
+ // Now it's safe to attach the HTMX event listener to document.body
153
+ document.body.addEventListener('htmx:afterSwap', function(event) {
154
+ // Check if this is an insert (afterend swap)
155
+ const targetElement = event.detail.target;
156
+ const requestElement = event.detail.requestConfig?.elt;
157
+ const swapStrategy = requestElement ? requestElement.getAttribute('hx-swap') : null;
158
+
159
+ if (swapStrategy === 'afterend') {
160
+ // For insertions, get the parent container of the original target
161
+ const listContainer = targetElement.closest('[id$="_items_container"]');
162
+ if (listContainer) {
163
+ updateMoveButtons(listContainer);
164
+ }
165
+ } else {
166
+ // Original logic for other swap types
167
+ const containers = event.detail.target.querySelectorAll('[id$="_items_container"]');
168
+ containers.forEach(container => {
169
+ updateMoveButtons(container);
170
+ });
171
+
172
+ // If the target itself is a container
173
+ if (event.detail.target.id && event.detail.target.id.endsWith('_items_container')) {
174
+ updateMoveButtons(event.detail.target);
175
+ }
176
+ }
177
+ });
178
+ });
179
+ """)
180
+
181
+
182
+ class PydanticForm(Generic[ModelType]):
183
+ """
184
+ Renders a form from a Pydantic model class
185
+
186
+ This class handles:
187
+ - Finding appropriate renderers for each field
188
+ - Managing field prefixes for proper form submission
189
+ - Creating the overall form structure
190
+ - Registering HTMX routes for list manipulation
191
+ - Parsing form data back to Pydantic model format
192
+ - Handling refresh and reset requests
193
+ - providing refresh and reset buttons
194
+ - validating request data against the model
195
+ """
196
+
197
+ def __init__(
198
+ self,
199
+ form_name: str,
200
+ model_class: Type[ModelType],
201
+ initial_values: Optional[ModelType] = None,
202
+ custom_renderers: Optional[List[Tuple[Type, Type[BaseFieldRenderer]]]] = None,
203
+ disabled: bool = False,
204
+ disabled_fields: Optional[List[str]] = None,
205
+ label_colors: Optional[Dict[str, str]] = None,
206
+ ):
207
+ """
208
+ Initialize the form renderer
209
+
210
+ Args:
211
+ form_name: Unique name for this form
212
+ model_class: The Pydantic model class to render
213
+ initial_values: Optional initial Pydantic model instance
214
+ custom_renderers: Optional list of tuples (field_type, renderer_cls) to register
215
+ disabled: Whether all form inputs should be disabled
216
+ disabled_fields: Optional list of top-level field names to disable specifically
217
+ label_colors: Optional dictionary mapping field names to label colors (CSS color values)
218
+ """
219
+ self.name = form_name
220
+ 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 {}
223
+ self.base_prefix = f"{form_name}_"
224
+ self.disabled = disabled
225
+ self.disabled_fields = (
226
+ disabled_fields or []
227
+ ) # Store as list for easier checking
228
+ self.label_colors = label_colors or {} # Store label colors mapping
229
+
230
+ # Register custom renderers with the global registry if provided
231
+ if custom_renderers:
232
+ registry = FieldRendererRegistry() # Get singleton instance
233
+ for field_type, renderer_cls in custom_renderers:
234
+ registry.register_type_renderer(field_type, renderer_cls)
235
+
236
+ def render_inputs(self) -> FT:
237
+ """
238
+ Render just the form inputs based on the model class (no form tag)
239
+
240
+ Returns:
241
+ A component containing the rendered form input fields
242
+ """
243
+ form_inputs = []
244
+ registry = FieldRendererRegistry() # Get singleton instance
245
+ logger.debug(
246
+ f"Starting render_inputs for form '{self.name}' with {len(self.model_class.model_fields)} fields"
247
+ )
248
+
249
+ for field_name, field_info in self.model_class.model_fields.items():
250
+ # Determine initial value
251
+ initial_value = (
252
+ self.values_dict.get(field_name) if self.values_dict else None
253
+ )
254
+
255
+ # Log the initial value type and a summary for debugging
256
+ if initial_value is not None:
257
+ value_type = type(initial_value).__name__
258
+ if isinstance(initial_value, (list, dict)):
259
+ value_size = f"size={len(initial_value)}"
260
+ else:
261
+ value_size = ""
262
+ logger.debug(f"Field '{field_name}': {value_type} {value_size}")
263
+ else:
264
+ logger.debug(
265
+ f"Field '{field_name}': None (will use default if available)"
266
+ )
267
+
268
+ # Use default if no value is provided
269
+ if initial_value is None:
270
+ if field_info.default is not None:
271
+ initial_value = field_info.default
272
+ logger.debug(f" - Using default value for '{field_name}'")
273
+ elif getattr(field_info, "default_factory", None) is not None:
274
+ try:
275
+ initial_value = field_info.default_factory()
276
+ logger.debug(f" - Using default_factory for '{field_name}'")
277
+ except Exception as e:
278
+ initial_value = None
279
+ logger.warning(
280
+ f" - Error in default_factory for '{field_name}': {e}"
281
+ )
282
+
283
+ # Get renderer from global registry
284
+ renderer_cls = registry.get_renderer(field_name, field_info)
285
+
286
+ if not renderer_cls:
287
+ # Fall back to StringFieldRenderer if no renderer found
288
+ renderer_cls = StringFieldRenderer
289
+ logger.warning(
290
+ f" - No renderer found for '{field_name}', falling back to StringFieldRenderer"
291
+ )
292
+
293
+ # Determine if this specific field should be disabled
294
+ is_field_disabled = self.disabled or (field_name in self.disabled_fields)
295
+ logger.debug(
296
+ f"Field '{field_name}' disabled state: {is_field_disabled} (Global: {self.disabled}, Specific: {field_name in self.disabled_fields})"
297
+ )
298
+
299
+ # Get label color for this field if specified
300
+ label_color = self.label_colors.get(field_name)
301
+
302
+ # Create and render the field
303
+ renderer = renderer_cls(
304
+ field_name=field_name,
305
+ field_info=field_info,
306
+ value=initial_value,
307
+ prefix=self.base_prefix,
308
+ disabled=is_field_disabled, # Pass the calculated disabled state
309
+ label_color=label_color, # Pass the label color if specified
310
+ )
311
+
312
+ rendered_field = renderer.render()
313
+ form_inputs.append(rendered_field)
314
+
315
+ # Create container for inputs, ensuring items stretch to full width
316
+ inputs_container = mui.DivVStacked(*form_inputs, cls="space-y-3 items-stretch")
317
+
318
+ # Define the ID for the wrapper div - this is what the HTMX request targets
319
+ form_content_wrapper_id = f"{self.name}-inputs-wrapper"
320
+ logger.debug(f"Creating form inputs wrapper with ID: {form_content_wrapper_id}")
321
+
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)
325
+
326
+ # ---- Form Renderer Methods (continued) ----
327
+
328
+ async def handle_refresh_request(self, req):
329
+ """
330
+ Handles the POST request for refreshing this form instance.
331
+
332
+ Args:
333
+ req: The request object
334
+
335
+ Returns:
336
+ HTML response with refreshed form inputs
337
+ """
338
+ form_data = await req.form()
339
+ form_dict = dict(form_data)
340
+ logger.info(f"Refresh request for form '{self.name}'")
341
+
342
+ parsed_data = {}
343
+ alert_ft = None # Changed to hold an FT object instead of a string
344
+ try:
345
+ # Use the instance's parse method directly
346
+
347
+ parsed_data = self.parse(form_dict)
348
+
349
+ except Exception as e:
350
+ logger.error(
351
+ f"Error parsing form data for refresh on form '{self.name}': {e}",
352
+ exc_info=True,
353
+ )
354
+ # Fallback: Use original initial data model dump if available, otherwise empty dict
355
+ parsed_data = (
356
+ self.initial_data_model.model_dump() if self.initial_data_model else {}
357
+ )
358
+ alert_ft = mui.Alert(
359
+ f"Warning: Could not fully process current form values for refresh. Display might not be fully updated. Error: {str(e)}",
360
+ cls=mui.AlertT.warning + " mb-4", # Add margin bottom
361
+ )
362
+
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
371
+
372
+ refreshed_inputs_component = temp_renderer.render_inputs()
373
+
374
+ if refreshed_inputs_component is None:
375
+ logger.error("render_inputs() returned None!")
376
+ alert_ft = mui.Alert(
377
+ "Critical error: Form refresh failed to generate content",
378
+ cls=mui.AlertT.error + " mb-4",
379
+ )
380
+ # Emergency fallback - use original renderer's inputs
381
+ refreshed_inputs_component = self.render_inputs()
382
+
383
+ # Return the FT components directly instead of creating a Response object
384
+ if alert_ft:
385
+ # Return both the alert and the form inputs as a tuple
386
+ return (alert_ft, refreshed_inputs_component)
387
+ else:
388
+ # Return just the form inputs
389
+ return refreshed_inputs_component
390
+
391
+ async def handle_reset_request(self) -> FT:
392
+ """
393
+ Handles the POST request for resetting this form instance to its initial values.
394
+
395
+ Returns:
396
+ HTML response with reset form inputs
397
+ """
398
+ logger.info(
399
+ f"Resetting form '{self.name}' to initial values. Initial model: {self.initial_data_model}"
400
+ )
401
+
402
+ # Create a temporary renderer with the original initial data
403
+ temp_renderer = PydanticForm(
404
+ form_name=self.name,
405
+ model_class=self.model_class,
406
+ initial_values=self.initial_data_model, # Use the originally stored model
407
+ )
408
+
409
+ # Render inputs with the initial data
410
+ reset_inputs_component = temp_renderer.render_inputs()
411
+
412
+ if reset_inputs_component is None:
413
+ logger.error(f"Reset for form '{self.name}' failed to render inputs.")
414
+ return mui.Alert("Error resetting form.", cls=mui.AlertT.error)
415
+
416
+ logger.info(
417
+ f"Reset form '{self.name}' successful. Component: {reset_inputs_component}"
418
+ )
419
+ return reset_inputs_component
420
+
421
+ def parse(self, form_dict: Dict[str, Any]) -> Dict[str, Any]:
422
+ """
423
+ Parse form data into a structure that matches the model.
424
+
425
+ This method processes form data that includes the form's base_prefix
426
+ and reconstructs the structure expected by the Pydantic model.
427
+
428
+ Args:
429
+ form_dict: Dictionary containing form field data (name -> value)
430
+
431
+ Returns:
432
+ Dictionary with parsed data in a structure matching the model
433
+ """
434
+
435
+ list_field_defs = _identify_list_fields(self.model_class)
436
+
437
+ # Parse non-list fields first - pass the base_prefix
438
+
439
+ result = _parse_non_list_fields(
440
+ form_dict, self.model_class, list_field_defs, self.base_prefix
441
+ )
442
+
443
+ # 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)
445
+
446
+ # Merge list results into the main result
447
+ result.update(list_results)
448
+
449
+ return result
450
+
451
+ def register_routes(self, app):
452
+ """
453
+ Register HTMX routes for list manipulation and form refresh
454
+
455
+ Args:
456
+ rt: The route registrar function from the application
457
+ """
458
+
459
+ # --- Register the form-specific refresh route ---
460
+ refresh_route_path = f"/form/{self.name}/refresh"
461
+
462
+ @app.route(refresh_route_path, methods=["POST"])
463
+ async def _instance_specific_refresh_handler(req):
464
+ """Handle form refresh request for this specific form instance"""
465
+ # Add entry point logging to confirm the route is being hit
466
+ logger.debug(f"Received POST request on {refresh_route_path}")
467
+ # Calls the instance method to handle the logic
468
+ return await self.handle_refresh_request(req)
469
+
470
+ logger.debug(
471
+ f"Registered refresh route for form '{self.name}' at {refresh_route_path}"
472
+ )
473
+
474
+ # --- Register the form-specific reset route ---
475
+ reset_route_path = f"/form/{self.name}/reset"
476
+
477
+ @app.route(reset_route_path, methods=["POST"])
478
+ async def _instance_specific_reset_handler(req):
479
+ """Handle form reset request for this specific form instance"""
480
+ logger.debug(f"Received POST request on {reset_route_path}")
481
+ # Calls the instance method to handle the logic
482
+ return await self.handle_reset_request()
483
+
484
+ logger.debug(
485
+ f"Registered reset route for form '{self.name}' at {reset_route_path}"
486
+ )
487
+
488
+ @app.route(f"/form/{self.name}/list/add/{{field_name}}")
489
+ async def post_list_add(req, field_name: str):
490
+ """
491
+ Handle adding an item to a list for this specific form
492
+
493
+ Args:
494
+ req: The request object
495
+ field_name: The name of the list field
496
+
497
+ Returns:
498
+ A component for the new list item
499
+ """
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
+ try:
526
+ # Ensure item_type is not None before checking attributes or type
527
+ if item_type:
528
+ # For Pydantic models, try to use model_construct for default values
529
+ if hasattr(item_type, "model_construct"):
530
+ try:
531
+ default_item = item_type.model_construct()
532
+ except Exception as e:
533
+ return fh.Li(
534
+ mui.Alert(
535
+ f"Error creating model instance: {str(e)}",
536
+ cls=mui.AlertT.error,
537
+ ),
538
+ cls="mb-2",
539
+ )
540
+ # Handle simple types with appropriate defaults
541
+ elif item_type is str:
542
+ default_item = ""
543
+ elif item_type is int:
544
+ default_item = 0
545
+ elif item_type is float:
546
+ default_item = 0.0
547
+ elif item_type is bool:
548
+ default_item = False
549
+ else:
550
+ default_item = None
551
+ else:
552
+ # Case where item_type itself was None (should ideally be caught earlier)
553
+ default_item = None
554
+ logger.warning(
555
+ f"item_type was None when trying to create default for {field_name}"
556
+ )
557
+ except Exception as e:
558
+ return fh.Li(
559
+ mui.Alert(
560
+ f"Error creating default item: {str(e)}", cls=mui.AlertT.error
561
+ ),
562
+ cls="mb-2",
563
+ )
564
+
565
+ # Generate a unique placeholder index
566
+ placeholder_idx = f"new_{int(pytime.time() * 1000)}"
567
+
568
+ # Create a list renderer and render the new item
569
+ list_renderer = ListFieldRenderer(
570
+ field_name=field_name,
571
+ field_info=field_info,
572
+ value=[], # Empty list, we only need to render one item
573
+ prefix=self.base_prefix, # Use the form's base prefix
574
+ )
575
+
576
+ # Render the new item card, set is_open=True to make it expanded by default
577
+ new_item_card = list_renderer._render_item_card(
578
+ default_item, placeholder_idx, item_type, is_open=True
579
+ )
580
+
581
+ return new_item_card
582
+
583
+ @app.route(f"/form/{self.name}/list/delete/{{field_name}}", methods=["DELETE"])
584
+ async def delete_list_item(req, field_name: str):
585
+ """
586
+ Handle deleting an item from a list for this specific form
587
+
588
+ Args:
589
+ req: The request object
590
+ field_name: The name of the list field
591
+
592
+ Returns:
593
+ Empty string to delete the target element
594
+ """
595
+ # Return empty string to delete the target element
596
+ logger.debug(
597
+ f"Received DELETE request for {field_name} for form '{self.name}'"
598
+ )
599
+ return fh.Response(status_code=200, content="")
600
+
601
+ def refresh_button(self, text: Optional[str] = None, **kwargs) -> FT:
602
+ """
603
+ Generates the HTML component for the form's refresh button.
604
+
605
+ Args:
606
+ text: Optional custom text for the button. Defaults to "Refresh Form Display".
607
+ **kwargs: Additional attributes to pass to the mui.Button component.
608
+
609
+ Returns:
610
+ A FastHTML component (mui.Button) representing the refresh button.
611
+ """
612
+ # Use provided text or default
613
+ button_text = text if text is not None else " Refresh Form Display"
614
+
615
+ # Define the target wrapper ID
616
+ form_content_wrapper_id = f"{self.name}-inputs-wrapper"
617
+
618
+ # Define the form ID to include
619
+ form_id = f"{self.name}-form"
620
+
621
+ # Define the target URL
622
+ refresh_url = f"/form/{self.name}/refresh"
623
+
624
+ # Base button attributes
625
+ button_attrs = {
626
+ "type": "button", # Prevent form submission
627
+ "hx_post": refresh_url, # Target the instance-specific route
628
+ "hx_target": f"#{form_content_wrapper_id}", # Target the wrapper Div ID
629
+ "hx_swap": "innerHTML",
630
+ "hx_trigger": "click", # Explicit trigger on click
631
+ "hx_include": f"#{form_id}", # Include all form fields in the request
632
+ "uk_tooltip": "Update the form display based on current values (e.g., list item titles)",
633
+ "cls": mui.ButtonT.secondary,
634
+ }
635
+
636
+ # Update with any additional attributes
637
+ button_attrs.update(kwargs)
638
+
639
+ # Create and return the button
640
+ return mui.Button(mui.UkIcon("refresh-ccw"), button_text, **button_attrs)
641
+
642
+ def reset_button(self, text: Optional[str] = None, **kwargs) -> FT:
643
+ """
644
+ Generates the HTML component for the form's reset button.
645
+
646
+ Args:
647
+ text: Optional custom text for the button. Defaults to "Reset to Initial".
648
+ **kwargs: Additional attributes to pass to the mui.Button component.
649
+
650
+ Returns:
651
+ A FastHTML component (mui.Button) representing the reset button.
652
+ """
653
+ # Use provided text or default
654
+ button_text = text if text is not None else " Reset to Initial"
655
+
656
+ # Define the target wrapper ID
657
+ form_content_wrapper_id = f"{self.name}-inputs-wrapper"
658
+
659
+ # Define the target URL
660
+ reset_url = f"/form/{self.name}/reset"
661
+
662
+ # Base button attributes
663
+ button_attrs = {
664
+ "type": "button", # Prevent form submission
665
+ "hx_post": reset_url, # Target the instance-specific route
666
+ "hx_target": f"#{form_content_wrapper_id}", # Target the wrapper Div ID
667
+ "hx_swap": "innerHTML",
668
+ "hx_confirm": "Are you sure you want to reset the form to its initial values? Any unsaved changes will be lost.",
669
+ "uk_tooltip": "Reset the form fields to their original values",
670
+ "cls": mui.ButtonT.destructive, # Use danger style to indicate destructive action
671
+ }
672
+
673
+ # Update with any additional attributes
674
+ button_attrs.update(kwargs)
675
+
676
+ # Create and return the button
677
+ return mui.Button(
678
+ mui.UkIcon("history"), # Icon representing reset/history
679
+ button_text,
680
+ **button_attrs,
681
+ )
682
+
683
+ async def model_validate_request(self, req: Any) -> ModelType:
684
+ """
685
+ Extracts form data from a request, parses it, and validates against the model.
686
+
687
+ This method encapsulates the common pattern of:
688
+ 1. Extracting form data from a request
689
+ 2. Converting it to a dictionary
690
+ 3. Parsing with the renderer's logic (handling prefixes, etc.)
691
+ 4. Validating against the Pydantic model
692
+
693
+ Args:
694
+ req: The request object (must have an awaitable .form() method)
695
+
696
+ Returns:
697
+ A validated instance of the model class
698
+
699
+ Raises:
700
+ ValidationError: If validation fails based on the model's rules
701
+ """
702
+ logger.debug(f"Validating request for form '{self.name}'")
703
+ form_data = await req.form()
704
+ form_dict = dict(form_data)
705
+
706
+ # Parse the form data using the renderer's logic
707
+ parsed_data = self.parse(form_dict)
708
+
709
+ # Validate against the model - allow ValidationError to propagate
710
+ validated_model = self.model_class.model_validate(parsed_data)
711
+ logger.info(f"Request validation successful for form '{self.name}'")
712
+
713
+ return validated_model