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.
- {fh_pydantic_form-0.3.0 → fh_pydantic_form-0.3.2}/.gitignore +4 -1
- {fh_pydantic_form-0.3.0 → fh_pydantic_form-0.3.2}/PKG-INFO +34 -4
- {fh_pydantic_form-0.3.0 → fh_pydantic_form-0.3.2}/README.md +33 -3
- {fh_pydantic_form-0.3.0 → fh_pydantic_form-0.3.2}/RELEASE_NOTES.md +51 -0
- {fh_pydantic_form-0.3.0 → fh_pydantic_form-0.3.2}/pyproject.toml +1 -1
- {fh_pydantic_form-0.3.0 → fh_pydantic_form-0.3.2}/src/fh_pydantic_form/comparison_form.py +104 -3
- {fh_pydantic_form-0.3.0 → fh_pydantic_form-0.3.2}/src/fh_pydantic_form/field_renderers.py +145 -61
- {fh_pydantic_form-0.3.0 → fh_pydantic_form-0.3.2}/src/fh_pydantic_form/form_parser.py +0 -3
- {fh_pydantic_form-0.3.0 → fh_pydantic_form-0.3.2}/src/fh_pydantic_form/form_renderer.py +81 -82
- {fh_pydantic_form-0.3.0 → fh_pydantic_form-0.3.2}/src/fh_pydantic_form/registry.py +0 -3
- {fh_pydantic_form-0.3.0 → fh_pydantic_form-0.3.2}/.github/workflows/build.yaml +0 -0
- {fh_pydantic_form-0.3.0 → fh_pydantic_form-0.3.2}/.github/workflows/publish.yaml +0 -0
- {fh_pydantic_form-0.3.0 → fh_pydantic_form-0.3.2}/.pre-commit-config.yaml +0 -0
- {fh_pydantic_form-0.3.0 → fh_pydantic_form-0.3.2}/LICENSE +0 -0
- {fh_pydantic_form-0.3.0 → fh_pydantic_form-0.3.2}/src/fh_pydantic_form/__init__.py +0 -0
- {fh_pydantic_form-0.3.0 → fh_pydantic_form-0.3.2}/src/fh_pydantic_form/color_utils.py +0 -0
- {fh_pydantic_form-0.3.0 → fh_pydantic_form-0.3.2}/src/fh_pydantic_form/constants.py +0 -0
- {fh_pydantic_form-0.3.0 → fh_pydantic_form-0.3.2}/src/fh_pydantic_form/defaults.py +0 -0
- {fh_pydantic_form-0.3.0 → fh_pydantic_form-0.3.2}/src/fh_pydantic_form/list_path.py +0 -0
- {fh_pydantic_form-0.3.0 → fh_pydantic_form-0.3.2}/src/fh_pydantic_form/py.typed +0 -0
- {fh_pydantic_form-0.3.0 → fh_pydantic_form-0.3.2}/src/fh_pydantic_form/type_helpers.py +0 -0
- {fh_pydantic_form-0.3.0 → fh_pydantic_form-0.3.2}/src/fh_pydantic_form/ui_style.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fh-pydantic-form
|
|
3
|
-
Version: 0.3.
|
|
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/
|
|
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/
|
|
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
|
|
@@ -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)
|
|
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
|
-
|
|
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
|
|
603
|
+
A TextArea component appropriate for string values
|
|
607
604
|
"""
|
|
608
|
-
|
|
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
|
-
|
|
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
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
1393
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1423
|
-
|
|
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
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
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
|
|
1435
|
-
|
|
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
|
|
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
|
-
#
|
|
1443
|
-
|
|
1444
|
-
|
|
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
|
-
#
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
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('
|
|
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"{
|
|
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"{
|
|
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
|
|
366
|
+
def with_initial_values(
|
|
367
|
+
self, initial_values: Optional[Union[ModelType, Dict[str, Any]]] = None
|
|
368
|
+
) -> "PydanticForm":
|
|
310
369
|
"""
|
|
311
|
-
Create a
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
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=
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|