fh-pydantic-form 0.2.0__py3-none-any.whl → 0.2.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/field_renderers.py +237 -92
- fh_pydantic_form/form_parser.py +78 -45
- fh_pydantic_form/form_renderer.py +132 -119
- fh_pydantic_form/list_path.py +145 -0
- fh_pydantic_form/ui_style.py +78 -47
- {fh_pydantic_form-0.2.0.dist-info → fh_pydantic_form-0.2.2.dist-info}/METADATA +4 -14
- fh_pydantic_form-0.2.2.dist-info/RECORD +14 -0
- fh_pydantic_form-0.2.0.dist-info/RECORD +0 -13
- {fh_pydantic_form-0.2.0.dist-info → fh_pydantic_form-0.2.2.dist-info}/WHEEL +0 -0
- {fh_pydantic_form-0.2.0.dist-info → fh_pydantic_form-0.2.2.dist-info}/licenses/LICENSE +0 -0
fh_pydantic_form/form_parser.py
CHANGED
|
@@ -275,7 +275,9 @@ def _parse_nested_model_field(
|
|
|
275
275
|
break
|
|
276
276
|
|
|
277
277
|
if found_any_subfield:
|
|
278
|
-
#
|
|
278
|
+
# ------------------------------------------------------------------
|
|
279
|
+
# 1. Process each **non-list** field in the nested model
|
|
280
|
+
# ------------------------------------------------------------------
|
|
279
281
|
for sub_field_name, sub_field_info in nested_model_class.model_fields.items():
|
|
280
282
|
sub_key = f"{current_prefix}{sub_field_name}"
|
|
281
283
|
annotation = getattr(sub_field_info, "annotation", None)
|
|
@@ -311,6 +313,22 @@ def _parse_nested_model_field(
|
|
|
311
313
|
elif is_optional:
|
|
312
314
|
nested_data[sub_field_name] = None
|
|
313
315
|
|
|
316
|
+
# ------------------------------------------------------------------
|
|
317
|
+
# 2. Handle **list fields** inside this nested model (e.g. Address.tags)
|
|
318
|
+
# Re-use the generic helpers so behaviour matches top-level lists.
|
|
319
|
+
# ------------------------------------------------------------------
|
|
320
|
+
nested_list_defs = _identify_list_fields(nested_model_class)
|
|
321
|
+
if nested_list_defs:
|
|
322
|
+
list_results = _parse_list_fields(
|
|
323
|
+
form_data,
|
|
324
|
+
nested_list_defs,
|
|
325
|
+
current_prefix, # ← prefix for this nested model
|
|
326
|
+
)
|
|
327
|
+
# Merge without clobbering keys already set in step 1
|
|
328
|
+
for lf_name, lf_val in list_results.items():
|
|
329
|
+
if lf_name not in nested_data:
|
|
330
|
+
nested_data[lf_name] = lf_val
|
|
331
|
+
|
|
314
332
|
return nested_data
|
|
315
333
|
|
|
316
334
|
# No data found for this nested model
|
|
@@ -331,13 +349,25 @@ def _parse_nested_model_field(
|
|
|
331
349
|
default_value = None
|
|
332
350
|
default_applied = False
|
|
333
351
|
|
|
334
|
-
|
|
352
|
+
# Import PydanticUndefined to check for it specifically
|
|
353
|
+
try:
|
|
354
|
+
from pydantic_core import PydanticUndefined
|
|
355
|
+
except ImportError:
|
|
356
|
+
# Fallback for older pydantic versions
|
|
357
|
+
from pydantic.fields import PydanticUndefined
|
|
358
|
+
|
|
359
|
+
if (
|
|
360
|
+
hasattr(field_info, "default")
|
|
361
|
+
and field_info.default is not None
|
|
362
|
+
and field_info.default is not PydanticUndefined
|
|
363
|
+
):
|
|
335
364
|
default_value = field_info.default
|
|
336
365
|
default_applied = True
|
|
337
366
|
logger.debug(f"Nested field {field_name} using default value.")
|
|
338
367
|
elif (
|
|
339
368
|
hasattr(field_info, "default_factory")
|
|
340
369
|
and field_info.default_factory is not None
|
|
370
|
+
and field_info.default_factory is not PydanticUndefined
|
|
341
371
|
):
|
|
342
372
|
try:
|
|
343
373
|
default_value = field_info.default_factory()
|
|
@@ -430,52 +460,23 @@ def _parse_list_fields(
|
|
|
430
460
|
|
|
431
461
|
items = []
|
|
432
462
|
for idx_str in ordered_indices:
|
|
463
|
+
# ------------------------------------------------------------------
|
|
464
|
+
# If this list stores *BaseModel* items, completely re-parse the item
|
|
465
|
+
# so that any inner lists (e.g. tags inside Address) become real lists
|
|
466
|
+
# instead of a bunch of 'tags_0', 'tags_new_xxx' flat entries.
|
|
467
|
+
# ------------------------------------------------------------------
|
|
468
|
+
if field_def["is_model_type"]:
|
|
469
|
+
item_prefix = f"{base_prefix}{field_name}_{idx_str}_"
|
|
470
|
+
parsed_item = _parse_model_list_item(form_data, item_type, item_prefix)
|
|
471
|
+
items.append(parsed_item)
|
|
472
|
+
continue
|
|
473
|
+
|
|
474
|
+
# ───────── simple (non-model) items – keep existing logic ──────────
|
|
433
475
|
item_data = list_items_temp[field_name][idx_str]
|
|
434
476
|
|
|
435
|
-
# Handle empty strings for optional fields inside models
|
|
436
|
-
if isinstance(item_data, dict):
|
|
437
|
-
# Check each subfield for optional type and empty string
|
|
438
|
-
for subfield_name, subfield_value in list(item_data.items()):
|
|
439
|
-
# Get the corresponding field_info from the item_type
|
|
440
|
-
if (
|
|
441
|
-
hasattr(item_type, "model_fields")
|
|
442
|
-
and subfield_name in item_type.model_fields
|
|
443
|
-
):
|
|
444
|
-
subfield_info = item_type.model_fields[subfield_name]
|
|
445
|
-
# Convert empty strings to None for optional fields
|
|
446
|
-
if subfield_value == "" and _is_optional_type(
|
|
447
|
-
subfield_info.annotation
|
|
448
|
-
):
|
|
449
|
-
item_data[subfield_name] = None
|
|
450
|
-
# Convert 'on' to True for boolean fields
|
|
451
|
-
elif subfield_value == "on":
|
|
452
|
-
annotation = getattr(subfield_info, "annotation", None)
|
|
453
|
-
base_type = _get_underlying_type_if_optional(annotation)
|
|
454
|
-
if base_type is bool:
|
|
455
|
-
item_data[subfield_name] = True
|
|
456
|
-
|
|
457
|
-
# Handle missing boolean fields in model list items
|
|
458
|
-
if field_def["is_model_type"] and hasattr(item_type, "model_fields"):
|
|
459
|
-
# Iterate through all model fields to find missing boolean fields
|
|
460
|
-
for (
|
|
461
|
-
model_field_name,
|
|
462
|
-
model_field_info,
|
|
463
|
-
) in item_type.model_fields.items():
|
|
464
|
-
annotation = getattr(model_field_info, "annotation", None)
|
|
465
|
-
base_type = _get_underlying_type_if_optional(annotation)
|
|
466
|
-
is_bool_type = base_type is bool
|
|
467
|
-
|
|
468
|
-
# If it's a boolean field and not in the item_data, set it to False
|
|
469
|
-
if is_bool_type and model_field_name not in item_data:
|
|
470
|
-
logger.info(
|
|
471
|
-
f"Setting missing boolean '{model_field_name}' to False for item in list '{field_name}'"
|
|
472
|
-
)
|
|
473
|
-
item_data[model_field_name] = False
|
|
474
|
-
|
|
475
477
|
# Convert string to int for integer-valued enums in simple lists
|
|
476
478
|
if (
|
|
477
|
-
|
|
478
|
-
and isinstance(item_type, type)
|
|
479
|
+
isinstance(item_type, type)
|
|
479
480
|
and issubclass(item_type, Enum)
|
|
480
481
|
and isinstance(item_data, str)
|
|
481
482
|
):
|
|
@@ -491,7 +492,6 @@ def _parse_list_fields(
|
|
|
491
492
|
# Empty enum, leave item_data as-is
|
|
492
493
|
pass
|
|
493
494
|
|
|
494
|
-
# For model types, keep as dict for now
|
|
495
495
|
items.append(item_data)
|
|
496
496
|
|
|
497
497
|
if items: # Only add if items were found
|
|
@@ -515,6 +515,39 @@ def _parse_list_fields(
|
|
|
515
515
|
return final_lists
|
|
516
516
|
|
|
517
517
|
|
|
518
|
+
def _parse_model_list_item(
|
|
519
|
+
form_data: Dict[str, Any],
|
|
520
|
+
item_type,
|
|
521
|
+
item_prefix: str,
|
|
522
|
+
) -> Dict[str, Any]:
|
|
523
|
+
"""
|
|
524
|
+
Fully parse a single BaseModel list item – including its own nested lists.
|
|
525
|
+
|
|
526
|
+
Re-uses the existing non-list and list helpers so we don't duplicate logic.
|
|
527
|
+
|
|
528
|
+
Args:
|
|
529
|
+
form_data: Dictionary containing form field data
|
|
530
|
+
item_type: The BaseModel class for this list item
|
|
531
|
+
item_prefix: Prefix for this specific list item (e.g., "main_form_compact_other_addresses_0_")
|
|
532
|
+
|
|
533
|
+
Returns:
|
|
534
|
+
Dictionary with fully parsed item data including nested lists
|
|
535
|
+
"""
|
|
536
|
+
nested_list_defs = _identify_list_fields(item_type)
|
|
537
|
+
# 1. Parse scalars & nested models
|
|
538
|
+
result = _parse_non_list_fields(
|
|
539
|
+
form_data,
|
|
540
|
+
item_type,
|
|
541
|
+
nested_list_defs,
|
|
542
|
+
base_prefix=item_prefix,
|
|
543
|
+
)
|
|
544
|
+
# 2. Parse inner lists
|
|
545
|
+
result.update(
|
|
546
|
+
_parse_list_fields(form_data, nested_list_defs, base_prefix=item_prefix)
|
|
547
|
+
)
|
|
548
|
+
return result
|
|
549
|
+
|
|
550
|
+
|
|
518
551
|
def _parse_list_item_key(
|
|
519
552
|
key: str, list_field_defs: Dict[str, Dict[str, Any]], base_prefix: str = ""
|
|
520
553
|
) -> Optional[Tuple[str, str, Optional[str], bool]]:
|
|
@@ -28,9 +28,11 @@ from fh_pydantic_form.form_parser import (
|
|
|
28
28
|
_parse_list_fields,
|
|
29
29
|
_parse_non_list_fields,
|
|
30
30
|
)
|
|
31
|
+
from fh_pydantic_form.list_path import walk_path
|
|
31
32
|
from fh_pydantic_form.registry import FieldRendererRegistry
|
|
32
33
|
from fh_pydantic_form.type_helpers import _UNSET, get_default
|
|
33
34
|
from fh_pydantic_form.ui_style import (
|
|
35
|
+
COMPACT_EXTRA_CSS,
|
|
34
36
|
SpacingTheme,
|
|
35
37
|
SpacingValue,
|
|
36
38
|
_normalize_spacing,
|
|
@@ -207,17 +209,56 @@ class PydanticForm(Generic[ModelType]):
|
|
|
207
209
|
- validating request data against the model
|
|
208
210
|
"""
|
|
209
211
|
|
|
212
|
+
# --- module-level flag (add near top of file) ---
|
|
213
|
+
|
|
210
214
|
def _compact_wrapper(self, inner: FT) -> FT:
|
|
211
215
|
"""
|
|
212
|
-
Wrap inner markup
|
|
213
|
-
|
|
216
|
+
Wrap inner markup in a namespaced div.
|
|
217
|
+
Auto-inject the compact CSS the *first* time any compact form is rendered.
|
|
214
218
|
"""
|
|
215
|
-
|
|
216
|
-
from fh_pydantic_form.ui_style import COMPACT_EXTRA_CSS
|
|
219
|
+
wrapper_cls = "fhpf-wrapper w-full flex-1"
|
|
217
220
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
+
if self.spacing != SpacingTheme.COMPACT:
|
|
222
|
+
return fh.Div(inner, cls=wrapper_cls)
|
|
223
|
+
|
|
224
|
+
return fh.Div(
|
|
225
|
+
COMPACT_EXTRA_CSS,
|
|
226
|
+
fh.Div(inner, cls="fhpf-fields fhpf-compact"),
|
|
227
|
+
cls=wrapper_cls,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
def _clone_with_values(self, values: Dict[str, Any]) -> "PydanticForm":
|
|
231
|
+
"""
|
|
232
|
+
Create a copy of this renderer with the same configuration but different values.
|
|
233
|
+
|
|
234
|
+
This preserves all constructor arguments (label_colors, custom_renderers, etc.)
|
|
235
|
+
to avoid configuration drift during refresh operations.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
values: New values dictionary to use in the cloned renderer
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
A new PydanticForm instance with identical configuration but updated values
|
|
242
|
+
"""
|
|
243
|
+
# Get custom renderers if they were registered (not stored directly on instance)
|
|
244
|
+
# We'll rely on global registry state being preserved
|
|
245
|
+
|
|
246
|
+
clone = PydanticForm(
|
|
247
|
+
form_name=self.name,
|
|
248
|
+
model_class=self.model_class,
|
|
249
|
+
initial_values=None, # Will be set via values_dict below
|
|
250
|
+
custom_renderers=None, # Registry is global, no need to re-register
|
|
251
|
+
disabled=self.disabled,
|
|
252
|
+
disabled_fields=self.disabled_fields,
|
|
253
|
+
label_colors=self.label_colors,
|
|
254
|
+
exclude_fields=self.exclude_fields,
|
|
255
|
+
spacing=self.spacing,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
# Set the values directly
|
|
259
|
+
clone.values_dict = values
|
|
260
|
+
|
|
261
|
+
return clone
|
|
221
262
|
|
|
222
263
|
def __init__(
|
|
223
264
|
self,
|
|
@@ -393,6 +434,7 @@ class PydanticForm(Generic[ModelType]):
|
|
|
393
434
|
disabled=is_field_disabled, # Pass the calculated disabled state
|
|
394
435
|
label_color=label_color, # Pass the label color if specified
|
|
395
436
|
spacing=self.spacing, # Pass the spacing
|
|
437
|
+
field_path=[field_name], # Set top-level field path
|
|
396
438
|
)
|
|
397
439
|
|
|
398
440
|
rendered_field = renderer.render()
|
|
@@ -452,15 +494,8 @@ class PydanticForm(Generic[ModelType]):
|
|
|
452
494
|
cls=mui.AlertT.warning + " mb-4", # Add margin bottom
|
|
453
495
|
)
|
|
454
496
|
|
|
455
|
-
# Create
|
|
456
|
-
temp_renderer =
|
|
457
|
-
form_name=self.name,
|
|
458
|
-
model_class=self.model_class,
|
|
459
|
-
# No initial_data needed here, we set values_dict below
|
|
460
|
-
spacing=self.spacing,
|
|
461
|
-
)
|
|
462
|
-
# Set the values based on the parsed (or fallback) data
|
|
463
|
-
temp_renderer.values_dict = parsed_data
|
|
497
|
+
# Create temporary renderer with same configuration but updated values
|
|
498
|
+
temp_renderer = self._clone_with_values(parsed_data)
|
|
464
499
|
|
|
465
500
|
refreshed_inputs_component = temp_renderer.render_inputs()
|
|
466
501
|
|
|
@@ -565,6 +600,8 @@ class PydanticForm(Generic[ModelType]):
|
|
|
565
600
|
Ensures that every field listed in self.exclude_fields is present in data
|
|
566
601
|
if the model defines a default or default_factory, or if initial_values were provided.
|
|
567
602
|
|
|
603
|
+
Also ensures all model fields have appropriate defaults if missing.
|
|
604
|
+
|
|
568
605
|
Priority order:
|
|
569
606
|
1. initial_values (if provided during form creation)
|
|
570
607
|
2. model defaults/default_factory
|
|
@@ -577,6 +614,7 @@ class PydanticForm(Generic[ModelType]):
|
|
|
577
614
|
Returns:
|
|
578
615
|
The same dictionary instance for method chaining
|
|
579
616
|
"""
|
|
617
|
+
# Handle excluded fields first
|
|
580
618
|
for field_name in self.exclude_fields:
|
|
581
619
|
# Skip if already present (e.g., user provided initial_values)
|
|
582
620
|
if field_name in data:
|
|
@@ -613,6 +651,22 @@ class PydanticForm(Generic[ModelType]):
|
|
|
613
651
|
# No default → leave missing; validation will surface error
|
|
614
652
|
logger.debug(f"No default found for excluded field '{field_name}'")
|
|
615
653
|
|
|
654
|
+
# Also handle any other missing fields that should have defaults
|
|
655
|
+
for field_name, field_info in self.model_class.model_fields.items():
|
|
656
|
+
if field_name not in data:
|
|
657
|
+
# Try to inject defaults for missing fields
|
|
658
|
+
if field_name in self.initial_values_dict:
|
|
659
|
+
initial_val = self.initial_values_dict[field_name]
|
|
660
|
+
if hasattr(initial_val, "model_dump"):
|
|
661
|
+
initial_val = initial_val.model_dump()
|
|
662
|
+
data[field_name] = initial_val
|
|
663
|
+
else:
|
|
664
|
+
default_val = get_default(field_info)
|
|
665
|
+
if default_val is not _UNSET:
|
|
666
|
+
if hasattr(default_val, "model_dump"):
|
|
667
|
+
default_val = default_val.model_dump()
|
|
668
|
+
data[field_name] = default_val
|
|
669
|
+
|
|
616
670
|
return data
|
|
617
671
|
|
|
618
672
|
def register_routes(self, app):
|
|
@@ -652,123 +706,76 @@ class PydanticForm(Generic[ModelType]):
|
|
|
652
706
|
f"Registered reset route for form '{self.name}' at {reset_route_path}"
|
|
653
707
|
)
|
|
654
708
|
|
|
655
|
-
|
|
656
|
-
|
|
709
|
+
# Try the route with a more explicit pattern
|
|
710
|
+
route_pattern = f"/form/{self.name}/list/{{action}}/{{list_path:path}}"
|
|
711
|
+
logger.debug(f"Registering list action route: {route_pattern}")
|
|
712
|
+
|
|
713
|
+
@app.route(route_pattern, methods=["POST", "DELETE"])
|
|
714
|
+
async def list_action(req, action: str, list_path: str):
|
|
657
715
|
"""
|
|
658
|
-
Handle
|
|
716
|
+
Handle list actions (add/delete) for nested lists in this specific form
|
|
659
717
|
|
|
660
718
|
Args:
|
|
661
719
|
req: The request object
|
|
662
|
-
|
|
720
|
+
action: Either "add" or "delete"
|
|
721
|
+
list_path: Path to the list field (e.g., "tags" or "main_address/tags" or "other_addresses/1/tags")
|
|
663
722
|
|
|
664
723
|
Returns:
|
|
665
|
-
A component for the new list item
|
|
724
|
+
A component for the new list item (add) or empty response (delete)
|
|
666
725
|
"""
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
item_type = None
|
|
670
|
-
|
|
671
|
-
if field_name in self.model_class.model_fields:
|
|
672
|
-
field_info = self.model_class.model_fields[field_name]
|
|
673
|
-
annotation = getattr(field_info, "annotation", None)
|
|
674
|
-
|
|
675
|
-
if (
|
|
676
|
-
annotation is not None
|
|
677
|
-
and hasattr(annotation, "__origin__")
|
|
678
|
-
and annotation.__origin__ is list
|
|
679
|
-
):
|
|
680
|
-
item_type = annotation.__args__[0]
|
|
681
|
-
|
|
682
|
-
if not item_type:
|
|
683
|
-
logger.error(
|
|
684
|
-
f"Cannot determine item type for list field {field_name}"
|
|
685
|
-
)
|
|
686
|
-
return mui.Alert(
|
|
687
|
-
f"Cannot determine item type for list field {field_name}",
|
|
688
|
-
cls=mui.AlertT.error,
|
|
689
|
-
)
|
|
690
|
-
|
|
691
|
-
# Create a default item using smart defaults
|
|
692
|
-
default_item_dict: Dict[str, Any] | str | Any | None = None
|
|
693
|
-
try:
|
|
694
|
-
if not item_type:
|
|
695
|
-
logger.warning(
|
|
696
|
-
f"item_type was None when trying to create default for {field_name}"
|
|
697
|
-
)
|
|
698
|
-
default_item_dict = ""
|
|
699
|
-
elif hasattr(item_type, "model_fields"):
|
|
700
|
-
# For Pydantic models, use smart default generation
|
|
701
|
-
default_item_dict = default_dict_for_model(item_type)
|
|
702
|
-
else:
|
|
703
|
-
# For simple types, use annotation-based defaults
|
|
704
|
-
default_item_dict = default_for_annotation(item_type)
|
|
726
|
+
if action not in {"add", "delete"}:
|
|
727
|
+
return fh.Response(status_code=400, content="Unknown list action")
|
|
705
728
|
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
except Exception as e:
|
|
711
|
-
logger.error(
|
|
712
|
-
f"Error creating default item for {field_name}: {e}", exc_info=True
|
|
713
|
-
)
|
|
714
|
-
return fh.Li(
|
|
715
|
-
mui.Alert(
|
|
716
|
-
f"Error creating default item: {str(e)}", cls=mui.AlertT.error
|
|
717
|
-
),
|
|
718
|
-
cls="mb-2",
|
|
729
|
+
segments = list_path.split("/")
|
|
730
|
+
try:
|
|
731
|
+
list_field_info, html_parts, item_type = walk_path(
|
|
732
|
+
self.model_class, segments
|
|
719
733
|
)
|
|
734
|
+
except ValueError as exc:
|
|
735
|
+
logger.warning("Bad list path %s – %s", list_path, exc)
|
|
736
|
+
return mui.Alert(str(exc), cls=mui.AlertT.error)
|
|
720
737
|
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
# Create a list renderer
|
|
725
|
-
if field_info is not None:
|
|
726
|
-
list_renderer = ListFieldRenderer(
|
|
727
|
-
field_name=field_name,
|
|
728
|
-
field_info=field_info,
|
|
729
|
-
value=[], # Empty list, we only need to render one item
|
|
730
|
-
prefix=self.base_prefix, # Use the form's base prefix
|
|
731
|
-
)
|
|
732
|
-
else:
|
|
733
|
-
logger.error(f"field_info is None for field {field_name}")
|
|
734
|
-
return mui.Alert(
|
|
735
|
-
f"Field info not found for {field_name}",
|
|
736
|
-
cls=mui.AlertT.error,
|
|
738
|
+
if req.method == "DELETE":
|
|
739
|
+
logger.debug(
|
|
740
|
+
f"Received DELETE request for {list_path} for form '{self.name}'"
|
|
737
741
|
)
|
|
742
|
+
return fh.Response(status_code=200, content="")
|
|
738
743
|
|
|
739
|
-
#
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
744
|
+
# === add (POST) ===
|
|
745
|
+
default_item = (
|
|
746
|
+
default_dict_for_model(item_type)
|
|
747
|
+
if hasattr(item_type, "model_fields")
|
|
748
|
+
else default_for_annotation(item_type)
|
|
743
749
|
)
|
|
744
750
|
|
|
745
|
-
#
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
+
# Build prefix **without** the list field itself to avoid duplication
|
|
752
|
+
parts_before_list = html_parts[:-1] # drop final segment
|
|
753
|
+
if parts_before_list:
|
|
754
|
+
html_prefix = f"{self.base_prefix}{'_'.join(parts_before_list)}_"
|
|
755
|
+
else:
|
|
756
|
+
html_prefix = self.base_prefix
|
|
757
|
+
|
|
758
|
+
# Create renderer for the list field
|
|
759
|
+
renderer = ListFieldRenderer(
|
|
760
|
+
field_name=segments[-1],
|
|
761
|
+
field_info=list_field_info,
|
|
762
|
+
value=[],
|
|
763
|
+
prefix=html_prefix,
|
|
764
|
+
spacing=self.spacing,
|
|
765
|
+
disabled=self.disabled,
|
|
766
|
+
field_path=segments, # Pass the full path segments
|
|
767
|
+
form_name=self.name, # Pass the explicit form name
|
|
751
768
|
)
|
|
752
769
|
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
@app.route(f"/form/{self.name}/list/delete/{{field_name}}", methods=["DELETE"])
|
|
756
|
-
async def delete_list_item(req, field_name: str):
|
|
757
|
-
"""
|
|
758
|
-
Handle deleting an item from a list for this specific form
|
|
759
|
-
|
|
760
|
-
Args:
|
|
761
|
-
req: The request object
|
|
762
|
-
field_name: The name of the list field
|
|
770
|
+
# Generate a unique placeholder index
|
|
771
|
+
placeholder_idx = f"new_{int(pytime.time() * 1000)}"
|
|
763
772
|
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
# Return empty string to delete the target element
|
|
768
|
-
logger.debug(
|
|
769
|
-
f"Received DELETE request for {field_name} for form '{self.name}'"
|
|
773
|
+
# Render the new item card, set is_open=True to make it expanded by default
|
|
774
|
+
new_card = renderer._render_item_card(
|
|
775
|
+
default_item, placeholder_idx, item_type, is_open=True
|
|
770
776
|
)
|
|
771
|
-
|
|
777
|
+
|
|
778
|
+
return new_card
|
|
772
779
|
|
|
773
780
|
def refresh_button(self, text: Optional[str] = None, **kwargs) -> FT:
|
|
774
781
|
"""
|
|
@@ -787,9 +794,6 @@ class PydanticForm(Generic[ModelType]):
|
|
|
787
794
|
# Define the target wrapper ID
|
|
788
795
|
form_content_wrapper_id = f"{self.name}-inputs-wrapper"
|
|
789
796
|
|
|
790
|
-
# Define the form ID to include
|
|
791
|
-
form_id = f"{self.name}-form"
|
|
792
|
-
|
|
793
797
|
# Define the target URL
|
|
794
798
|
refresh_url = f"/form/{self.name}/refresh"
|
|
795
799
|
|
|
@@ -800,7 +804,7 @@ class PydanticForm(Generic[ModelType]):
|
|
|
800
804
|
"hx_target": f"#{form_content_wrapper_id}", # Target the wrapper Div ID
|
|
801
805
|
"hx_swap": "innerHTML",
|
|
802
806
|
"hx_trigger": "click", # Explicit trigger on click
|
|
803
|
-
"hx_include":
|
|
807
|
+
"hx_include": "closest form", # Include all form fields from the enclosing form
|
|
804
808
|
"uk_tooltip": "Update the form display based on current values (e.g., list item titles)",
|
|
805
809
|
"cls": mui.ButtonT.secondary,
|
|
806
810
|
}
|
|
@@ -883,3 +887,12 @@ class PydanticForm(Generic[ModelType]):
|
|
|
883
887
|
logger.info(f"Request validation successful for form '{self.name}'")
|
|
884
888
|
|
|
885
889
|
return validated_model
|
|
890
|
+
|
|
891
|
+
def form_id(self) -> str:
|
|
892
|
+
"""
|
|
893
|
+
Get the standard form ID for this renderer.
|
|
894
|
+
|
|
895
|
+
Returns:
|
|
896
|
+
The form ID string that should be used for the HTML form element
|
|
897
|
+
"""
|
|
898
|
+
return f"{self.name}-form"
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import logging
|
|
3
|
+
from typing import List, Tuple, Type, get_origin, get_args
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
from pydantic.fields import FieldInfo
|
|
6
|
+
|
|
7
|
+
from fh_pydantic_form.type_helpers import _get_underlying_type_if_optional
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def walk_path(
|
|
13
|
+
model: Type[BaseModel], segments: List[str]
|
|
14
|
+
) -> Tuple[FieldInfo, List[str], Type]:
|
|
15
|
+
"""
|
|
16
|
+
Resolve `segments` against `model`, stopping at the *list* field.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
model: The BaseModel class to traverse
|
|
20
|
+
segments: Path segments like ["main_address", "tags"] or ["other_addresses", "1", "tags"]
|
|
21
|
+
The final segment should always be a list field name.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
Tuple of:
|
|
25
|
+
- list_field_info: the FieldInfo for the target list field
|
|
26
|
+
- html_prefix_parts: segments used to build element IDs (includes indices)
|
|
27
|
+
- item_type: the concrete python type of items in the list
|
|
28
|
+
|
|
29
|
+
Raises:
|
|
30
|
+
ValueError: if the path is invalid or doesn't lead to a list field
|
|
31
|
+
"""
|
|
32
|
+
if not segments:
|
|
33
|
+
raise ValueError("Empty path provided")
|
|
34
|
+
|
|
35
|
+
current_model = model
|
|
36
|
+
html_parts = []
|
|
37
|
+
i = 0
|
|
38
|
+
|
|
39
|
+
# Process all segments except the last one (which should be the list field)
|
|
40
|
+
while i < len(segments) - 1:
|
|
41
|
+
segment = segments[i]
|
|
42
|
+
|
|
43
|
+
# Check if this segment is a field name
|
|
44
|
+
if segment in current_model.model_fields:
|
|
45
|
+
field_info = current_model.model_fields[segment]
|
|
46
|
+
field_type = _get_underlying_type_if_optional(field_info.annotation)
|
|
47
|
+
html_parts.append(segment)
|
|
48
|
+
|
|
49
|
+
# Check if this is a list field (we're traversing into a list element)
|
|
50
|
+
if get_origin(field_type) is list:
|
|
51
|
+
# Next segment should be an index
|
|
52
|
+
if i + 1 >= len(segments) - 1:
|
|
53
|
+
raise ValueError(f"Expected index after list field '{segment}'")
|
|
54
|
+
|
|
55
|
+
next_segment = segments[i + 1]
|
|
56
|
+
if not _is_index_segment(next_segment):
|
|
57
|
+
raise ValueError(
|
|
58
|
+
f"Expected index after list field '{segment}', got '{next_segment}'"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Get the item type of the list
|
|
62
|
+
list_item_type = (
|
|
63
|
+
get_args(field_type)[0] if get_args(field_type) else None
|
|
64
|
+
)
|
|
65
|
+
if not list_item_type or not hasattr(list_item_type, "model_fields"):
|
|
66
|
+
raise ValueError(
|
|
67
|
+
f"List field '{segment}' does not contain BaseModel items"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# Add the index to html_parts and update current model
|
|
71
|
+
html_parts.append(next_segment)
|
|
72
|
+
current_model = list_item_type
|
|
73
|
+
|
|
74
|
+
# Skip the next segment (the index) since we processed it
|
|
75
|
+
i += 2
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
# Check if this is a BaseModel field
|
|
79
|
+
elif hasattr(field_type, "model_fields"):
|
|
80
|
+
current_model = field_type
|
|
81
|
+
i += 1
|
|
82
|
+
else:
|
|
83
|
+
raise ValueError(f"Field '{segment}' is not a BaseModel or list type")
|
|
84
|
+
|
|
85
|
+
elif _is_index_segment(segment):
|
|
86
|
+
# This should only happen if we're processing an index that wasn't handled above
|
|
87
|
+
raise ValueError(
|
|
88
|
+
f"Unexpected index segment '{segment}' without preceding list field"
|
|
89
|
+
)
|
|
90
|
+
else:
|
|
91
|
+
raise ValueError(
|
|
92
|
+
f"Field '{segment}' not found in model {current_model.__name__}"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Process the final segment (should be a list field)
|
|
96
|
+
final_field_name = segments[-1]
|
|
97
|
+
if final_field_name not in current_model.model_fields:
|
|
98
|
+
raise ValueError(
|
|
99
|
+
f"Field '{final_field_name}' not found in model {current_model.__name__}"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
list_field_info = current_model.model_fields[final_field_name]
|
|
103
|
+
list_field_type = _get_underlying_type_if_optional(list_field_info.annotation)
|
|
104
|
+
|
|
105
|
+
# Verify this is actually a list field
|
|
106
|
+
if get_origin(list_field_type) is not list:
|
|
107
|
+
raise ValueError(f"Final field '{final_field_name}' is not a list type")
|
|
108
|
+
|
|
109
|
+
# Get the item type
|
|
110
|
+
item_type_args = get_args(list_field_type)
|
|
111
|
+
if not item_type_args:
|
|
112
|
+
raise ValueError(
|
|
113
|
+
f"Cannot determine item type for list field '{final_field_name}'"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
item_type = item_type_args[0]
|
|
117
|
+
html_parts.append(final_field_name)
|
|
118
|
+
|
|
119
|
+
logger.debug(
|
|
120
|
+
f"walk_path resolved: {segments} -> field_info={list_field_info}, html_parts={html_parts}, item_type={item_type}"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
return list_field_info, html_parts, item_type
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _is_index_segment(segment: str) -> bool:
|
|
127
|
+
"""
|
|
128
|
+
Check if a segment represents an index (purely numeric or placeholder like 'new_1234').
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
segment: The segment to check
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
True if the segment represents an index
|
|
135
|
+
"""
|
|
136
|
+
# Pure numeric (like "0", "1", "2")
|
|
137
|
+
if segment.isdigit():
|
|
138
|
+
return True
|
|
139
|
+
|
|
140
|
+
# Placeholder format (like "new_1234567890")
|
|
141
|
+
if segment.startswith("new_") and len(segment) > 4:
|
|
142
|
+
timestamp_part = segment[4:]
|
|
143
|
+
return timestamp_part.isdigit()
|
|
144
|
+
|
|
145
|
+
return False
|