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