fh-pydantic-form 0.3.0__tar.gz → 0.3.2__tar.gz

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.

Files changed (22) hide show
  1. {fh_pydantic_form-0.3.0 → fh_pydantic_form-0.3.2}/.gitignore +4 -1
  2. {fh_pydantic_form-0.3.0 → fh_pydantic_form-0.3.2}/PKG-INFO +34 -4
  3. {fh_pydantic_form-0.3.0 → fh_pydantic_form-0.3.2}/README.md +33 -3
  4. {fh_pydantic_form-0.3.0 → fh_pydantic_form-0.3.2}/RELEASE_NOTES.md +51 -0
  5. {fh_pydantic_form-0.3.0 → fh_pydantic_form-0.3.2}/pyproject.toml +1 -1
  6. {fh_pydantic_form-0.3.0 → fh_pydantic_form-0.3.2}/src/fh_pydantic_form/comparison_form.py +104 -3
  7. {fh_pydantic_form-0.3.0 → fh_pydantic_form-0.3.2}/src/fh_pydantic_form/field_renderers.py +145 -61
  8. {fh_pydantic_form-0.3.0 → fh_pydantic_form-0.3.2}/src/fh_pydantic_form/form_parser.py +0 -3
  9. {fh_pydantic_form-0.3.0 → fh_pydantic_form-0.3.2}/src/fh_pydantic_form/form_renderer.py +81 -82
  10. {fh_pydantic_form-0.3.0 → fh_pydantic_form-0.3.2}/src/fh_pydantic_form/registry.py +0 -3
  11. {fh_pydantic_form-0.3.0 → fh_pydantic_form-0.3.2}/.github/workflows/build.yaml +0 -0
  12. {fh_pydantic_form-0.3.0 → fh_pydantic_form-0.3.2}/.github/workflows/publish.yaml +0 -0
  13. {fh_pydantic_form-0.3.0 → fh_pydantic_form-0.3.2}/.pre-commit-config.yaml +0 -0
  14. {fh_pydantic_form-0.3.0 → fh_pydantic_form-0.3.2}/LICENSE +0 -0
  15. {fh_pydantic_form-0.3.0 → fh_pydantic_form-0.3.2}/src/fh_pydantic_form/__init__.py +0 -0
  16. {fh_pydantic_form-0.3.0 → fh_pydantic_form-0.3.2}/src/fh_pydantic_form/color_utils.py +0 -0
  17. {fh_pydantic_form-0.3.0 → fh_pydantic_form-0.3.2}/src/fh_pydantic_form/constants.py +0 -0
  18. {fh_pydantic_form-0.3.0 → fh_pydantic_form-0.3.2}/src/fh_pydantic_form/defaults.py +0 -0
  19. {fh_pydantic_form-0.3.0 → fh_pydantic_form-0.3.2}/src/fh_pydantic_form/list_path.py +0 -0
  20. {fh_pydantic_form-0.3.0 → fh_pydantic_form-0.3.2}/src/fh_pydantic_form/py.typed +0 -0
  21. {fh_pydantic_form-0.3.0 → fh_pydantic_form-0.3.2}/src/fh_pydantic_form/type_helpers.py +0 -0
  22. {fh_pydantic_form-0.3.0 → fh_pydantic_form-0.3.2}/src/fh_pydantic_form/ui_style.py +0 -0
@@ -130,4 +130,7 @@ dmypy.json
130
130
  uv.lock
131
131
 
132
132
 
133
- .sesskey
133
+ .sesskey
134
+ *.db
135
+ *.db-shm
136
+ *.db-wal
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fh-pydantic-form
3
- Version: 0.3.0
3
+ Version: 0.3.2
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
@@ -30,15 +30,16 @@ Description-Content-Type: text/markdown
30
30
 
31
31
  **Generate HTML forms from Pydantic models for your FastHTML applications.**
32
32
 
33
- `fh-pydantic-form` simplifies creating web forms for [FastHTML](https://github.com/AnswerDotAI/fasthtml) by automatically generating the necessary HTML input elements based on your Pydantic model definitions. It integrates seamlessly with and leverages [MonsterUI](https://github.com/AnswerDotAI/monsterui) components for styling.
33
+ `fh-pydantic-form` simplifies creating web forms for [FastHTML](https://github.com/AnswerDotAI/fasthtml) by automatically generating the necessary HTML input elements based on your Pydantic model definitions. It integrates seamlessly with and leverages [MonsterUI](https://github.com/AnswerDotAI/monsterui) components for styling. This makes it ideal for annotation workflows for structured outputs: when the schema updates, so does your annotation app.
34
+
35
+ <img width="1072" alt="image" src="https://github.com/user-attachments/assets/9e189266-0118-4de2-a35c-1118f11b0581" />
34
36
 
35
- <img width="1348" alt="image" src="https://github.com/user-attachments/assets/59cc4f10-6858-41cb-80ed-e735a883cf20" />
36
37
 
37
38
 
38
39
 
39
40
  <details >
40
41
  <summary>show demo screen recording</summary>
41
- <video src="https://private-user-images.githubusercontent.com/27999937/436237879-feabf388-22af-43e6-b054-f103b8a1b6e6.mp4" controls="controls" style="max-width: 730px;">
42
+ <video src="https://private-user-images.githubusercontent.com/27999937/462832274-a00096d9-47ee-485b-af0a-e74838ec6c6d.mp4" controls="controls">
42
43
  </video>
43
44
  </details>
44
45
 
@@ -867,6 +868,34 @@ form_renderer = PydanticForm("my_form", MyModel, initial_values=initial_values_d
867
868
 
868
869
  The dictionary does not have to be complete, and we try to handle schema drift gracefully. If you exclude fields from the form, we fill those fields with the initial_values or the default values.
869
870
 
871
+ ### Reusing Form Configuration with Different Values
872
+
873
+ The `with_initial_values()` method allows you to create a new form instance with the same configuration but different initial values:
874
+
875
+ ```python
876
+ # Create a base form configuration
877
+ base_form = PydanticForm(
878
+ "product_form",
879
+ ProductModel,
880
+ disabled_fields=["id"],
881
+ label_colors={"name": "text-blue-600", "price": "text-green-600"},
882
+ spacing="compact"
883
+ )
884
+
885
+ # Create forms with different initial values using the same configuration
886
+ form_for_product_a = base_form.with_initial_values({"name": "Product A", "price": 29.99})
887
+ form_for_product_b = base_form.with_initial_values({"name": "Product B", "price": 45.50})
888
+
889
+ # Or with model instances
890
+ existing_product = ProductModel(name="Existing Product", price=19.99)
891
+ form_for_existing = base_form.with_initial_values(existing_product)
892
+ ```
893
+
894
+ This is particularly useful for:
895
+ - **Editing workflows** where you need the same form configuration for different records
896
+ - **Template forms** where you want to reuse styling and field configurations
897
+ - **Bulk operations** where you process multiple items with the same form structure
898
+
870
899
 
871
900
 
872
901
  ### Schema Drift Resilience
@@ -996,6 +1025,7 @@ form_renderer = PydanticForm(
996
1025
  | Method | Purpose |
997
1026
  |--------|---------|
998
1027
  | `render_inputs()` | Generate the HTML form inputs (without `<form>` wrapper) |
1028
+ | `with_initial_values(initial_values)` | Create a new form instance with same configuration but different initial values |
999
1029
  | `refresh_button(text=None, **kwargs)` | Create a refresh button component |
1000
1030
  | `reset_button(text=None, **kwargs)` | Create a reset button component |
1001
1031
  | `register_routes(app)` | Register HTMX endpoints for list manipulation |
@@ -5,15 +5,16 @@
5
5
 
6
6
  **Generate HTML forms from Pydantic models for your FastHTML applications.**
7
7
 
8
- `fh-pydantic-form` simplifies creating web forms for [FastHTML](https://github.com/AnswerDotAI/fasthtml) by automatically generating the necessary HTML input elements based on your Pydantic model definitions. It integrates seamlessly with and leverages [MonsterUI](https://github.com/AnswerDotAI/monsterui) components for styling.
8
+ `fh-pydantic-form` simplifies creating web forms for [FastHTML](https://github.com/AnswerDotAI/fasthtml) by automatically generating the necessary HTML input elements based on your Pydantic model definitions. It integrates seamlessly with and leverages [MonsterUI](https://github.com/AnswerDotAI/monsterui) components for styling. This makes it ideal for annotation workflows for structured outputs: when the schema updates, so does your annotation app.
9
+
10
+ <img width="1072" alt="image" src="https://github.com/user-attachments/assets/9e189266-0118-4de2-a35c-1118f11b0581" />
9
11
 
10
- <img width="1348" alt="image" src="https://github.com/user-attachments/assets/59cc4f10-6858-41cb-80ed-e735a883cf20" />
11
12
 
12
13
 
13
14
 
14
15
  <details >
15
16
  <summary>show demo screen recording</summary>
16
- <video src="https://private-user-images.githubusercontent.com/27999937/436237879-feabf388-22af-43e6-b054-f103b8a1b6e6.mp4" controls="controls" style="max-width: 730px;">
17
+ <video src="https://private-user-images.githubusercontent.com/27999937/462832274-a00096d9-47ee-485b-af0a-e74838ec6c6d.mp4" controls="controls">
17
18
  </video>
18
19
  </details>
19
20
 
@@ -842,6 +843,34 @@ form_renderer = PydanticForm("my_form", MyModel, initial_values=initial_values_d
842
843
 
843
844
  The dictionary does not have to be complete, and we try to handle schema drift gracefully. If you exclude fields from the form, we fill those fields with the initial_values or the default values.
844
845
 
846
+ ### Reusing Form Configuration with Different Values
847
+
848
+ The `with_initial_values()` method allows you to create a new form instance with the same configuration but different initial values:
849
+
850
+ ```python
851
+ # Create a base form configuration
852
+ base_form = PydanticForm(
853
+ "product_form",
854
+ ProductModel,
855
+ disabled_fields=["id"],
856
+ label_colors={"name": "text-blue-600", "price": "text-green-600"},
857
+ spacing="compact"
858
+ )
859
+
860
+ # Create forms with different initial values using the same configuration
861
+ form_for_product_a = base_form.with_initial_values({"name": "Product A", "price": 29.99})
862
+ form_for_product_b = base_form.with_initial_values({"name": "Product B", "price": 45.50})
863
+
864
+ # Or with model instances
865
+ existing_product = ProductModel(name="Existing Product", price=19.99)
866
+ form_for_existing = base_form.with_initial_values(existing_product)
867
+ ```
868
+
869
+ This is particularly useful for:
870
+ - **Editing workflows** where you need the same form configuration for different records
871
+ - **Template forms** where you want to reuse styling and field configurations
872
+ - **Bulk operations** where you process multiple items with the same form structure
873
+
845
874
 
846
875
 
847
876
  ### Schema Drift Resilience
@@ -971,6 +1000,7 @@ form_renderer = PydanticForm(
971
1000
  | Method | Purpose |
972
1001
  |--------|---------|
973
1002
  | `render_inputs()` | Generate the HTML form inputs (without `<form>` wrapper) |
1003
+ | `with_initial_values(initial_values)` | Create a new form instance with same configuration but different initial values |
974
1004
  | `refresh_button(text=None, **kwargs)` | Create a refresh button component |
975
1005
  | `reset_button(text=None, **kwargs)` | Create a reset button component |
976
1006
  | `register_routes(app)` | Register HTMX endpoints for list manipulation |
@@ -1,5 +1,56 @@
1
1
  # Release Notes
2
2
 
3
+ ## Version 0.3.2 (2025-07-05)
4
+
5
+ ### 🔧 UI/UX Improvements
6
+
7
+ #### Form Interaction Enhancements
8
+ - **IMPROVED**: Better handling of falsy values in StringFieldRenderer for more robust form inputs
9
+ - **ENHANCED**: Accordion state preservation across refresh operations for improved user experience
10
+ - **FIXED**: Dropdown events no longer trigger accordion sync in comparison forms, preventing UI conflicts
11
+
12
+ #### String Field Enhancements
13
+ - **NEW**: Textarea input support for better handling of longer text fields
14
+ - **IMPROVED**: StringFieldRenderer robustness with better code quality and error handling
15
+ - **ENHANCED**: Fallback handling for string values with comprehensive test coverage
16
+
17
+ #### List Management Improvements
18
+ - **ENHANCED**: List items now behave like BaseModel accordions for consistent UI patterns
19
+ - **IMPROVED**: Better default values for new list items
20
+ - **FIXED**: Nested list item accordion synchronization in ComparisonForm
21
+
22
+ ### 🐛 Bug Fixes
23
+
24
+ #### Performance & Logging
25
+ - **FIXED**: Reduced excessive DEBUG logging messages for cleaner console output
26
+ - **IMPROVED**: Overall application performance with optimized refresh operations
27
+
28
+ #### Scroll & Navigation
29
+ - **NEW**: Scroll position preservation during form refresh operations
30
+ - **ENHANCED**: UI improvements for refresh and reset actions with better visual feedback
31
+
32
+ ### 📚 Documentation & Examples
33
+
34
+ #### Enhanced Examples
35
+ - **UPDATED**: Annotation example with cleanup and improvements
36
+ - **IMPROVED**: Comparison example with better demonstration of features
37
+ - **ENHANCED**: README.md with updated documentation and usage examples
38
+
39
+ ### 📊 Statistics
40
+ - **24 commits** since v0.3.1
41
+ - Focus on UI polish, form interaction improvements, and string field enhancements
42
+ - Improved logging and performance optimizations
43
+ - Enhanced documentation and examples
44
+
45
+ **Key Highlights:**
46
+ This release focuses on improving form interaction quality, with particular attention to string field handling, scroll preservation, and accordion state management. The textarea support and better falsy value handling make forms more robust for real-world usage scenarios.
47
+
48
+ ---
49
+
50
+ ## Version 0.3.1 (2025-06-24)
51
+
52
+ - fix datetime.time renderer when format is not HH:MM
53
+
3
54
  ## Version 0.3.0 (2025-06-23)
4
55
 
5
56
  ### 🎉 Major Features
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fh-pydantic-form"
3
- version = "0.3.0"
3
+ version = "0.3.2"
4
4
  description = "a library to turn any pydantic BaseModel object into a fasthtml/monsterui input form"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -52,6 +52,16 @@ window.fhpfInitComparisonSync = function initComparisonSync(){
52
52
  const sourceLi = ev.target.closest('li');
53
53
  if (!sourceLi) return;
54
54
 
55
+ // Skip if this event is from a select/dropdown element
56
+ if (ev.target.closest('uk-select, select, [uk-select]')) {
57
+ return;
58
+ }
59
+
60
+ // Skip if this is a nested list item (let mirrorNestedListItems handle it)
61
+ if (sourceLi.closest('[id$="_items_container"]')) {
62
+ return;
63
+ }
64
+
55
65
  // Find our grid-cell wrapper (both left & right share the same data-path)
56
66
  const cell = sourceLi.closest('[data-path]');
57
67
  if (!cell) return;
@@ -91,7 +101,99 @@ window.fhpfInitComparisonSync = function initComparisonSync(){
91
101
  });
92
102
  }
93
103
 
94
- // 3) Wrap the list-toggle so ListFieldRenderer accordions sync too
104
+ // 3) Sync nested list item accordions (individual items within lists)
105
+ UIkit.util.on(
106
+ document,
107
+ 'show hide',
108
+ '[id$="_items_container"] > li', // only list items within items containers
109
+ mirrorNestedListItems
110
+ );
111
+
112
+ function mirrorNestedListItems(ev) {
113
+ const sourceLi = ev.target.closest('li');
114
+ if (!sourceLi) return;
115
+
116
+ // Skip if this event is from a select/dropdown element
117
+ if (ev.target.closest('uk-select, select, [uk-select]')) {
118
+ return;
119
+ }
120
+
121
+ // Skip if this event was triggered by our own sync
122
+ if (sourceLi.dataset.syncDisabled) {
123
+ return;
124
+ }
125
+
126
+ // Find the list container (items_container) that contains this item
127
+ const listContainer = sourceLi.closest('[id$="_items_container"]');
128
+ if (!listContainer) return;
129
+
130
+ // Find the grid cell wrapper with data-path
131
+ const cell = listContainer.closest('[data-path]');
132
+ if (!cell) return;
133
+ const path = cell.dataset.path;
134
+
135
+ // Determine index of this <li> within its list container
136
+ const listAccordion = sourceLi.parentElement;
137
+ const idx = Array.prototype.indexOf.call(listAccordion.children, sourceLi);
138
+ const opening = ev.type === 'show';
139
+
140
+ // Mirror on the other side
141
+ document
142
+ .querySelectorAll(`[data-path="${path}"]`)
143
+ .forEach(peerCell => {
144
+ if (peerCell === cell) return;
145
+
146
+ // Find the peer's list container
147
+ const peerListContainer = peerCell.querySelector('[id$="_items_container"]');
148
+ if (!peerListContainer) return;
149
+
150
+ // The list container IS the accordion itself (not a wrapper around it)
151
+ let peerListAccordion;
152
+ if (peerListContainer.hasAttribute('uk-accordion') && peerListContainer.tagName === 'UL') {
153
+ peerListAccordion = peerListContainer;
154
+ } else {
155
+ peerListAccordion = peerListContainer.querySelector('ul[uk-accordion]');
156
+ }
157
+
158
+ if (!peerListAccordion || idx >= peerListAccordion.children.length) return;
159
+
160
+ const peerLi = peerListAccordion.children[idx];
161
+ const peerContent = peerLi.querySelector('.uk-accordion-content');
162
+
163
+ // Prevent event cascading by temporarily disabling our own event listener
164
+ if (peerLi.dataset.syncDisabled) {
165
+ return;
166
+ }
167
+
168
+ // Mark this item as being synced to prevent loops
169
+ peerLi.dataset.syncDisabled = 'true';
170
+
171
+ // Check current state and only sync if different
172
+ const currentlyOpen = peerLi.classList.contains('uk-open');
173
+
174
+ if (currentlyOpen !== opening) {
175
+ if (opening) {
176
+ peerLi.classList.add('uk-open');
177
+ if (peerContent) {
178
+ peerContent.hidden = false;
179
+ peerContent.style.height = 'auto';
180
+ }
181
+ } else {
182
+ peerLi.classList.remove('uk-open');
183
+ if (peerContent) {
184
+ peerContent.hidden = true;
185
+ }
186
+ }
187
+ }
188
+
189
+ // Re-enable sync after a short delay
190
+ setTimeout(() => {
191
+ delete peerLi.dataset.syncDisabled;
192
+ }, 100);
193
+ });
194
+ }
195
+
196
+ // 4) Wrap the list-toggle so ListFieldRenderer accordions sync too
95
197
  if (typeof window.toggleListItems === 'function' && !window.__listSyncWrapped) {
96
198
  // guard to only wrap once
97
199
  window.__listSyncWrapped = true;
@@ -455,13 +557,11 @@ class ComparisonForm(Generic[ModelType]):
455
557
  reset_path = f"/compare/{self.name}/{side}/reset"
456
558
  reset_handler = create_reset_handler(form, side, label)
457
559
  app.route(reset_path, methods=["POST"])(reset_handler)
458
- logger.debug(f"Registered comparison reset route: {reset_path}")
459
560
 
460
561
  # Refresh route
461
562
  refresh_path = f"/compare/{self.name}/{side}/refresh"
462
563
  refresh_handler = create_refresh_handler(form, side, label)
463
564
  app.route(refresh_path, methods=["POST"])(refresh_handler)
464
- logger.debug(f"Registered comparison refresh route: {refresh_path}")
465
565
 
466
566
  def form_wrapper(self, content: FT, form_id: Optional[str] = None) -> FT:
467
567
  """
@@ -506,6 +606,7 @@ class ComparisonForm(Generic[ModelType]):
506
606
  kwargs.setdefault("hx_target", f"#{form.name}-inputs-wrapper")
507
607
  kwargs.setdefault("hx_swap", "innerHTML")
508
608
  kwargs.setdefault("hx_include", prefix_selector)
609
+ kwargs.setdefault("hx_preserve", "scroll")
509
610
 
510
611
  # Delegate to the underlying form's button method
511
612
  button_method = getattr(form, f"{action}_button")
@@ -272,9 +272,6 @@ class MetricsRendererMixin:
272
272
  existing_style = node.attrs.get("style", "")
273
273
  node.attrs["style"] = highlight_css + " " + existing_style
274
274
  highlight_count += 1
275
- logger.debug(
276
- f"Applied highlight to tag={getattr(node, 'tag', 'unknown')}, attrs={list(node.attrs.keys()) if hasattr(node, 'attrs') else []}"
277
- )
278
275
 
279
276
  # Process children if they exist
280
277
  if hasattr(node, "children") and node.children:
@@ -285,7 +282,7 @@ class MetricsRendererMixin:
285
282
  apply_highlight(element)
286
283
 
287
284
  if highlight_count == 0:
288
- logger.debug("No form controls found to highlight in element tree")
285
+ pass # No form controls found to highlight
289
286
 
290
287
  return element
291
288
 
@@ -603,13 +600,9 @@ class StringFieldRenderer(BaseFieldRenderer):
603
600
  Render input element for the field
604
601
 
605
602
  Returns:
606
- A TextInput component appropriate for string values
603
+ A TextArea component appropriate for string values
607
604
  """
608
- # is_field_required = (
609
- # not self.is_optional
610
- # and self.field_info.default is None
611
- # and getattr(self.field_info, "default_factory", None) is None
612
- # )
605
+
613
606
  has_default = get_default(self.field_info) is not _UNSET
614
607
  is_field_required = not self.is_optional and not has_default
615
608
 
@@ -625,21 +618,40 @@ class StringFieldRenderer(BaseFieldRenderer):
625
618
  if input_spacing_cls:
626
619
  input_cls_parts.append(input_spacing_cls)
627
620
 
621
+ # Calculate appropriate number of rows based on content
622
+ if isinstance(self.value, str) and self.value:
623
+ # Count line breaks
624
+ line_count = len(self.value.split("\n"))
625
+ # Also consider content length for very long single lines (assuming ~60 chars per line)
626
+ char_count = len(self.value)
627
+ estimated_lines = max(line_count, (char_count // 60) + 1)
628
+ # Compact bounds: minimum 1 row, maximum 3 rows
629
+ rows = min(max(estimated_lines, 1), 3)
630
+ else:
631
+ # Single row for empty content
632
+ rows = 1
633
+
628
634
  input_attrs = {
629
- "value": self.value or "",
630
635
  "id": self.field_name,
631
636
  "name": self.field_name,
632
- "type": "text",
633
637
  "placeholder": placeholder_text,
634
638
  "required": is_field_required,
635
639
  "cls": " ".join(input_cls_parts),
640
+ "rows": rows,
641
+ "style": "resize: vertical; min-height: 2.5rem; padding: 0.5rem; line-height: 1.25;",
636
642
  }
637
643
 
638
644
  # Only add the disabled attribute if the field should actually be disabled
639
645
  if self.disabled:
640
646
  input_attrs["disabled"] = True
641
647
 
642
- return mui.Input(**input_attrs)
648
+ # Convert value to string representation, handling None and all other types
649
+ if self.value is None:
650
+ display_value = ""
651
+ else:
652
+ display_value = str(self.value)
653
+
654
+ return mui.TextArea(display_value, **input_attrs)
643
655
 
644
656
 
645
657
  class NumberFieldRenderer(BaseFieldRenderer):
@@ -820,11 +832,19 @@ class TimeFieldRenderer(BaseFieldRenderer):
820
832
  A TimeInput component appropriate for time values
821
833
  """
822
834
  formatted_value = ""
823
- if (
824
- isinstance(self.value, str) and len(self.value) == 5
825
- ): # Basic check for HH:MM format
826
- # Assume it's the correct string format from the form
827
- formatted_value = self.value
835
+ if isinstance(self.value, str):
836
+ # Try to parse the time string using various formats
837
+ time_formats = ["%H:%M", "%H:%M:%S", "%H:%M:%S.%f"]
838
+
839
+ for fmt in time_formats:
840
+ try:
841
+ from datetime import datetime
842
+
843
+ parsed_time = datetime.strptime(self.value, fmt).time()
844
+ formatted_value = parsed_time.strftime("%H:%M")
845
+ break
846
+ except ValueError:
847
+ continue
828
848
  elif isinstance(self.value, time):
829
849
  formatted_value = self.value.strftime("%H:%M") # HH:MM
830
850
 
@@ -1386,73 +1406,110 @@ class ListFieldRenderer(BaseFieldRenderer):
1386
1406
  label_span.attrs["uk-tooltip"] = description
1387
1407
  label_span.attrs["title"] = description
1388
1408
 
1389
- # Decorate the label span with the metric badge (bullet)
1390
- label_span = self._decorate_label(label_span, self.metric_entry)
1409
+ # Metric decoration will be applied to the title_component below
1391
1410
 
1392
- # Construct the container ID that will be generated by render_input()
1393
- container_id = self._container_id()
1394
-
1395
- # Only add refresh icon if we have a form name
1396
- if form_name:
1411
+ # Only add refresh icon if we have a form name and field is not disabled
1412
+ if form_name and not self.disabled:
1397
1413
  # Create the smaller icon component
1398
1414
  refresh_icon_component = mui.UkIcon(
1399
1415
  "refresh-ccw",
1400
1416
  cls="w-3 h-3 text-gray-500 hover:text-blue-600", # Smaller size
1401
1417
  )
1402
1418
 
1403
- # Create the clickable span wrapper for the icon
1404
- # Use prefix-based selector to include only fields from this form
1405
- hx_include_selector = (
1406
- f"form [name^='{self.prefix}']" if self.prefix else "closest form"
1407
- )
1408
-
1409
1419
  # Use override endpoint if provided (for ComparisonForm), otherwise use standard form refresh
1410
1420
  refresh_url = (
1411
1421
  self._refresh_endpoint_override or f"/form/{form_name}/refresh"
1412
1422
  )
1413
1423
 
1414
- refresh_icon_trigger = fh.Span(
1424
+ # Get container ID for accordion state preservation
1425
+ container_id = self._container_id()
1426
+
1427
+ # Create refresh icon as a button with aggressive styling reset
1428
+ refresh_icon_trigger = mui.Button(
1415
1429
  refresh_icon_component,
1416
- cls="ml-1 inline-block align-middle cursor-pointer", # Add margin, ensure inline-like behavior
1430
+ type="button", # Prevent form submission
1417
1431
  hx_post=refresh_url,
1418
1432
  hx_target=f"#{form_name}-inputs-wrapper",
1419
1433
  hx_swap="innerHTML",
1420
- hx_include=hx_include_selector, # Use prefix-based selector
1434
+ hx_trigger="click", # Explicit trigger on click
1435
+ hx_include="closest form", # Include all form fields from the enclosing form
1436
+ hx_preserve="scroll",
1421
1437
  uk_tooltip="Refresh form display to update list summaries",
1422
- # Prevent 'toggleListItems' on the parent from firing
1423
- onclick="event.stopPropagation();",
1438
+ style="all: unset; display: inline-flex; align-items: center; cursor: pointer; padding: 0 0.5rem; margin-left: 0.5rem;",
1439
+ **{
1440
+ "hx-on::before-request": f"window.saveAccordionState && window.saveAccordionState('{container_id}')"
1441
+ },
1442
+ **{
1443
+ "hx-on::after-swap": f"window.restoreAccordionState && window.restoreAccordionState('{container_id}')"
1444
+ },
1424
1445
  )
1425
1446
 
1426
- # Combine label and icon
1427
- label_with_icon = fh.Div(
1428
- label_span, # Use the properly styled label span
1429
- refresh_icon_trigger,
1430
- cls="flex items-center cursor-pointer", # Added cursor-pointer
1431
- onclick=f"toggleListItems('{container_id}'); return false;", # Add click handler
1447
+ # Combine label and icon - put refresh icon in a separate div to isolate it
1448
+ title_component = fh.Div(
1449
+ fh.Div(
1450
+ label_span, # Use the properly styled label span
1451
+ cls="flex-1", # Take up remaining space
1452
+ ),
1453
+ fh.Div(
1454
+ refresh_icon_trigger,
1455
+ cls="flex-shrink-0 px-1", # Don't shrink, add horizontal padding for larger click area
1456
+ onclick="event.stopPropagation();", # Isolate the refresh icon area
1457
+ ),
1458
+ cls="flex items-center",
1432
1459
  )
1433
1460
  else:
1434
- # If no form name, just use the styled label but still make it clickable
1435
- label_with_icon = fh.Div(
1461
+ # If no form name, just use the styled label
1462
+ title_component = fh.Div(
1436
1463
  label_span, # Use the properly styled label span
1437
- cls="flex items-center cursor-pointer", # Added cursor-pointer
1438
- onclick=f"toggleListItems('{container_id}'); return false;", # Add click handler
1439
- uk_tooltip="Click to toggle all items open/closed",
1464
+ cls="flex items-center", # Remove cursor-pointer and click handler
1440
1465
  )
1441
1466
 
1442
- # Return container with label+icon and input
1443
- field_element = fh.Div(
1444
- label_with_icon,
1445
- self.render_input(),
1446
- cls=f"{spacing('outer_margin', self.spacing)} w-full",
1467
+ # Apply metrics decoration to title (bullet only, no border)
1468
+ title_component = self._decorate_metrics(
1469
+ title_component, self.metric_entry, scope=DecorationScope.BULLET
1447
1470
  )
1448
1471
 
1449
- # Apply metrics decoration if available
1450
- return self._decorate_metrics(
1451
- field_element,
1452
- self.metric_entry,
1453
- scope=DecorationScope.BORDER,
1472
+ # Compute border color for the wrapper accordion
1473
+ border_color = self._metric_border_color(self.metric_entry)
1474
+ li_style = {}
1475
+ if border_color:
1476
+ li_style["style"] = (
1477
+ f"border-left: 4px solid {border_color}; padding-left: 0.25rem;"
1478
+ )
1479
+
1480
+ # Create the wrapper AccordionItem that contains all the list items
1481
+ list_content = self.render_input()
1482
+
1483
+ # Define unique IDs for the wrapper accordion
1484
+ wrapper_item_id = f"{self.field_name}_wrapper_item"
1485
+ wrapper_accordion_id = f"{self.field_name}_wrapper_accordion"
1486
+
1487
+ # Create the wrapper AccordionItem
1488
+ wrapper_accordion_item = mui.AccordionItem(
1489
+ title_component, # Title component with label and refresh icon
1490
+ list_content, # Content component (the list items)
1491
+ open=True, # Open by default
1492
+ li_kwargs={
1493
+ "id": wrapper_item_id,
1494
+ **li_style,
1495
+ },
1496
+ cls=spacing("outer_margin", self.spacing),
1497
+ )
1498
+
1499
+ # Wrap in an Accordion container
1500
+ accordion_cls = spacing_many(
1501
+ ["accordion_divider", "accordion_content"], self.spacing
1502
+ )
1503
+ wrapper_accordion = mui.Accordion(
1504
+ wrapper_accordion_item,
1505
+ id=wrapper_accordion_id,
1506
+ multiple=True,
1507
+ collapsible=True,
1508
+ cls=f"{accordion_cls} w-full".strip(),
1454
1509
  )
1455
1510
 
1511
+ return wrapper_accordion
1512
+
1456
1513
  def render_input(self) -> FT:
1457
1514
  """
1458
1515
  Render a list of items with add/delete/move capabilities
@@ -1553,12 +1610,12 @@ class ListFieldRenderer(BaseFieldRenderer):
1553
1610
  cls=mui.AlertT.info,
1554
1611
  )
1555
1612
 
1556
- # Return the complete component
1613
+ # Return the complete component (minimal styling since it's now wrapped in an accordion)
1557
1614
  t = self.spacing
1558
1615
  return fh.Div(
1559
1616
  accordion,
1560
1617
  empty_state,
1561
- cls=f"{spacing('outer_margin', t)} {spacing('card_border', t)} rounded-md {spacing('padding', t)}".strip(),
1618
+ cls=f"{spacing('padding', t)}".strip(), # Keep padding for content, remove border and margin
1562
1619
  )
1563
1620
 
1564
1621
  def _render_item_card(self, item, idx, item_type, is_open=False) -> FT:
@@ -1590,6 +1647,33 @@ class ListFieldRenderer(BaseFieldRenderer):
1590
1647
  is_model = hasattr(item_type, "model_fields")
1591
1648
 
1592
1649
  # --- Generate item summary for the accordion title ---
1650
+ # Create a user-friendly display index
1651
+ if isinstance(idx, str) and idx.startswith("new_"):
1652
+ # Get the type name for new items
1653
+ if is_model:
1654
+ # For BaseModel types, use the class name
1655
+ type_name = item_type.__name__
1656
+ else:
1657
+ # For simple types, use a friendly name
1658
+ type_name_map = {
1659
+ str: "String",
1660
+ int: "Number",
1661
+ float: "Number",
1662
+ bool: "Boolean",
1663
+ date: "Date",
1664
+ time: "Time",
1665
+ }
1666
+ type_name = type_name_map.get(
1667
+ item_type,
1668
+ item_type.__name__
1669
+ if hasattr(item_type, "__name__")
1670
+ else str(item_type),
1671
+ )
1672
+
1673
+ display_idx = f"New {type_name}"
1674
+ else:
1675
+ display_idx = str(idx)
1676
+
1593
1677
  if is_model:
1594
1678
  try:
1595
1679
  # Determine how to get the string representation based on item type
@@ -1610,7 +1694,7 @@ class ListFieldRenderer(BaseFieldRenderer):
1610
1694
 
1611
1695
  if model_for_display is not None:
1612
1696
  # Use the model's __str__ method
1613
- item_summary_text = f"{idx}: {str(model_for_display)}"
1697
+ item_summary_text = f"{display_idx}: {str(model_for_display)}"
1614
1698
  else:
1615
1699
  # Fallback for None or unexpected types
1616
1700
  item_summary_text = f"{item_type.__name__}: (Unknown format: {type(item).__name__})"
@@ -1635,7 +1719,7 @@ class ListFieldRenderer(BaseFieldRenderer):
1635
1719
  )
1636
1720
  item_summary_text = f"{item_type.__name__}: (Error displaying item)"
1637
1721
  else:
1638
- item_summary_text = f"{idx}: {str(item)}"
1722
+ item_summary_text = f"{display_idx}: {str(item)}"
1639
1723
 
1640
1724
  # --- Render item content elements ---
1641
1725
  item_content_elements = []
@@ -81,7 +81,6 @@ def _parse_non_list_fields(
81
81
 
82
82
  # Skip SkipJsonSchema fields - they should not be parsed from form data
83
83
  if _is_skip_json_schema_field(field_info):
84
- logger.debug(f"Skipping SkipJsonSchema field during parsing: {field_name}")
85
84
  continue
86
85
 
87
86
  # Create full key with prefix
@@ -399,7 +398,6 @@ def _parse_nested_model_field(
399
398
  ):
400
399
  default_value = field_info.default
401
400
  default_applied = True
402
- logger.debug(f"Nested field {field_name} using default value.")
403
401
  elif (
404
402
  hasattr(field_info, "default_factory")
405
403
  and field_info.default_factory is not None
@@ -408,7 +406,6 @@ def _parse_nested_model_field(
408
406
  try:
409
407
  default_value = field_info.default_factory()
410
408
  default_applied = True
411
- logger.debug(f"Nested field {field_name} using default_factory.")
412
409
  except Exception as e:
413
410
  logger.warning(
414
411
  f"Error creating default for {field_name} using default_factory: {e}"
@@ -156,6 +156,63 @@ function toggleListItems(containerId) {
156
156
  }
157
157
  }
158
158
 
159
+ // Simple accordion state preservation using item IDs
160
+ window.saveAccordionState = function(containerId) {
161
+ const container = document.getElementById(containerId);
162
+ if (!container) return;
163
+
164
+ const openItemIds = [];
165
+ container.querySelectorAll('li.uk-open').forEach(item => {
166
+ if (item.id) {
167
+ openItemIds.push(item.id);
168
+ }
169
+ });
170
+
171
+ // Store in sessionStorage with container-specific key
172
+ sessionStorage.setItem(`accordion_state_${containerId}`, JSON.stringify(openItemIds));
173
+ };
174
+
175
+ window.restoreAccordionState = function(containerId) {
176
+ const container = document.getElementById(containerId);
177
+ if (!container) return;
178
+
179
+ const savedState = sessionStorage.getItem(`accordion_state_${containerId}`);
180
+ if (!savedState) return;
181
+
182
+ try {
183
+ const openItemIds = JSON.parse(savedState);
184
+
185
+ // Restore open state for each saved item by ID
186
+ openItemIds.forEach(itemId => {
187
+ const item = document.getElementById(itemId);
188
+ if (item && container.contains(item)) {
189
+ item.classList.add('uk-open');
190
+ const content = item.querySelector('.uk-accordion-content');
191
+ if (content) {
192
+ content.hidden = false;
193
+ content.style.height = 'auto';
194
+ }
195
+ }
196
+ });
197
+ } catch (e) {
198
+ console.warn('Failed to restore accordion state:', e);
199
+ }
200
+ };
201
+
202
+ // Save all accordion states in the form
203
+ window.saveAllAccordionStates = function() {
204
+ document.querySelectorAll('[id$="_items_container"]').forEach(container => {
205
+ window.saveAccordionState(container.id);
206
+ });
207
+ };
208
+
209
+ // Restore all accordion states in the form
210
+ window.restoreAllAccordionStates = function() {
211
+ document.querySelectorAll('[id$="_items_container"]').forEach(container => {
212
+ window.restoreAccordionState(container.id);
213
+ });
214
+ };
215
+
159
216
  // Wait for the DOM to be fully loaded before initializing
160
217
  document.addEventListener('DOMContentLoaded', () => {
161
218
  // Initialize button states for elements present on initial load
@@ -306,26 +363,28 @@ class PydanticForm(Generic[ModelType]):
306
363
  """
307
364
  self.values_dict = self.initial_values_dict.copy()
308
365
 
309
- def _clone_with_values(self, values: Dict[str, Any]) -> "PydanticForm":
366
+ def with_initial_values(
367
+ self, initial_values: Optional[Union[ModelType, Dict[str, Any]]] = None
368
+ ) -> "PydanticForm":
310
369
  """
311
- Create a copy of this renderer with the same configuration but different values.
370
+ Create a new PydanticForm instance with the same configuration but different initial values.
312
371
 
313
- This preserves all constructor arguments (label_colors, custom_renderers, etc.)
314
- to avoid configuration drift during refresh operations.
372
+ This preserves all constructor arguments (label_colors, custom_renderers, spacing, etc.)
373
+ while allowing you to specify new initial values. This is useful for reusing form
374
+ configurations with different data.
315
375
 
316
376
  Args:
317
- values: New values dictionary to use in the cloned renderer
377
+ initial_values: New initial values as BaseModel instance or dict.
378
+ Same format as the constructor accepts.
318
379
 
319
380
  Returns:
320
- A new PydanticForm instance with identical configuration but updated values
381
+ A new PydanticForm instance with identical configuration but updated initial values
321
382
  """
322
- # Get custom renderers if they were registered (not stored directly on instance)
323
- # We'll rely on global registry state being preserved
324
-
383
+ # Create the new instance with the same configuration
325
384
  clone = PydanticForm(
326
385
  form_name=self.name,
327
386
  model_class=self.model_class,
328
- initial_values=None, # Will be set via values_dict below
387
+ initial_values=initial_values, # Pass through to constructor for proper handling
329
388
  custom_renderers=None, # Registry is global, no need to re-register
330
389
  disabled=self.disabled,
331
390
  disabled_fields=self.disabled_fields,
@@ -335,9 +394,6 @@ class PydanticForm(Generic[ModelType]):
335
394
  metrics_dict=self.metrics_dict,
336
395
  )
337
396
 
338
- # Set the values directly
339
- clone.values_dict = values
340
-
341
397
  return clone
342
398
 
343
399
  def render_inputs(self) -> FT:
@@ -349,19 +405,14 @@ class PydanticForm(Generic[ModelType]):
349
405
  """
350
406
  form_inputs = []
351
407
  registry = FieldRendererRegistry() # Get singleton instance
352
- logger.debug(
353
- f"Starting render_inputs for form '{self.name}' with {len(self.model_class.model_fields)} fields"
354
- )
355
408
 
356
409
  for field_name, field_info in self.model_class.model_fields.items():
357
410
  # Skip excluded fields
358
411
  if field_name in self.exclude_fields:
359
- logger.debug(f"Skipping excluded field: {field_name}")
360
412
  continue
361
413
 
362
414
  # Skip SkipJsonSchema fields (they should not be rendered in the form)
363
415
  if _is_skip_json_schema_field(field_info):
364
- logger.debug(f"Skipping SkipJsonSchema field: {field_name}")
365
416
  continue
366
417
 
367
418
  # Only use what was explicitly provided in initial values
@@ -375,35 +426,16 @@ class PydanticForm(Generic[ModelType]):
375
426
  field_name in self.values_dict if self.values_dict else False
376
427
  )
377
428
 
378
- # Log the initial value type and a summary for debugging
379
- if initial_value is not None:
380
- value_type = type(initial_value).__name__
381
- if isinstance(initial_value, (list, dict)):
382
- value_size = f"size={len(initial_value)}"
383
- else:
384
- value_size = ""
385
- logger.debug(
386
- f"Field '{field_name}': {value_type} {value_size} (provided: {field_was_provided})"
387
- )
388
- else:
389
- logger.debug(
390
- f"Field '{field_name}': None (provided: {field_was_provided})"
391
- )
392
-
393
429
  # Only use defaults if field was not provided at all
394
430
  if not field_was_provided:
395
431
  # Field not provided - use model defaults
396
432
  if field_info.default is not None:
397
433
  initial_value = field_info.default
398
- logger.debug(f" - Using default value for '{field_name}'")
399
434
  elif getattr(field_info, "default_factory", None) is not None:
400
435
  try:
401
436
  default_factory = field_info.default_factory
402
437
  if callable(default_factory):
403
438
  initial_value = default_factory()
404
- logger.debug(
405
- f" - Using default_factory for '{field_name}'"
406
- )
407
439
  else:
408
440
  initial_value = None
409
441
  logger.warning(
@@ -428,9 +460,6 @@ class PydanticForm(Generic[ModelType]):
428
460
 
429
461
  # Determine if this specific field should be disabled
430
462
  is_field_disabled = self.disabled or (field_name in self.disabled_fields)
431
- logger.debug(
432
- f"Field '{field_name}' disabled state: {is_field_disabled} (Global: {self.disabled}, Specific: {field_name in self.disabled_fields})"
433
- )
434
463
 
435
464
  # Get label color for this field if specified
436
465
  label_color = self.label_colors.get(field_name)
@@ -460,7 +489,6 @@ class PydanticForm(Generic[ModelType]):
460
489
 
461
490
  # Define the ID for the wrapper div - this is what the HTMX request targets
462
491
  form_content_wrapper_id = f"{self.name}-inputs-wrapper"
463
- logger.debug(f"Creating form inputs wrapper with ID: {form_content_wrapper_id}")
464
492
 
465
493
  # Create the wrapper div and apply compact styling if needed
466
494
  wrapped = self._compact_wrapper(
@@ -490,11 +518,6 @@ class PydanticForm(Generic[ModelType]):
490
518
  if key.startswith(self.base_prefix)
491
519
  }
492
520
 
493
- logger.debug(
494
- f"Filtered form data for '{self.name}': "
495
- f"{len(data)} keys -> {len(filtered)} keys"
496
- )
497
-
498
521
  return filtered
499
522
 
500
523
  # ---- Form Renderer Methods (continued) ----
@@ -551,7 +574,7 @@ class PydanticForm(Generic[ModelType]):
551
574
  self.values_dict = parsed_data.copy()
552
575
 
553
576
  # Create temporary renderer with same configuration but updated values
554
- temp_renderer = self._clone_with_values(parsed_data)
577
+ temp_renderer = self.with_initial_values(parsed_data)
555
578
 
556
579
  refreshed_inputs_component = temp_renderer.render_inputs()
557
580
 
@@ -585,18 +608,7 @@ class PydanticForm(Generic[ModelType]):
585
608
  logger.info(f"Resetting form '{self.name}' to initial values")
586
609
 
587
610
  # Create temporary renderer with original initial dict
588
- temp_renderer = PydanticForm(
589
- form_name=self.name,
590
- model_class=self.model_class,
591
- initial_values=self.initial_values_dict, # Use dict instead of BaseModel
592
- custom_renderers=getattr(self, "custom_renderers", None),
593
- disabled=self.disabled,
594
- disabled_fields=self.disabled_fields,
595
- label_colors=self.label_colors,
596
- exclude_fields=self.exclude_fields,
597
- spacing=self.spacing,
598
- metrics_dict=self.metrics_dict,
599
- )
611
+ temp_renderer = self.with_initial_values(self.initial_values_dict)
600
612
 
601
613
  reset_inputs_component = temp_renderer.render_inputs()
602
614
 
@@ -682,7 +694,6 @@ class PydanticForm(Generic[ModelType]):
682
694
  if hasattr(initial_val, "model_dump"):
683
695
  initial_val = initial_val.model_dump()
684
696
  data[field_name] = initial_val
685
- logger.debug(f"Injected initial value for missing field '{field_name}'")
686
697
  continue
687
698
 
688
699
  # Second priority: use model defaults
@@ -692,18 +703,13 @@ class PydanticForm(Generic[ModelType]):
692
703
  if hasattr(default_val, "model_dump"):
693
704
  default_val = default_val.model_dump()
694
705
  data[field_name] = default_val
695
- logger.debug(
696
- f"Injected model default value for missing field '{field_name}'"
697
- )
698
706
  else:
699
707
  # Check if this is a SkipJsonSchema field
700
708
  if _is_skip_json_schema_field(field_info):
701
- logger.debug(
702
- f"No default found for SkipJsonSchema field '{field_name}'"
703
- )
709
+ pass # Skip fields don't need defaults
704
710
  else:
705
711
  # No default → leave missing; validation will surface error
706
- logger.debug(f"No default found for field '{field_name}'")
712
+ pass
707
713
 
708
714
  return data
709
715
 
@@ -722,31 +728,20 @@ class PydanticForm(Generic[ModelType]):
722
728
  async def _instance_specific_refresh_handler(req):
723
729
  """Handle form refresh request for this specific form instance"""
724
730
  # Add entry point logging to confirm the route is being hit
725
- logger.debug(f"Received POST request on {refresh_route_path}")
726
731
  # Calls the instance method to handle the logic
727
732
  return await self.handle_refresh_request(req)
728
733
 
729
- logger.debug(
730
- f"Registered refresh route for form '{self.name}' at {refresh_route_path}"
731
- )
732
-
733
734
  # --- Register the form-specific reset route ---
734
735
  reset_route_path = f"/form/{self.name}/reset"
735
736
 
736
737
  @app.route(reset_route_path, methods=["POST"])
737
738
  async def _instance_specific_reset_handler(req):
738
739
  """Handle form reset request for this specific form instance"""
739
- logger.debug(f"Received POST request on {reset_route_path}")
740
740
  # Calls the instance method to handle the logic
741
741
  return await self.handle_reset_request()
742
742
 
743
- logger.debug(
744
- f"Registered reset route for form '{self.name}' at {reset_route_path}"
745
- )
746
-
747
743
  # Try the route with a more explicit pattern
748
744
  route_pattern = f"/form/{self.name}/list/{{action}}/{{list_path:path}}"
749
- logger.debug(f"Registering list action route: {route_pattern}")
750
745
 
751
746
  @app.route(route_pattern, methods=["POST", "DELETE"])
752
747
  async def list_action(req, action: str, list_path: str):
@@ -774,9 +769,6 @@ class PydanticForm(Generic[ModelType]):
774
769
  return mui.Alert(str(exc), cls=mui.AlertT.error)
775
770
 
776
771
  if req.method == "DELETE":
777
- logger.debug(
778
- f"Received DELETE request for {list_path} for form '{self.name}'"
779
- )
780
772
  return fh.Response(status_code=200, content="")
781
773
 
782
774
  # === add (POST) ===
@@ -844,8 +836,15 @@ class PydanticForm(Generic[ModelType]):
844
836
  "hx_swap": "innerHTML",
845
837
  "hx_trigger": "click", # Explicit trigger on click
846
838
  "hx_include": "closest form", # Include all form fields from the enclosing form
839
+ "hx_preserve": "scroll",
847
840
  "uk_tooltip": "Update the form display based on current values (e.g., list item titles)",
848
841
  "cls": mui.ButtonT.secondary,
842
+ **{
843
+ "hx-on::before-request": "window.saveAllAccordionStates && window.saveAllAccordionStates()"
844
+ },
845
+ **{
846
+ "hx-on::after-swap": "window.restoreAllAccordionStates && window.restoreAllAccordionStates()"
847
+ },
849
848
  }
850
849
 
851
850
  # Update with any additional attributes
@@ -881,6 +880,7 @@ class PydanticForm(Generic[ModelType]):
881
880
  "hx_target": f"#{form_content_wrapper_id}", # Target the wrapper Div ID
882
881
  "hx_swap": "innerHTML",
883
882
  "hx_confirm": "Are you sure you want to reset the form to its initial values? Any unsaved changes will be lost.",
883
+ "hx_preserve": "scroll",
884
884
  "uk_tooltip": "Reset the form fields to their original values",
885
885
  "cls": mui.ButtonT.destructive, # Use danger style to indicate destructive action
886
886
  }
@@ -914,7 +914,6 @@ class PydanticForm(Generic[ModelType]):
914
914
  Raises:
915
915
  ValidationError: If validation fails based on the model's rules
916
916
  """
917
- logger.debug(f"Validating request for form '{self.name}'")
918
917
  form_data = await req.form()
919
918
  form_dict = dict(form_data)
920
919
 
@@ -39,10 +39,7 @@ class FieldRendererRegistry:
39
39
 
40
40
  def __new__(cls, *args, **kwargs):
41
41
  if cls._instance is None:
42
- logger.debug("Creating new FieldRendererRegistry singleton instance.")
43
42
  cls._instance = super().__new__(cls)
44
- else:
45
- logger.debug("Returning existing FieldRendererRegistry singleton instance.")
46
43
  return cls._instance
47
44
 
48
45
  @classmethod