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