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,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
|
+
)
|