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.

@@ -0,0 +1,1033 @@
1
+ import logging
2
+ from datetime import date, time
3
+ from typing import (
4
+ Any,
5
+ Optional,
6
+ get_args,
7
+ get_origin,
8
+ )
9
+
10
+ import fasthtml.common as fh
11
+ import monsterui.all as mui
12
+ from fastcore.xml import FT
13
+ from pydantic import ValidationError
14
+ from pydantic.fields import FieldInfo
15
+
16
+ from fh_pydantic_form.registry import FieldRendererRegistry
17
+ from fh_pydantic_form.type_helpers import (
18
+ _get_underlying_type_if_optional,
19
+ _is_optional_type,
20
+ )
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class BaseFieldRenderer:
26
+ """
27
+ Base class for field renderers
28
+
29
+ Field renderers are responsible for:
30
+ - Rendering a label for the field
31
+ - Rendering an appropriate input element for the field
32
+ - Combining the label and input with proper spacing
33
+
34
+ Subclasses must implement render_input()
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ field_name: str,
40
+ field_info: FieldInfo,
41
+ value: Any = None,
42
+ prefix: str = "",
43
+ disabled: bool = False,
44
+ label_color: Optional[str] = None,
45
+ ):
46
+ """
47
+ Initialize the field renderer
48
+
49
+ Args:
50
+ field_name: The name of the field
51
+ field_info: The FieldInfo for the field
52
+ value: The current value of the field (optional)
53
+ prefix: Optional prefix for the field name (used for nested fields)
54
+ disabled: Whether the field should be rendered as disabled
55
+ label_color: Optional CSS color value for the field label
56
+ """
57
+ self.field_name = f"{prefix}{field_name}" if prefix else field_name
58
+ self.original_field_name = field_name
59
+ self.field_info = field_info
60
+ self.value = value
61
+ self.prefix = prefix
62
+ self.is_optional = _is_optional_type(field_info.annotation)
63
+ self.disabled = disabled
64
+ self.label_color = label_color
65
+
66
+ def render_label(self) -> FT:
67
+ """
68
+ Render label for the field
69
+
70
+ Returns:
71
+ A FastHTML component for the label
72
+ """
73
+ # Get field description from field_info
74
+ description = getattr(self.field_info, "description", None)
75
+
76
+ # Prepare label text
77
+ label_text = self.original_field_name.replace("_", " ").title()
78
+
79
+ # Create span attributes with tooltip if description is available
80
+ span_attrs = {}
81
+ if description:
82
+ span_attrs["uk_tooltip"] = description
83
+ # Removed cursor-help class while preserving tooltip functionality
84
+
85
+ # Create the span with the label text and tooltip
86
+ label_text_span = fh.Span(label_text, **span_attrs)
87
+
88
+ # Prepare label attributes
89
+ label_attrs = {"For": self.field_name}
90
+
91
+ # Apply color styling if specified
92
+ if self.label_color:
93
+ label_attrs["style"] = f"color: {self.label_color};"
94
+
95
+ # Create and return the label - using standard fh.Label with appropriate styling
96
+ return fh.Label(
97
+ label_text_span,
98
+ **label_attrs,
99
+ cls="block text-sm font-medium text-gray-700 mb-1",
100
+ )
101
+
102
+ def render_input(self) -> FT:
103
+ """
104
+ Render input element for the field
105
+
106
+ Returns:
107
+ A FastHTML component for the input element
108
+
109
+ Raises:
110
+ NotImplementedError: Subclasses must implement this method
111
+ """
112
+ raise NotImplementedError("Subclasses must implement render_input")
113
+
114
+ def render(self) -> FT:
115
+ """
116
+ Render the complete field (label + input) with spacing in a collapsible accordion
117
+
118
+ Returns:
119
+ A FastHTML component (mui.Accordion) containing the complete field
120
+ """
121
+ # 1. Get the full label component (fh.Label)
122
+ label_component = self.render_label()
123
+
124
+ # Apply color styling directly to the Label component if needed
125
+ if self.label_color and isinstance(label_component, fh.FT):
126
+ if "style" in label_component.attrs:
127
+ label_component.attrs["style"] += f" color: {self.label_color};"
128
+ else:
129
+ label_component.attrs["style"] = f"color: {self.label_color};"
130
+
131
+ # 2. Render the input field that will be the accordion content
132
+ input_component = self.render_input()
133
+
134
+ # 3. Define unique IDs for potential targeting
135
+ item_id = f"{self.field_name}_item"
136
+ accordion_id = f"{self.field_name}_accordion"
137
+
138
+ # 4. Create the AccordionItem with the full label component as title
139
+ accordion_item = mui.AccordionItem(
140
+ label_component, # Use the entire label component including the "for" attribute
141
+ input_component, # Content component (the input field)
142
+ open=True, # Open by default
143
+ li_kwargs={"id": item_id}, # Pass the specific ID for the <li>
144
+ cls="mb-2", # Add spacing between accordion items
145
+ )
146
+
147
+ # 5. Wrap the single AccordionItem in an Accordion container
148
+ accordion_container = mui.Accordion(
149
+ accordion_item, # The single item to include
150
+ id=accordion_id, # ID for the accordion container (ul)
151
+ multiple=True, # Allow multiple open (though only one exists)
152
+ collapsible=True, # Allow toggling
153
+ )
154
+
155
+ return accordion_container
156
+
157
+
158
+ # ---- Specific Field Renderers ----
159
+
160
+
161
+ class StringFieldRenderer(BaseFieldRenderer):
162
+ """Renderer for string fields"""
163
+
164
+ def render_input(self) -> FT:
165
+ """
166
+ Render input element for the field
167
+
168
+ Returns:
169
+ A TextInput component appropriate for string values
170
+ """
171
+ is_field_required = (
172
+ not self.is_optional
173
+ and self.field_info.default is None
174
+ and getattr(self.field_info, "default_factory", None) is None
175
+ )
176
+
177
+ placeholder_text = f"Enter {self.original_field_name.replace('_', ' ')}"
178
+ if self.is_optional:
179
+ placeholder_text += " (Optional)"
180
+
181
+ input_attrs = {
182
+ "value": self.value or "",
183
+ "id": self.field_name,
184
+ "name": self.field_name,
185
+ "type": "text",
186
+ "placeholder": placeholder_text,
187
+ "required": is_field_required,
188
+ "cls": "w-full",
189
+ }
190
+
191
+ # Only add the disabled attribute if the field should actually be disabled
192
+ if self.disabled:
193
+ input_attrs["disabled"] = True
194
+
195
+ return mui.Input(**input_attrs)
196
+
197
+
198
+ class NumberFieldRenderer(BaseFieldRenderer):
199
+ """Renderer for number fields (int, float)"""
200
+
201
+ def render_input(self) -> FT:
202
+ """
203
+ Render input element for the field
204
+
205
+ Returns:
206
+ A NumberInput component appropriate for numeric values
207
+ """
208
+ is_field_required = (
209
+ not self.is_optional
210
+ and self.field_info.default is None
211
+ and getattr(self.field_info, "default_factory", None) is None
212
+ )
213
+
214
+ placeholder_text = f"Enter {self.original_field_name.replace('_', ' ')}"
215
+ if self.is_optional:
216
+ placeholder_text += " (Optional)"
217
+
218
+ input_attrs = {
219
+ "value": str(self.value) if self.value is not None else "",
220
+ "id": self.field_name,
221
+ "name": self.field_name,
222
+ "type": "number",
223
+ "placeholder": placeholder_text,
224
+ "required": is_field_required,
225
+ "cls": "w-full",
226
+ "step": "any"
227
+ if self.field_info.annotation is float
228
+ or get_origin(self.field_info.annotation) is float
229
+ else "1",
230
+ }
231
+
232
+ # Only add the disabled attribute if the field should actually be disabled
233
+ if self.disabled:
234
+ input_attrs["disabled"] = True
235
+
236
+ return mui.Input(**input_attrs)
237
+
238
+
239
+ class BooleanFieldRenderer(BaseFieldRenderer):
240
+ """Renderer for boolean fields"""
241
+
242
+ def render_input(self) -> FT:
243
+ """
244
+ Render input element for the field
245
+
246
+ Returns:
247
+ A CheckboxX component appropriate for boolean values
248
+ """
249
+ checkbox_attrs = {
250
+ "id": self.field_name,
251
+ "name": self.field_name,
252
+ "checked": bool(self.value),
253
+ }
254
+
255
+ # Only add the disabled attribute if the field should actually be disabled
256
+ if self.disabled:
257
+ checkbox_attrs["disabled"] = True
258
+
259
+ return mui.CheckboxX(**checkbox_attrs)
260
+
261
+
262
+ class DateFieldRenderer(BaseFieldRenderer):
263
+ """Renderer for date fields"""
264
+
265
+ def render_input(self) -> FT:
266
+ """
267
+ Render input element for the field
268
+
269
+ Returns:
270
+ A DateInput component appropriate for date values
271
+ """
272
+ formatted_value = ""
273
+ if (
274
+ isinstance(self.value, str) and len(self.value) == 10
275
+ ): # Basic check for YYYY-MM-DD format
276
+ # Assume it's the correct string format from the form
277
+ formatted_value = self.value
278
+ elif isinstance(self.value, date):
279
+ formatted_value = self.value.isoformat() # YYYY-MM-DD
280
+
281
+ is_field_required = (
282
+ not self.is_optional
283
+ and self.field_info.default is None
284
+ and getattr(self.field_info, "default_factory", None) is None
285
+ )
286
+
287
+ placeholder_text = f"Select {self.original_field_name.replace('_', ' ')}"
288
+ if self.is_optional:
289
+ placeholder_text += " (Optional)"
290
+
291
+ input_attrs = {
292
+ "value": formatted_value,
293
+ "id": self.field_name,
294
+ "name": self.field_name,
295
+ "type": "date",
296
+ "placeholder": placeholder_text,
297
+ "required": is_field_required,
298
+ "cls": "w-full",
299
+ }
300
+
301
+ # Only add the disabled attribute if the field should actually be disabled
302
+ if self.disabled:
303
+ input_attrs["disabled"] = True
304
+
305
+ return mui.Input(**input_attrs)
306
+
307
+
308
+ class TimeFieldRenderer(BaseFieldRenderer):
309
+ """Renderer for time fields"""
310
+
311
+ def render_input(self) -> FT:
312
+ """
313
+ Render input element for the field
314
+
315
+ Returns:
316
+ A TimeInput component appropriate for time values
317
+ """
318
+ formatted_value = ""
319
+ if (
320
+ isinstance(self.value, str) and len(self.value) == 5
321
+ ): # Basic check for HH:MM format
322
+ # Assume it's the correct string format from the form
323
+ formatted_value = self.value
324
+ elif isinstance(self.value, time):
325
+ formatted_value = self.value.strftime("%H:%M") # HH:MM
326
+
327
+ is_field_required = (
328
+ not self.is_optional
329
+ and self.field_info.default is None
330
+ and getattr(self.field_info, "default_factory", None) is None
331
+ )
332
+
333
+ placeholder_text = f"Select {self.original_field_name.replace('_', ' ')}"
334
+ if self.is_optional:
335
+ placeholder_text += " (Optional)"
336
+
337
+ input_attrs = {
338
+ "value": formatted_value,
339
+ "id": self.field_name,
340
+ "name": self.field_name,
341
+ "type": "time",
342
+ "placeholder": placeholder_text,
343
+ "required": is_field_required,
344
+ "cls": "w-full",
345
+ }
346
+
347
+ # Only add the disabled attribute if the field should actually be disabled
348
+ if self.disabled:
349
+ input_attrs["disabled"] = True
350
+
351
+ return mui.Input(**input_attrs)
352
+
353
+
354
+ class LiteralFieldRenderer(BaseFieldRenderer):
355
+ """Renderer for Literal fields as dropdown selects"""
356
+
357
+ def render_input(self) -> FT:
358
+ """
359
+ Render input element for the field as a select dropdown
360
+
361
+ Returns:
362
+ A Select component with options based on the Literal values
363
+ """
364
+ # Get the Literal values from annotation
365
+ annotation = _get_underlying_type_if_optional(self.field_info.annotation)
366
+ literal_values = get_args(annotation)
367
+
368
+ if not literal_values:
369
+ return mui.Alert(
370
+ f"No literal values found for {self.field_name}", cls=mui.AlertT.warning
371
+ )
372
+
373
+ # Determine if field is required
374
+ is_field_required = (
375
+ not self.is_optional
376
+ and self.field_info.default is None
377
+ and getattr(self.field_info, "default_factory", None) is None
378
+ )
379
+
380
+ # Create options for each literal value
381
+ options = []
382
+ current_value_str = str(self.value) if self.value is not None else None
383
+
384
+ # Add empty option for optional fields
385
+ if self.is_optional:
386
+ options.append(
387
+ fh.Option("-- None --", value="", selected=(self.value is None))
388
+ )
389
+
390
+ # Add options for each literal value
391
+ for value in literal_values:
392
+ value_str = str(value)
393
+ is_selected = current_value_str == value_str
394
+ options.append(
395
+ fh.Option(
396
+ value_str, # Display text
397
+ value=value_str, # Value attribute
398
+ selected=is_selected,
399
+ )
400
+ )
401
+
402
+ placeholder_text = f"Select {self.original_field_name.replace('_', ' ')}"
403
+ if self.is_optional:
404
+ placeholder_text += " (Optional)"
405
+
406
+ # Prepare attributes dictionary
407
+ select_attrs = {
408
+ "id": self.field_name,
409
+ "name": self.field_name,
410
+ "required": is_field_required,
411
+ "placeholder": placeholder_text,
412
+ "cls": "w-full",
413
+ }
414
+
415
+ # Only add the disabled attribute if the field should actually be disabled
416
+ if self.disabled:
417
+ select_attrs["disabled"] = True
418
+
419
+ # Render the select element with options and attributes
420
+ return mui.Select(*options, **select_attrs)
421
+
422
+
423
+ class BaseModelFieldRenderer(BaseFieldRenderer):
424
+ """Renderer for nested Pydantic BaseModel fields"""
425
+
426
+ def render(self) -> FT:
427
+ """
428
+ Render the nested BaseModel field as a single-item accordion using mui.Accordion.
429
+
430
+ Returns:
431
+ A FastHTML component (mui.Accordion) containing the accordion structure.
432
+ """
433
+ # 1. Get the label content (the inner Span with text/tooltip)
434
+ label_component = self.render_label()
435
+ if isinstance(label_component, fh.FT) and label_component.children:
436
+ label_content = label_component.children[0]
437
+ # Extract label style if present
438
+ label_style = label_component.attrs.get("style", "")
439
+ # Apply label style directly to the span if needed
440
+ if label_style:
441
+ # Check if label_content is already a Span, otherwise wrap it
442
+ if isinstance(label_content, fh.Span):
443
+ label_content.attrs["style"] = label_style
444
+ else:
445
+ # This case is less likely if render_label returns Label(Span(...))
446
+ label_content = fh.Span(label_content, style=label_style)
447
+ else:
448
+ # Fallback if structure is different (should not happen ideally)
449
+ label_content = self.original_field_name.replace("_", " ").title()
450
+ label_style = f"color: {self.label_color};" if self.label_color else ""
451
+ if label_style:
452
+ label_content = fh.Span(label_content, style=label_style)
453
+
454
+ # 2. Render the nested input fields that will be the accordion content
455
+ input_component = self.render_input()
456
+
457
+ # 3. Define unique IDs for potential targeting
458
+ item_id = f"{self.field_name}_item"
459
+ accordion_id = f"{self.field_name}_accordion"
460
+
461
+ # 4. Create the AccordionItem using the MonsterUI component
462
+ # - Pass label_content as the title.
463
+ # - Pass input_component as the content.
464
+ # - Set 'open=True' to be expanded by default.
465
+ # - Pass item_id via li_kwargs.
466
+ # - Add 'mb-4' class for bottom margin spacing.
467
+ accordion_item = mui.AccordionItem(
468
+ label_content, # Title component (already potentially styled Span)
469
+ input_component, # Content component (the Card with nested fields)
470
+ open=True, # Open by default
471
+ li_kwargs={"id": item_id}, # Pass the specific ID for the <li>
472
+ cls="mb-4", # Add bottom margin to the <li> element
473
+ )
474
+
475
+ # 5. Wrap the single AccordionItem in an Accordion container
476
+ # - Set multiple=True (harmless for single item)
477
+ # - Set collapsible=True
478
+ accordion_container = mui.Accordion(
479
+ accordion_item, # The single item to include
480
+ id=accordion_id, # ID for the accordion container (ul)
481
+ multiple=True, # Allow multiple open (though only one exists)
482
+ collapsible=True, # Allow toggling
483
+ )
484
+
485
+ return accordion_container
486
+
487
+ def render_input(self) -> FT:
488
+ """
489
+ Render input elements for nested model fields
490
+
491
+ Returns:
492
+ A Card component containing nested form fields
493
+ """
494
+ # Get the nested model class from annotation
495
+ nested_model_class = _get_underlying_type_if_optional(
496
+ self.field_info.annotation
497
+ )
498
+
499
+ if nested_model_class is None or not hasattr(
500
+ nested_model_class, "model_fields"
501
+ ):
502
+ return mui.Alert(
503
+ f"No nested model class found for {self.field_name}",
504
+ cls=mui.AlertT.error,
505
+ )
506
+
507
+ # Prepare values dict
508
+ values_dict = (
509
+ self.value.model_dump()
510
+ if hasattr(self.value, "model_dump")
511
+ else self.value
512
+ if isinstance(self.value, dict)
513
+ else {}
514
+ )
515
+
516
+ # Create nested field inputs directly instead of using FormRenderer
517
+ nested_inputs = []
518
+
519
+ for (
520
+ nested_field_name,
521
+ nested_field_info,
522
+ ) in nested_model_class.model_fields.items():
523
+ # Determine initial value
524
+ nested_field_value = (
525
+ values_dict.get(nested_field_name) if values_dict else None
526
+ )
527
+
528
+ # Apply default if needed
529
+ if nested_field_value is None:
530
+ if nested_field_info.default is not None:
531
+ nested_field_value = nested_field_info.default
532
+ elif getattr(nested_field_info, "default_factory", None) is not None:
533
+ try:
534
+ nested_field_value = nested_field_info.default_factory()
535
+ except Exception:
536
+ nested_field_value = None
537
+
538
+ # Get renderer for this nested field
539
+ registry = FieldRendererRegistry() # Get singleton instance
540
+ renderer_cls = registry.get_renderer(nested_field_name, nested_field_info)
541
+
542
+ if not renderer_cls:
543
+ # Fall back to StringFieldRenderer if no renderer found
544
+ renderer_cls = StringFieldRenderer
545
+
546
+ # The prefix for nested fields is simply the field_name of this BaseModel instance + underscore
547
+ # field_name already includes the form prefix, so we don't need to add self.prefix again
548
+ nested_prefix = f"{self.field_name}_"
549
+
550
+ # Create and render the nested field
551
+ renderer = renderer_cls(
552
+ field_name=nested_field_name,
553
+ field_info=nested_field_info,
554
+ value=nested_field_value,
555
+ prefix=nested_prefix,
556
+ disabled=self.disabled, # Propagate disabled state to nested fields
557
+ )
558
+
559
+ nested_inputs.append(renderer.render())
560
+
561
+ # Create container for nested inputs
562
+ nested_form_content = mui.DivVStacked(
563
+ *nested_inputs, cls="space-y-3 items-stretch"
564
+ )
565
+
566
+ # Wrap in card for visual distinction
567
+ return mui.Card(
568
+ nested_form_content,
569
+ cls="p-3 mt-1 border rounded",
570
+ )
571
+
572
+
573
+ class ListFieldRenderer(BaseFieldRenderer):
574
+ """Renderer for list fields containing any type"""
575
+
576
+ def render(self) -> FT:
577
+ """
578
+ Render the complete field (label + input) with spacing, adding a refresh icon for list fields.
579
+ Makes the label clickable to toggle all list items open/closed.
580
+
581
+ Returns:
582
+ A FastHTML component containing the complete field with refresh icon
583
+ """
584
+ # Extract form name from prefix (removing trailing underscore if present)
585
+ form_name = self.prefix.rstrip("_") if self.prefix else None
586
+
587
+ # Get the original label
588
+ original_label = self.render_label()
589
+
590
+ # Construct the container ID that will be generated by render_input()
591
+ container_id = f"{self.prefix}{self.original_field_name}_items_container"
592
+
593
+ # Only add refresh icon if we have a form name
594
+ if form_name:
595
+ # Create the smaller icon component
596
+ refresh_icon_component = mui.UkIcon(
597
+ "refresh-ccw",
598
+ cls="w-3 h-3 text-gray-500 hover:text-blue-600", # Smaller size
599
+ )
600
+
601
+ # Create the clickable span wrapper for the icon
602
+ refresh_icon_trigger = fh.Span(
603
+ refresh_icon_component,
604
+ cls="ml-1 inline-block align-middle cursor-pointer", # Add margin, ensure inline-like behavior
605
+ hx_post=f"/form/{form_name}/refresh",
606
+ hx_target=f"#{form_name}-inputs-wrapper",
607
+ hx_swap="innerHTML",
608
+ hx_include=f"#{form_name}-form",
609
+ uk_tooltip="Refresh form display to update list summaries",
610
+ )
611
+
612
+ # Combine label and icon
613
+ label_with_icon = fh.Div(
614
+ original_label,
615
+ refresh_icon_trigger,
616
+ cls="flex items-center cursor-pointer", # Added cursor-pointer
617
+ onclick=f"toggleListItems('{container_id}'); return false;", # Add click handler
618
+ )
619
+ else:
620
+ # If no form name, just use the original label but still make it clickable
621
+ label_with_icon = fh.Div(
622
+ original_label,
623
+ cls="flex items-center cursor-pointer", # Added cursor-pointer
624
+ onclick=f"toggleListItems('{container_id}'); return false;", # Add click handler
625
+ uk_tooltip="Click to toggle all items open/closed",
626
+ )
627
+
628
+ # Return container with label+icon and input
629
+ return fh.Div(label_with_icon, self.render_input(), cls="mb-4")
630
+
631
+ def render_input(self) -> FT:
632
+ """
633
+ Render a list of items with add/delete/move capabilities
634
+
635
+ Returns:
636
+ A component containing the list items and controls
637
+ """
638
+ # Initialize the value as an empty list, ensuring it's always a list
639
+ items = [] if not isinstance(self.value, list) else self.value
640
+
641
+ annotation = getattr(self.field_info, "annotation", None)
642
+
643
+ if (
644
+ annotation is not None
645
+ and hasattr(annotation, "__origin__")
646
+ and annotation.__origin__ is list
647
+ ):
648
+ item_type = annotation.__args__[0]
649
+
650
+ if not item_type:
651
+ logger.error(f"Cannot determine item type for list field {self.field_name}")
652
+ return mui.Alert(
653
+ f"Cannot determine item type for list field {self.field_name}",
654
+ cls=mui.AlertT.error,
655
+ )
656
+
657
+ # Create list items
658
+ item_elements = []
659
+ for idx, item in enumerate(items):
660
+ try:
661
+ item_card = self._render_item_card(item, idx, item_type)
662
+ item_elements.append(item_card)
663
+ except Exception as e:
664
+ logger.error(f"Error rendering item {idx}: {str(e)}", exc_info=True)
665
+ error_message = f"Error rendering item {idx}: {str(e)}"
666
+
667
+ # Add more context to the error for debugging
668
+ if isinstance(item, dict):
669
+ error_message += f" (Dict keys: {list(item.keys())})"
670
+
671
+ item_elements.append(
672
+ mui.AccordionItem(
673
+ mui.Alert(
674
+ error_message,
675
+ cls=mui.AlertT.error,
676
+ ),
677
+ # title=f"Error in item {idx}",
678
+ li_kwargs={"cls": "mb-2"},
679
+ )
680
+ )
681
+
682
+ # Container for list items with form-specific prefix in ID
683
+ container_id = f"{self.prefix}{self.original_field_name}_items_container"
684
+
685
+ # Use mui.Accordion component
686
+ accordion = mui.Accordion(
687
+ *item_elements,
688
+ id=container_id,
689
+ multiple=True, # Allow multiple items to be open at once
690
+ collapsible=True, # Make it collapsible
691
+ cls="space-y-2", # Add space between items
692
+ )
693
+
694
+ # Empty state message if no items
695
+ empty_state = ""
696
+ if not items:
697
+ # Extract form name from prefix if available
698
+ form_name = self.prefix.rstrip("_") if self.prefix else None
699
+
700
+ # Check if it's a simple type or BaseModel
701
+ add_url = (
702
+ f"/form/{form_name}/list/add/{self.original_field_name}"
703
+ if form_name
704
+ else f"/list/add/{self.field_name}"
705
+ )
706
+
707
+ # Prepare button attributes
708
+ add_button_attrs = {
709
+ "cls": "uk-button-primary uk-button-small mt-2",
710
+ "hx_post": add_url,
711
+ "hx_target": f"#{container_id}",
712
+ "hx_swap": "beforeend",
713
+ "type": "button",
714
+ }
715
+
716
+ # Only add disabled attribute if field should be disabled
717
+ if self.disabled:
718
+ add_button_attrs["disabled"] = "true"
719
+
720
+ empty_state = mui.Alert(
721
+ fh.Div(
722
+ mui.UkIcon("info", cls="mr-2"),
723
+ "No items in this list. Click 'Add Item' to create one.",
724
+ mui.Button("Add Item", **add_button_attrs),
725
+ cls="flex flex-col items-start",
726
+ ),
727
+ cls=mui.AlertT.info,
728
+ )
729
+
730
+ # Return the complete component
731
+ return fh.Div(
732
+ accordion,
733
+ empty_state,
734
+ cls="mb-4 border rounded-md p-4",
735
+ )
736
+
737
+ def _render_item_card(self, item, idx, item_type, is_open=False) -> FT:
738
+ """
739
+ Render a card for a single item in the list
740
+
741
+ Args:
742
+ item: The item data
743
+ idx: The index of the item
744
+ item_type: The type of the item
745
+ is_open: Whether the accordion item should be open by default
746
+
747
+ Returns:
748
+ A FastHTML component for the item card
749
+ """
750
+ try:
751
+ # Create a unique ID for this item
752
+ item_id = f"{self.field_name}_{idx}"
753
+ item_card_id = f"{item_id}_card"
754
+
755
+ # Extract form name from prefix if available
756
+ form_name = self.prefix.rstrip("_") if self.prefix else None
757
+
758
+ # Check if it's a simple type or BaseModel
759
+ is_model = hasattr(item_type, "model_fields")
760
+
761
+ # --- Generate item summary for the accordion title ---
762
+ if is_model:
763
+ try:
764
+ # Determine how to get the string representation based on item type
765
+ if isinstance(item, item_type):
766
+ # Item is already a model instance
767
+ model_for_display = item
768
+
769
+ elif isinstance(item, dict):
770
+ # Item is a dict, try to create a model instance for display
771
+ model_for_display = item_type.model_validate(item)
772
+
773
+ else:
774
+ # Handle cases where item is None or unexpected type
775
+ model_for_display = None
776
+ logger.warning(
777
+ f"Item {item} is neither a model instance nor a dict: {type(item).__name__}"
778
+ )
779
+
780
+ if model_for_display is not None:
781
+ # Use the model's __str__ method
782
+ item_summary_text = (
783
+ f"{item_type.__name__}: {str(model_for_display)}"
784
+ )
785
+ else:
786
+ # Fallback for None or unexpected types
787
+ item_summary_text = f"{item_type.__name__}: (Unknown format: {type(item).__name__})"
788
+ logger.warning(
789
+ f"Using fallback summary text: {item_summary_text}"
790
+ )
791
+ except ValidationError as e:
792
+ # Handle validation errors when creating model from dict
793
+ logger.warning(
794
+ f"Validation error creating display string for {item_type.__name__}: {e}"
795
+ )
796
+ if isinstance(item, dict):
797
+ logger.warning(
798
+ f"Validation failed for dict keys: {list(item.keys())}"
799
+ )
800
+ item_summary_text = f"{item_type.__name__}: (Invalid data)"
801
+ except Exception as e:
802
+ # Catch any other unexpected errors
803
+ logger.error(
804
+ f"Error creating display string for {item_type.__name__}: {e}",
805
+ exc_info=True,
806
+ )
807
+ item_summary_text = f"{item_type.__name__}: (Error displaying item)"
808
+ else:
809
+ item_summary_text = str(item)
810
+
811
+ # --- Render item content elements ---
812
+ item_content_elements = []
813
+
814
+ if is_model:
815
+ # Handle BaseModel items - include the form prefix in nested items
816
+ # Form name prefix + field name + index + _
817
+ name_prefix = f"{self.prefix}{self.original_field_name}_{idx}_"
818
+
819
+ nested_values = (
820
+ item.model_dump()
821
+ if hasattr(item, "model_dump")
822
+ else item
823
+ if isinstance(item, dict)
824
+ else {}
825
+ )
826
+
827
+ # Check if there's a specific renderer registered for this item_type
828
+ registry = FieldRendererRegistry()
829
+ # Create a dummy FieldInfo for the renderer lookup
830
+ item_field_info = FieldInfo(annotation=item_type)
831
+ # Look up potential custom renderer for this item type
832
+ item_renderer_cls = registry.get_renderer(
833
+ f"item_{idx}", item_field_info
834
+ )
835
+
836
+ # Get the default BaseModelFieldRenderer class for comparison
837
+ from_imports = globals()
838
+ BaseModelFieldRenderer_cls = from_imports.get("BaseModelFieldRenderer")
839
+
840
+ # Check if a specific renderer (different from BaseModelFieldRenderer) was found
841
+ if (
842
+ item_renderer_cls
843
+ and item_renderer_cls is not BaseModelFieldRenderer_cls
844
+ ):
845
+ # Use the custom renderer for the entire item
846
+ item_renderer = item_renderer_cls(
847
+ field_name=f"{self.original_field_name}_{idx}",
848
+ field_info=item_field_info,
849
+ value=item,
850
+ prefix=self.prefix,
851
+ disabled=self.disabled, # Propagate disabled state
852
+ )
853
+ # Add the rendered input to content elements
854
+ item_content_elements.append(item_renderer.render_input())
855
+ else:
856
+ # Fall back to original behavior: render each field individually
857
+ for (
858
+ nested_field_name,
859
+ nested_field_info,
860
+ ) in item_type.model_fields.items():
861
+ nested_field_value = nested_values.get(nested_field_name)
862
+
863
+ # Apply default if needed
864
+ if (
865
+ nested_field_value is None
866
+ and hasattr(nested_field_info, "default")
867
+ and nested_field_info.default is not None
868
+ ):
869
+ nested_field_value = nested_field_info.default
870
+ elif (
871
+ nested_field_value is None
872
+ and hasattr(nested_field_info, "default_factory")
873
+ and nested_field_info.default_factory is not None
874
+ ):
875
+ try:
876
+ nested_field_value = nested_field_info.default_factory()
877
+ except Exception:
878
+ pass
879
+
880
+ # Get renderer and render field
881
+ renderer_cls = FieldRendererRegistry().get_renderer(
882
+ nested_field_name, nested_field_info
883
+ )
884
+ renderer = renderer_cls(
885
+ field_name=nested_field_name,
886
+ field_info=nested_field_info,
887
+ value=nested_field_value,
888
+ prefix=name_prefix,
889
+ disabled=self.disabled, # Propagate disabled state
890
+ )
891
+
892
+ # Add rendered field to content elements
893
+ item_content_elements.append(renderer.render())
894
+ else:
895
+ # Handle simple type items
896
+ field_info = FieldInfo(annotation=item_type)
897
+ renderer_cls = FieldRendererRegistry().get_renderer(
898
+ f"item_{idx}", field_info
899
+ )
900
+ # Calculate the base name for the item within the list
901
+ item_base_name = f"{self.original_field_name}_{idx}" # e.g., "tags_0"
902
+
903
+ simple_renderer = renderer_cls(
904
+ field_name=item_base_name, # Correct: Use name relative to list field
905
+ field_info=field_info,
906
+ value=item,
907
+ prefix=self.prefix, # Correct: Provide the form prefix
908
+ disabled=self.disabled, # Propagate disabled state
909
+ )
910
+ input_element = simple_renderer.render_input()
911
+ item_content_elements.append(fh.Div(input_element))
912
+
913
+ # --- Create action buttons with form-specific URLs ---
914
+ # Generate HTMX endpoints with form name if available
915
+ delete_url = (
916
+ f"/form/{form_name}/list/delete/{self.original_field_name}"
917
+ if form_name
918
+ else f"/list/delete/{self.field_name}"
919
+ )
920
+
921
+ add_url = (
922
+ f"/form/{form_name}/list/add/{self.original_field_name}"
923
+ if form_name
924
+ else f"/list/add/{self.field_name}"
925
+ )
926
+
927
+ # Use the full ID (with prefix) for targeting
928
+ full_card_id = (
929
+ f"{self.prefix}{item_card_id}" if self.prefix else item_card_id
930
+ )
931
+
932
+ # Create attribute dictionaries for buttons
933
+ delete_button_attrs = {
934
+ "cls": "uk-button-danger uk-button-small",
935
+ "hx_delete": delete_url,
936
+ "hx_target": f"#{full_card_id}",
937
+ "hx_swap": "outerHTML",
938
+ "uk_tooltip": "Delete this item",
939
+ "hx_params": f"idx={idx}",
940
+ "hx_confirm": "Are you sure you want to delete this item?",
941
+ "type": "button", # Prevent form submission
942
+ }
943
+
944
+ add_below_button_attrs = {
945
+ "cls": "uk-button-secondary uk-button-small ml-2",
946
+ "hx_post": add_url,
947
+ "hx_target": f"#{full_card_id}",
948
+ "hx_swap": "afterend",
949
+ "uk_tooltip": "Insert new item below",
950
+ "type": "button", # Prevent form submission
951
+ }
952
+
953
+ move_up_button_attrs = {
954
+ "cls": "uk-button-link move-up-btn",
955
+ "onclick": "moveItemUp(this); return false;",
956
+ "uk_tooltip": "Move up",
957
+ "type": "button", # Prevent form submission
958
+ }
959
+
960
+ move_down_button_attrs = {
961
+ "cls": "uk-button-link move-down-btn ml-2",
962
+ "onclick": "moveItemDown(this); return false;",
963
+ "uk_tooltip": "Move down",
964
+ "type": "button", # Prevent form submission
965
+ }
966
+
967
+ # Create buttons using attribute dictionaries, passing disabled state directly
968
+ delete_button = mui.Button(
969
+ mui.UkIcon("trash"), disabled=self.disabled, **delete_button_attrs
970
+ )
971
+
972
+ add_below_button = mui.Button(
973
+ mui.UkIcon("plus-circle"),
974
+ disabled=self.disabled,
975
+ **add_below_button_attrs,
976
+ )
977
+
978
+ move_up_button = mui.Button(
979
+ mui.UkIcon("arrow-up"), disabled=self.disabled, **move_up_button_attrs
980
+ )
981
+
982
+ move_down_button = mui.Button(
983
+ mui.UkIcon("arrow-down"),
984
+ disabled=self.disabled,
985
+ **move_down_button_attrs,
986
+ )
987
+
988
+ # Assemble actions div
989
+ actions = fh.Div(
990
+ fh.Div( # Left side buttons
991
+ delete_button, add_below_button, cls="flex items-center"
992
+ ),
993
+ fh.Div( # Right side buttons
994
+ move_up_button, move_down_button, cls="flex items-center space-x-1"
995
+ ),
996
+ cls="flex justify-between w-full mt-3 pt-3 border-t border-gray-200",
997
+ )
998
+
999
+ # Create a wrapper Div for the main content elements with proper padding
1000
+ content_wrapper = fh.Div(*item_content_elements, cls="px-4 py-3 space-y-3")
1001
+
1002
+ # Return the accordion item
1003
+ title_component = fh.Span(
1004
+ item_summary_text, cls="text-gray-700 font-medium pl-3"
1005
+ )
1006
+ li_attrs = {"id": full_card_id}
1007
+
1008
+ return mui.AccordionItem(
1009
+ title_component, # Title as first positional argument
1010
+ content_wrapper, # Use the new padded wrapper for content
1011
+ actions, # More content elements
1012
+ cls="uk-card uk-card-default uk-margin-small-bottom", # Use cls keyword arg directly
1013
+ open=is_open,
1014
+ li_kwargs=li_attrs, # Pass remaining li attributes without cls
1015
+ )
1016
+
1017
+ except Exception as e:
1018
+ # Return error representation
1019
+ title_component = f"Error in item {idx}"
1020
+ content_component = mui.Alert(
1021
+ f"Error rendering item {idx}: {str(e)}", cls=mui.AlertT.error
1022
+ )
1023
+ li_attrs = {"id": f"{self.field_name}_{idx}_error_card"}
1024
+
1025
+ # Wrap error component in a div with consistent padding
1026
+ content_wrapper = fh.Div(content_component, cls="px-4 py-3")
1027
+
1028
+ return mui.AccordionItem(
1029
+ title_component, # Title as first positional argument
1030
+ content_wrapper, # Wrapped content element
1031
+ cls="mb-2", # Use cls keyword arg directly
1032
+ li_kwargs=li_attrs, # Pass remaining li attributes without cls
1033
+ )