fh-pydantic-form 0.3.1__py3-none-any.whl → 0.3.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/comparison_form.py +104 -3
- fh_pydantic_form/field_renderers.py +132 -56
- fh_pydantic_form/form_parser.py +0 -3
- fh_pydantic_form/form_renderer.py +81 -82
- fh_pydantic_form/registry.py +0 -3
- {fh_pydantic_form-0.3.1.dist-info → fh_pydantic_form-0.3.2.dist-info}/METADATA +34 -4
- {fh_pydantic_form-0.3.1.dist-info → fh_pydantic_form-0.3.2.dist-info}/RECORD +9 -9
- {fh_pydantic_form-0.3.1.dist-info → fh_pydantic_form-0.3.2.dist-info}/WHEEL +0 -0
- {fh_pydantic_form-0.3.1.dist-info → fh_pydantic_form-0.3.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -52,6 +52,16 @@ window.fhpfInitComparisonSync = function initComparisonSync(){
|
|
|
52
52
|
const sourceLi = ev.target.closest('li');
|
|
53
53
|
if (!sourceLi) return;
|
|
54
54
|
|
|
55
|
+
// Skip if this event is from a select/dropdown element
|
|
56
|
+
if (ev.target.closest('uk-select, select, [uk-select]')) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Skip if this is a nested list item (let mirrorNestedListItems handle it)
|
|
61
|
+
if (sourceLi.closest('[id$="_items_container"]')) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
55
65
|
// Find our grid-cell wrapper (both left & right share the same data-path)
|
|
56
66
|
const cell = sourceLi.closest('[data-path]');
|
|
57
67
|
if (!cell) return;
|
|
@@ -91,7 +101,99 @@ window.fhpfInitComparisonSync = function initComparisonSync(){
|
|
|
91
101
|
});
|
|
92
102
|
}
|
|
93
103
|
|
|
94
|
-
// 3)
|
|
104
|
+
// 3) Sync nested list item accordions (individual items within lists)
|
|
105
|
+
UIkit.util.on(
|
|
106
|
+
document,
|
|
107
|
+
'show hide',
|
|
108
|
+
'[id$="_items_container"] > li', // only list items within items containers
|
|
109
|
+
mirrorNestedListItems
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
function mirrorNestedListItems(ev) {
|
|
113
|
+
const sourceLi = ev.target.closest('li');
|
|
114
|
+
if (!sourceLi) return;
|
|
115
|
+
|
|
116
|
+
// Skip if this event is from a select/dropdown element
|
|
117
|
+
if (ev.target.closest('uk-select, select, [uk-select]')) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Skip if this event was triggered by our own sync
|
|
122
|
+
if (sourceLi.dataset.syncDisabled) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Find the list container (items_container) that contains this item
|
|
127
|
+
const listContainer = sourceLi.closest('[id$="_items_container"]');
|
|
128
|
+
if (!listContainer) return;
|
|
129
|
+
|
|
130
|
+
// Find the grid cell wrapper with data-path
|
|
131
|
+
const cell = listContainer.closest('[data-path]');
|
|
132
|
+
if (!cell) return;
|
|
133
|
+
const path = cell.dataset.path;
|
|
134
|
+
|
|
135
|
+
// Determine index of this <li> within its list container
|
|
136
|
+
const listAccordion = sourceLi.parentElement;
|
|
137
|
+
const idx = Array.prototype.indexOf.call(listAccordion.children, sourceLi);
|
|
138
|
+
const opening = ev.type === 'show';
|
|
139
|
+
|
|
140
|
+
// Mirror on the other side
|
|
141
|
+
document
|
|
142
|
+
.querySelectorAll(`[data-path="${path}"]`)
|
|
143
|
+
.forEach(peerCell => {
|
|
144
|
+
if (peerCell === cell) return;
|
|
145
|
+
|
|
146
|
+
// Find the peer's list container
|
|
147
|
+
const peerListContainer = peerCell.querySelector('[id$="_items_container"]');
|
|
148
|
+
if (!peerListContainer) return;
|
|
149
|
+
|
|
150
|
+
// The list container IS the accordion itself (not a wrapper around it)
|
|
151
|
+
let peerListAccordion;
|
|
152
|
+
if (peerListContainer.hasAttribute('uk-accordion') && peerListContainer.tagName === 'UL') {
|
|
153
|
+
peerListAccordion = peerListContainer;
|
|
154
|
+
} else {
|
|
155
|
+
peerListAccordion = peerListContainer.querySelector('ul[uk-accordion]');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (!peerListAccordion || idx >= peerListAccordion.children.length) return;
|
|
159
|
+
|
|
160
|
+
const peerLi = peerListAccordion.children[idx];
|
|
161
|
+
const peerContent = peerLi.querySelector('.uk-accordion-content');
|
|
162
|
+
|
|
163
|
+
// Prevent event cascading by temporarily disabling our own event listener
|
|
164
|
+
if (peerLi.dataset.syncDisabled) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Mark this item as being synced to prevent loops
|
|
169
|
+
peerLi.dataset.syncDisabled = 'true';
|
|
170
|
+
|
|
171
|
+
// Check current state and only sync if different
|
|
172
|
+
const currentlyOpen = peerLi.classList.contains('uk-open');
|
|
173
|
+
|
|
174
|
+
if (currentlyOpen !== opening) {
|
|
175
|
+
if (opening) {
|
|
176
|
+
peerLi.classList.add('uk-open');
|
|
177
|
+
if (peerContent) {
|
|
178
|
+
peerContent.hidden = false;
|
|
179
|
+
peerContent.style.height = 'auto';
|
|
180
|
+
}
|
|
181
|
+
} else {
|
|
182
|
+
peerLi.classList.remove('uk-open');
|
|
183
|
+
if (peerContent) {
|
|
184
|
+
peerContent.hidden = true;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Re-enable sync after a short delay
|
|
190
|
+
setTimeout(() => {
|
|
191
|
+
delete peerLi.dataset.syncDisabled;
|
|
192
|
+
}, 100);
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 4) Wrap the list-toggle so ListFieldRenderer accordions sync too
|
|
95
197
|
if (typeof window.toggleListItems === 'function' && !window.__listSyncWrapped) {
|
|
96
198
|
// guard to only wrap once
|
|
97
199
|
window.__listSyncWrapped = true;
|
|
@@ -455,13 +557,11 @@ class ComparisonForm(Generic[ModelType]):
|
|
|
455
557
|
reset_path = f"/compare/{self.name}/{side}/reset"
|
|
456
558
|
reset_handler = create_reset_handler(form, side, label)
|
|
457
559
|
app.route(reset_path, methods=["POST"])(reset_handler)
|
|
458
|
-
logger.debug(f"Registered comparison reset route: {reset_path}")
|
|
459
560
|
|
|
460
561
|
# Refresh route
|
|
461
562
|
refresh_path = f"/compare/{self.name}/{side}/refresh"
|
|
462
563
|
refresh_handler = create_refresh_handler(form, side, label)
|
|
463
564
|
app.route(refresh_path, methods=["POST"])(refresh_handler)
|
|
464
|
-
logger.debug(f"Registered comparison refresh route: {refresh_path}")
|
|
465
565
|
|
|
466
566
|
def form_wrapper(self, content: FT, form_id: Optional[str] = None) -> FT:
|
|
467
567
|
"""
|
|
@@ -506,6 +606,7 @@ class ComparisonForm(Generic[ModelType]):
|
|
|
506
606
|
kwargs.setdefault("hx_target", f"#{form.name}-inputs-wrapper")
|
|
507
607
|
kwargs.setdefault("hx_swap", "innerHTML")
|
|
508
608
|
kwargs.setdefault("hx_include", prefix_selector)
|
|
609
|
+
kwargs.setdefault("hx_preserve", "scroll")
|
|
509
610
|
|
|
510
611
|
# Delegate to the underlying form's button method
|
|
511
612
|
button_method = getattr(form, f"{action}_button")
|
|
@@ -272,9 +272,6 @@ class MetricsRendererMixin:
|
|
|
272
272
|
existing_style = node.attrs.get("style", "")
|
|
273
273
|
node.attrs["style"] = highlight_css + " " + existing_style
|
|
274
274
|
highlight_count += 1
|
|
275
|
-
logger.debug(
|
|
276
|
-
f"Applied highlight to tag={getattr(node, 'tag', 'unknown')}, attrs={list(node.attrs.keys()) if hasattr(node, 'attrs') else []}"
|
|
277
|
-
)
|
|
278
275
|
|
|
279
276
|
# Process children if they exist
|
|
280
277
|
if hasattr(node, "children") and node.children:
|
|
@@ -285,7 +282,7 @@ class MetricsRendererMixin:
|
|
|
285
282
|
apply_highlight(element)
|
|
286
283
|
|
|
287
284
|
if highlight_count == 0:
|
|
288
|
-
|
|
285
|
+
pass # No form controls found to highlight
|
|
289
286
|
|
|
290
287
|
return element
|
|
291
288
|
|
|
@@ -603,13 +600,9 @@ class StringFieldRenderer(BaseFieldRenderer):
|
|
|
603
600
|
Render input element for the field
|
|
604
601
|
|
|
605
602
|
Returns:
|
|
606
|
-
A
|
|
603
|
+
A TextArea component appropriate for string values
|
|
607
604
|
"""
|
|
608
|
-
|
|
609
|
-
# not self.is_optional
|
|
610
|
-
# and self.field_info.default is None
|
|
611
|
-
# and getattr(self.field_info, "default_factory", None) is None
|
|
612
|
-
# )
|
|
605
|
+
|
|
613
606
|
has_default = get_default(self.field_info) is not _UNSET
|
|
614
607
|
is_field_required = not self.is_optional and not has_default
|
|
615
608
|
|
|
@@ -625,21 +618,40 @@ class StringFieldRenderer(BaseFieldRenderer):
|
|
|
625
618
|
if input_spacing_cls:
|
|
626
619
|
input_cls_parts.append(input_spacing_cls)
|
|
627
620
|
|
|
621
|
+
# Calculate appropriate number of rows based on content
|
|
622
|
+
if isinstance(self.value, str) and self.value:
|
|
623
|
+
# Count line breaks
|
|
624
|
+
line_count = len(self.value.split("\n"))
|
|
625
|
+
# Also consider content length for very long single lines (assuming ~60 chars per line)
|
|
626
|
+
char_count = len(self.value)
|
|
627
|
+
estimated_lines = max(line_count, (char_count // 60) + 1)
|
|
628
|
+
# Compact bounds: minimum 1 row, maximum 3 rows
|
|
629
|
+
rows = min(max(estimated_lines, 1), 3)
|
|
630
|
+
else:
|
|
631
|
+
# Single row for empty content
|
|
632
|
+
rows = 1
|
|
633
|
+
|
|
628
634
|
input_attrs = {
|
|
629
|
-
"value": self.value or "",
|
|
630
635
|
"id": self.field_name,
|
|
631
636
|
"name": self.field_name,
|
|
632
|
-
"type": "text",
|
|
633
637
|
"placeholder": placeholder_text,
|
|
634
638
|
"required": is_field_required,
|
|
635
639
|
"cls": " ".join(input_cls_parts),
|
|
640
|
+
"rows": rows,
|
|
641
|
+
"style": "resize: vertical; min-height: 2.5rem; padding: 0.5rem; line-height: 1.25;",
|
|
636
642
|
}
|
|
637
643
|
|
|
638
644
|
# Only add the disabled attribute if the field should actually be disabled
|
|
639
645
|
if self.disabled:
|
|
640
646
|
input_attrs["disabled"] = True
|
|
641
647
|
|
|
642
|
-
|
|
648
|
+
# Convert value to string representation, handling None and all other types
|
|
649
|
+
if self.value is None:
|
|
650
|
+
display_value = ""
|
|
651
|
+
else:
|
|
652
|
+
display_value = str(self.value)
|
|
653
|
+
|
|
654
|
+
return mui.TextArea(display_value, **input_attrs)
|
|
643
655
|
|
|
644
656
|
|
|
645
657
|
class NumberFieldRenderer(BaseFieldRenderer):
|
|
@@ -1394,73 +1406,110 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
1394
1406
|
label_span.attrs["uk-tooltip"] = description
|
|
1395
1407
|
label_span.attrs["title"] = description
|
|
1396
1408
|
|
|
1397
|
-
#
|
|
1398
|
-
label_span = self._decorate_label(label_span, self.metric_entry)
|
|
1399
|
-
|
|
1400
|
-
# Construct the container ID that will be generated by render_input()
|
|
1401
|
-
container_id = self._container_id()
|
|
1409
|
+
# Metric decoration will be applied to the title_component below
|
|
1402
1410
|
|
|
1403
|
-
# Only add refresh icon if we have a form name
|
|
1404
|
-
if form_name:
|
|
1411
|
+
# Only add refresh icon if we have a form name and field is not disabled
|
|
1412
|
+
if form_name and not self.disabled:
|
|
1405
1413
|
# Create the smaller icon component
|
|
1406
1414
|
refresh_icon_component = mui.UkIcon(
|
|
1407
1415
|
"refresh-ccw",
|
|
1408
1416
|
cls="w-3 h-3 text-gray-500 hover:text-blue-600", # Smaller size
|
|
1409
1417
|
)
|
|
1410
1418
|
|
|
1411
|
-
# Create the clickable span wrapper for the icon
|
|
1412
|
-
# Use prefix-based selector to include only fields from this form
|
|
1413
|
-
hx_include_selector = (
|
|
1414
|
-
f"form [name^='{self.prefix}']" if self.prefix else "closest form"
|
|
1415
|
-
)
|
|
1416
|
-
|
|
1417
1419
|
# Use override endpoint if provided (for ComparisonForm), otherwise use standard form refresh
|
|
1418
1420
|
refresh_url = (
|
|
1419
1421
|
self._refresh_endpoint_override or f"/form/{form_name}/refresh"
|
|
1420
1422
|
)
|
|
1421
1423
|
|
|
1422
|
-
|
|
1424
|
+
# Get container ID for accordion state preservation
|
|
1425
|
+
container_id = self._container_id()
|
|
1426
|
+
|
|
1427
|
+
# Create refresh icon as a button with aggressive styling reset
|
|
1428
|
+
refresh_icon_trigger = mui.Button(
|
|
1423
1429
|
refresh_icon_component,
|
|
1424
|
-
|
|
1430
|
+
type="button", # Prevent form submission
|
|
1425
1431
|
hx_post=refresh_url,
|
|
1426
1432
|
hx_target=f"#{form_name}-inputs-wrapper",
|
|
1427
1433
|
hx_swap="innerHTML",
|
|
1428
|
-
|
|
1434
|
+
hx_trigger="click", # Explicit trigger on click
|
|
1435
|
+
hx_include="closest form", # Include all form fields from the enclosing form
|
|
1436
|
+
hx_preserve="scroll",
|
|
1429
1437
|
uk_tooltip="Refresh form display to update list summaries",
|
|
1430
|
-
|
|
1431
|
-
|
|
1438
|
+
style="all: unset; display: inline-flex; align-items: center; cursor: pointer; padding: 0 0.5rem; margin-left: 0.5rem;",
|
|
1439
|
+
**{
|
|
1440
|
+
"hx-on::before-request": f"window.saveAccordionState && window.saveAccordionState('{container_id}')"
|
|
1441
|
+
},
|
|
1442
|
+
**{
|
|
1443
|
+
"hx-on::after-swap": f"window.restoreAccordionState && window.restoreAccordionState('{container_id}')"
|
|
1444
|
+
},
|
|
1432
1445
|
)
|
|
1433
1446
|
|
|
1434
|
-
# Combine label and icon
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1447
|
+
# Combine label and icon - put refresh icon in a separate div to isolate it
|
|
1448
|
+
title_component = fh.Div(
|
|
1449
|
+
fh.Div(
|
|
1450
|
+
label_span, # Use the properly styled label span
|
|
1451
|
+
cls="flex-1", # Take up remaining space
|
|
1452
|
+
),
|
|
1453
|
+
fh.Div(
|
|
1454
|
+
refresh_icon_trigger,
|
|
1455
|
+
cls="flex-shrink-0 px-1", # Don't shrink, add horizontal padding for larger click area
|
|
1456
|
+
onclick="event.stopPropagation();", # Isolate the refresh icon area
|
|
1457
|
+
),
|
|
1458
|
+
cls="flex items-center",
|
|
1440
1459
|
)
|
|
1441
1460
|
else:
|
|
1442
|
-
# If no form name, just use the styled label
|
|
1443
|
-
|
|
1461
|
+
# If no form name, just use the styled label
|
|
1462
|
+
title_component = fh.Div(
|
|
1444
1463
|
label_span, # Use the properly styled label span
|
|
1445
|
-
cls="flex items-center
|
|
1446
|
-
onclick=f"toggleListItems('{container_id}'); return false;", # Add click handler
|
|
1447
|
-
uk_tooltip="Click to toggle all items open/closed",
|
|
1464
|
+
cls="flex items-center", # Remove cursor-pointer and click handler
|
|
1448
1465
|
)
|
|
1449
1466
|
|
|
1450
|
-
#
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
self.render_input(),
|
|
1454
|
-
cls=f"{spacing('outer_margin', self.spacing)} w-full",
|
|
1467
|
+
# Apply metrics decoration to title (bullet only, no border)
|
|
1468
|
+
title_component = self._decorate_metrics(
|
|
1469
|
+
title_component, self.metric_entry, scope=DecorationScope.BULLET
|
|
1455
1470
|
)
|
|
1456
1471
|
|
|
1457
|
-
#
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1472
|
+
# Compute border color for the wrapper accordion
|
|
1473
|
+
border_color = self._metric_border_color(self.metric_entry)
|
|
1474
|
+
li_style = {}
|
|
1475
|
+
if border_color:
|
|
1476
|
+
li_style["style"] = (
|
|
1477
|
+
f"border-left: 4px solid {border_color}; padding-left: 0.25rem;"
|
|
1478
|
+
)
|
|
1479
|
+
|
|
1480
|
+
# Create the wrapper AccordionItem that contains all the list items
|
|
1481
|
+
list_content = self.render_input()
|
|
1482
|
+
|
|
1483
|
+
# Define unique IDs for the wrapper accordion
|
|
1484
|
+
wrapper_item_id = f"{self.field_name}_wrapper_item"
|
|
1485
|
+
wrapper_accordion_id = f"{self.field_name}_wrapper_accordion"
|
|
1486
|
+
|
|
1487
|
+
# Create the wrapper AccordionItem
|
|
1488
|
+
wrapper_accordion_item = mui.AccordionItem(
|
|
1489
|
+
title_component, # Title component with label and refresh icon
|
|
1490
|
+
list_content, # Content component (the list items)
|
|
1491
|
+
open=True, # Open by default
|
|
1492
|
+
li_kwargs={
|
|
1493
|
+
"id": wrapper_item_id,
|
|
1494
|
+
**li_style,
|
|
1495
|
+
},
|
|
1496
|
+
cls=spacing("outer_margin", self.spacing),
|
|
1462
1497
|
)
|
|
1463
1498
|
|
|
1499
|
+
# Wrap in an Accordion container
|
|
1500
|
+
accordion_cls = spacing_many(
|
|
1501
|
+
["accordion_divider", "accordion_content"], self.spacing
|
|
1502
|
+
)
|
|
1503
|
+
wrapper_accordion = mui.Accordion(
|
|
1504
|
+
wrapper_accordion_item,
|
|
1505
|
+
id=wrapper_accordion_id,
|
|
1506
|
+
multiple=True,
|
|
1507
|
+
collapsible=True,
|
|
1508
|
+
cls=f"{accordion_cls} w-full".strip(),
|
|
1509
|
+
)
|
|
1510
|
+
|
|
1511
|
+
return wrapper_accordion
|
|
1512
|
+
|
|
1464
1513
|
def render_input(self) -> FT:
|
|
1465
1514
|
"""
|
|
1466
1515
|
Render a list of items with add/delete/move capabilities
|
|
@@ -1561,12 +1610,12 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
1561
1610
|
cls=mui.AlertT.info,
|
|
1562
1611
|
)
|
|
1563
1612
|
|
|
1564
|
-
# Return the complete component
|
|
1613
|
+
# Return the complete component (minimal styling since it's now wrapped in an accordion)
|
|
1565
1614
|
t = self.spacing
|
|
1566
1615
|
return fh.Div(
|
|
1567
1616
|
accordion,
|
|
1568
1617
|
empty_state,
|
|
1569
|
-
cls=f"{spacing('
|
|
1618
|
+
cls=f"{spacing('padding', t)}".strip(), # Keep padding for content, remove border and margin
|
|
1570
1619
|
)
|
|
1571
1620
|
|
|
1572
1621
|
def _render_item_card(self, item, idx, item_type, is_open=False) -> FT:
|
|
@@ -1598,6 +1647,33 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
1598
1647
|
is_model = hasattr(item_type, "model_fields")
|
|
1599
1648
|
|
|
1600
1649
|
# --- Generate item summary for the accordion title ---
|
|
1650
|
+
# Create a user-friendly display index
|
|
1651
|
+
if isinstance(idx, str) and idx.startswith("new_"):
|
|
1652
|
+
# Get the type name for new items
|
|
1653
|
+
if is_model:
|
|
1654
|
+
# For BaseModel types, use the class name
|
|
1655
|
+
type_name = item_type.__name__
|
|
1656
|
+
else:
|
|
1657
|
+
# For simple types, use a friendly name
|
|
1658
|
+
type_name_map = {
|
|
1659
|
+
str: "String",
|
|
1660
|
+
int: "Number",
|
|
1661
|
+
float: "Number",
|
|
1662
|
+
bool: "Boolean",
|
|
1663
|
+
date: "Date",
|
|
1664
|
+
time: "Time",
|
|
1665
|
+
}
|
|
1666
|
+
type_name = type_name_map.get(
|
|
1667
|
+
item_type,
|
|
1668
|
+
item_type.__name__
|
|
1669
|
+
if hasattr(item_type, "__name__")
|
|
1670
|
+
else str(item_type),
|
|
1671
|
+
)
|
|
1672
|
+
|
|
1673
|
+
display_idx = f"New {type_name}"
|
|
1674
|
+
else:
|
|
1675
|
+
display_idx = str(idx)
|
|
1676
|
+
|
|
1601
1677
|
if is_model:
|
|
1602
1678
|
try:
|
|
1603
1679
|
# Determine how to get the string representation based on item type
|
|
@@ -1618,7 +1694,7 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
1618
1694
|
|
|
1619
1695
|
if model_for_display is not None:
|
|
1620
1696
|
# Use the model's __str__ method
|
|
1621
|
-
item_summary_text = f"{
|
|
1697
|
+
item_summary_text = f"{display_idx}: {str(model_for_display)}"
|
|
1622
1698
|
else:
|
|
1623
1699
|
# Fallback for None or unexpected types
|
|
1624
1700
|
item_summary_text = f"{item_type.__name__}: (Unknown format: {type(item).__name__})"
|
|
@@ -1643,7 +1719,7 @@ class ListFieldRenderer(BaseFieldRenderer):
|
|
|
1643
1719
|
)
|
|
1644
1720
|
item_summary_text = f"{item_type.__name__}: (Error displaying item)"
|
|
1645
1721
|
else:
|
|
1646
|
-
item_summary_text = f"{
|
|
1722
|
+
item_summary_text = f"{display_idx}: {str(item)}"
|
|
1647
1723
|
|
|
1648
1724
|
# --- Render item content elements ---
|
|
1649
1725
|
item_content_elements = []
|
fh_pydantic_form/form_parser.py
CHANGED
|
@@ -81,7 +81,6 @@ def _parse_non_list_fields(
|
|
|
81
81
|
|
|
82
82
|
# Skip SkipJsonSchema fields - they should not be parsed from form data
|
|
83
83
|
if _is_skip_json_schema_field(field_info):
|
|
84
|
-
logger.debug(f"Skipping SkipJsonSchema field during parsing: {field_name}")
|
|
85
84
|
continue
|
|
86
85
|
|
|
87
86
|
# Create full key with prefix
|
|
@@ -399,7 +398,6 @@ def _parse_nested_model_field(
|
|
|
399
398
|
):
|
|
400
399
|
default_value = field_info.default
|
|
401
400
|
default_applied = True
|
|
402
|
-
logger.debug(f"Nested field {field_name} using default value.")
|
|
403
401
|
elif (
|
|
404
402
|
hasattr(field_info, "default_factory")
|
|
405
403
|
and field_info.default_factory is not None
|
|
@@ -408,7 +406,6 @@ def _parse_nested_model_field(
|
|
|
408
406
|
try:
|
|
409
407
|
default_value = field_info.default_factory()
|
|
410
408
|
default_applied = True
|
|
411
|
-
logger.debug(f"Nested field {field_name} using default_factory.")
|
|
412
409
|
except Exception as e:
|
|
413
410
|
logger.warning(
|
|
414
411
|
f"Error creating default for {field_name} using default_factory: {e}"
|
|
@@ -156,6 +156,63 @@ function toggleListItems(containerId) {
|
|
|
156
156
|
}
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
// Simple accordion state preservation using item IDs
|
|
160
|
+
window.saveAccordionState = function(containerId) {
|
|
161
|
+
const container = document.getElementById(containerId);
|
|
162
|
+
if (!container) return;
|
|
163
|
+
|
|
164
|
+
const openItemIds = [];
|
|
165
|
+
container.querySelectorAll('li.uk-open').forEach(item => {
|
|
166
|
+
if (item.id) {
|
|
167
|
+
openItemIds.push(item.id);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// Store in sessionStorage with container-specific key
|
|
172
|
+
sessionStorage.setItem(`accordion_state_${containerId}`, JSON.stringify(openItemIds));
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
window.restoreAccordionState = function(containerId) {
|
|
176
|
+
const container = document.getElementById(containerId);
|
|
177
|
+
if (!container) return;
|
|
178
|
+
|
|
179
|
+
const savedState = sessionStorage.getItem(`accordion_state_${containerId}`);
|
|
180
|
+
if (!savedState) return;
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
const openItemIds = JSON.parse(savedState);
|
|
184
|
+
|
|
185
|
+
// Restore open state for each saved item by ID
|
|
186
|
+
openItemIds.forEach(itemId => {
|
|
187
|
+
const item = document.getElementById(itemId);
|
|
188
|
+
if (item && container.contains(item)) {
|
|
189
|
+
item.classList.add('uk-open');
|
|
190
|
+
const content = item.querySelector('.uk-accordion-content');
|
|
191
|
+
if (content) {
|
|
192
|
+
content.hidden = false;
|
|
193
|
+
content.style.height = 'auto';
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
} catch (e) {
|
|
198
|
+
console.warn('Failed to restore accordion state:', e);
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// Save all accordion states in the form
|
|
203
|
+
window.saveAllAccordionStates = function() {
|
|
204
|
+
document.querySelectorAll('[id$="_items_container"]').forEach(container => {
|
|
205
|
+
window.saveAccordionState(container.id);
|
|
206
|
+
});
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// Restore all accordion states in the form
|
|
210
|
+
window.restoreAllAccordionStates = function() {
|
|
211
|
+
document.querySelectorAll('[id$="_items_container"]').forEach(container => {
|
|
212
|
+
window.restoreAccordionState(container.id);
|
|
213
|
+
});
|
|
214
|
+
};
|
|
215
|
+
|
|
159
216
|
// Wait for the DOM to be fully loaded before initializing
|
|
160
217
|
document.addEventListener('DOMContentLoaded', () => {
|
|
161
218
|
// Initialize button states for elements present on initial load
|
|
@@ -306,26 +363,28 @@ class PydanticForm(Generic[ModelType]):
|
|
|
306
363
|
"""
|
|
307
364
|
self.values_dict = self.initial_values_dict.copy()
|
|
308
365
|
|
|
309
|
-
def
|
|
366
|
+
def with_initial_values(
|
|
367
|
+
self, initial_values: Optional[Union[ModelType, Dict[str, Any]]] = None
|
|
368
|
+
) -> "PydanticForm":
|
|
310
369
|
"""
|
|
311
|
-
Create a
|
|
370
|
+
Create a new PydanticForm instance with the same configuration but different initial values.
|
|
312
371
|
|
|
313
|
-
This preserves all constructor arguments (label_colors, custom_renderers, etc.)
|
|
314
|
-
to
|
|
372
|
+
This preserves all constructor arguments (label_colors, custom_renderers, spacing, etc.)
|
|
373
|
+
while allowing you to specify new initial values. This is useful for reusing form
|
|
374
|
+
configurations with different data.
|
|
315
375
|
|
|
316
376
|
Args:
|
|
317
|
-
|
|
377
|
+
initial_values: New initial values as BaseModel instance or dict.
|
|
378
|
+
Same format as the constructor accepts.
|
|
318
379
|
|
|
319
380
|
Returns:
|
|
320
|
-
A new PydanticForm instance with identical configuration but updated values
|
|
381
|
+
A new PydanticForm instance with identical configuration but updated initial values
|
|
321
382
|
"""
|
|
322
|
-
#
|
|
323
|
-
# We'll rely on global registry state being preserved
|
|
324
|
-
|
|
383
|
+
# Create the new instance with the same configuration
|
|
325
384
|
clone = PydanticForm(
|
|
326
385
|
form_name=self.name,
|
|
327
386
|
model_class=self.model_class,
|
|
328
|
-
initial_values=
|
|
387
|
+
initial_values=initial_values, # Pass through to constructor for proper handling
|
|
329
388
|
custom_renderers=None, # Registry is global, no need to re-register
|
|
330
389
|
disabled=self.disabled,
|
|
331
390
|
disabled_fields=self.disabled_fields,
|
|
@@ -335,9 +394,6 @@ class PydanticForm(Generic[ModelType]):
|
|
|
335
394
|
metrics_dict=self.metrics_dict,
|
|
336
395
|
)
|
|
337
396
|
|
|
338
|
-
# Set the values directly
|
|
339
|
-
clone.values_dict = values
|
|
340
|
-
|
|
341
397
|
return clone
|
|
342
398
|
|
|
343
399
|
def render_inputs(self) -> FT:
|
|
@@ -349,19 +405,14 @@ class PydanticForm(Generic[ModelType]):
|
|
|
349
405
|
"""
|
|
350
406
|
form_inputs = []
|
|
351
407
|
registry = FieldRendererRegistry() # Get singleton instance
|
|
352
|
-
logger.debug(
|
|
353
|
-
f"Starting render_inputs for form '{self.name}' with {len(self.model_class.model_fields)} fields"
|
|
354
|
-
)
|
|
355
408
|
|
|
356
409
|
for field_name, field_info in self.model_class.model_fields.items():
|
|
357
410
|
# Skip excluded fields
|
|
358
411
|
if field_name in self.exclude_fields:
|
|
359
|
-
logger.debug(f"Skipping excluded field: {field_name}")
|
|
360
412
|
continue
|
|
361
413
|
|
|
362
414
|
# Skip SkipJsonSchema fields (they should not be rendered in the form)
|
|
363
415
|
if _is_skip_json_schema_field(field_info):
|
|
364
|
-
logger.debug(f"Skipping SkipJsonSchema field: {field_name}")
|
|
365
416
|
continue
|
|
366
417
|
|
|
367
418
|
# Only use what was explicitly provided in initial values
|
|
@@ -375,35 +426,16 @@ class PydanticForm(Generic[ModelType]):
|
|
|
375
426
|
field_name in self.values_dict if self.values_dict else False
|
|
376
427
|
)
|
|
377
428
|
|
|
378
|
-
# Log the initial value type and a summary for debugging
|
|
379
|
-
if initial_value is not None:
|
|
380
|
-
value_type = type(initial_value).__name__
|
|
381
|
-
if isinstance(initial_value, (list, dict)):
|
|
382
|
-
value_size = f"size={len(initial_value)}"
|
|
383
|
-
else:
|
|
384
|
-
value_size = ""
|
|
385
|
-
logger.debug(
|
|
386
|
-
f"Field '{field_name}': {value_type} {value_size} (provided: {field_was_provided})"
|
|
387
|
-
)
|
|
388
|
-
else:
|
|
389
|
-
logger.debug(
|
|
390
|
-
f"Field '{field_name}': None (provided: {field_was_provided})"
|
|
391
|
-
)
|
|
392
|
-
|
|
393
429
|
# Only use defaults if field was not provided at all
|
|
394
430
|
if not field_was_provided:
|
|
395
431
|
# Field not provided - use model defaults
|
|
396
432
|
if field_info.default is not None:
|
|
397
433
|
initial_value = field_info.default
|
|
398
|
-
logger.debug(f" - Using default value for '{field_name}'")
|
|
399
434
|
elif getattr(field_info, "default_factory", None) is not None:
|
|
400
435
|
try:
|
|
401
436
|
default_factory = field_info.default_factory
|
|
402
437
|
if callable(default_factory):
|
|
403
438
|
initial_value = default_factory()
|
|
404
|
-
logger.debug(
|
|
405
|
-
f" - Using default_factory for '{field_name}'"
|
|
406
|
-
)
|
|
407
439
|
else:
|
|
408
440
|
initial_value = None
|
|
409
441
|
logger.warning(
|
|
@@ -428,9 +460,6 @@ class PydanticForm(Generic[ModelType]):
|
|
|
428
460
|
|
|
429
461
|
# Determine if this specific field should be disabled
|
|
430
462
|
is_field_disabled = self.disabled or (field_name in self.disabled_fields)
|
|
431
|
-
logger.debug(
|
|
432
|
-
f"Field '{field_name}' disabled state: {is_field_disabled} (Global: {self.disabled}, Specific: {field_name in self.disabled_fields})"
|
|
433
|
-
)
|
|
434
463
|
|
|
435
464
|
# Get label color for this field if specified
|
|
436
465
|
label_color = self.label_colors.get(field_name)
|
|
@@ -460,7 +489,6 @@ class PydanticForm(Generic[ModelType]):
|
|
|
460
489
|
|
|
461
490
|
# Define the ID for the wrapper div - this is what the HTMX request targets
|
|
462
491
|
form_content_wrapper_id = f"{self.name}-inputs-wrapper"
|
|
463
|
-
logger.debug(f"Creating form inputs wrapper with ID: {form_content_wrapper_id}")
|
|
464
492
|
|
|
465
493
|
# Create the wrapper div and apply compact styling if needed
|
|
466
494
|
wrapped = self._compact_wrapper(
|
|
@@ -490,11 +518,6 @@ class PydanticForm(Generic[ModelType]):
|
|
|
490
518
|
if key.startswith(self.base_prefix)
|
|
491
519
|
}
|
|
492
520
|
|
|
493
|
-
logger.debug(
|
|
494
|
-
f"Filtered form data for '{self.name}': "
|
|
495
|
-
f"{len(data)} keys -> {len(filtered)} keys"
|
|
496
|
-
)
|
|
497
|
-
|
|
498
521
|
return filtered
|
|
499
522
|
|
|
500
523
|
# ---- Form Renderer Methods (continued) ----
|
|
@@ -551,7 +574,7 @@ class PydanticForm(Generic[ModelType]):
|
|
|
551
574
|
self.values_dict = parsed_data.copy()
|
|
552
575
|
|
|
553
576
|
# Create temporary renderer with same configuration but updated values
|
|
554
|
-
temp_renderer = self.
|
|
577
|
+
temp_renderer = self.with_initial_values(parsed_data)
|
|
555
578
|
|
|
556
579
|
refreshed_inputs_component = temp_renderer.render_inputs()
|
|
557
580
|
|
|
@@ -585,18 +608,7 @@ class PydanticForm(Generic[ModelType]):
|
|
|
585
608
|
logger.info(f"Resetting form '{self.name}' to initial values")
|
|
586
609
|
|
|
587
610
|
# Create temporary renderer with original initial dict
|
|
588
|
-
temp_renderer =
|
|
589
|
-
form_name=self.name,
|
|
590
|
-
model_class=self.model_class,
|
|
591
|
-
initial_values=self.initial_values_dict, # Use dict instead of BaseModel
|
|
592
|
-
custom_renderers=getattr(self, "custom_renderers", None),
|
|
593
|
-
disabled=self.disabled,
|
|
594
|
-
disabled_fields=self.disabled_fields,
|
|
595
|
-
label_colors=self.label_colors,
|
|
596
|
-
exclude_fields=self.exclude_fields,
|
|
597
|
-
spacing=self.spacing,
|
|
598
|
-
metrics_dict=self.metrics_dict,
|
|
599
|
-
)
|
|
611
|
+
temp_renderer = self.with_initial_values(self.initial_values_dict)
|
|
600
612
|
|
|
601
613
|
reset_inputs_component = temp_renderer.render_inputs()
|
|
602
614
|
|
|
@@ -682,7 +694,6 @@ class PydanticForm(Generic[ModelType]):
|
|
|
682
694
|
if hasattr(initial_val, "model_dump"):
|
|
683
695
|
initial_val = initial_val.model_dump()
|
|
684
696
|
data[field_name] = initial_val
|
|
685
|
-
logger.debug(f"Injected initial value for missing field '{field_name}'")
|
|
686
697
|
continue
|
|
687
698
|
|
|
688
699
|
# Second priority: use model defaults
|
|
@@ -692,18 +703,13 @@ class PydanticForm(Generic[ModelType]):
|
|
|
692
703
|
if hasattr(default_val, "model_dump"):
|
|
693
704
|
default_val = default_val.model_dump()
|
|
694
705
|
data[field_name] = default_val
|
|
695
|
-
logger.debug(
|
|
696
|
-
f"Injected model default value for missing field '{field_name}'"
|
|
697
|
-
)
|
|
698
706
|
else:
|
|
699
707
|
# Check if this is a SkipJsonSchema field
|
|
700
708
|
if _is_skip_json_schema_field(field_info):
|
|
701
|
-
|
|
702
|
-
f"No default found for SkipJsonSchema field '{field_name}'"
|
|
703
|
-
)
|
|
709
|
+
pass # Skip fields don't need defaults
|
|
704
710
|
else:
|
|
705
711
|
# No default → leave missing; validation will surface error
|
|
706
|
-
|
|
712
|
+
pass
|
|
707
713
|
|
|
708
714
|
return data
|
|
709
715
|
|
|
@@ -722,31 +728,20 @@ class PydanticForm(Generic[ModelType]):
|
|
|
722
728
|
async def _instance_specific_refresh_handler(req):
|
|
723
729
|
"""Handle form refresh request for this specific form instance"""
|
|
724
730
|
# Add entry point logging to confirm the route is being hit
|
|
725
|
-
logger.debug(f"Received POST request on {refresh_route_path}")
|
|
726
731
|
# Calls the instance method to handle the logic
|
|
727
732
|
return await self.handle_refresh_request(req)
|
|
728
733
|
|
|
729
|
-
logger.debug(
|
|
730
|
-
f"Registered refresh route for form '{self.name}' at {refresh_route_path}"
|
|
731
|
-
)
|
|
732
|
-
|
|
733
734
|
# --- Register the form-specific reset route ---
|
|
734
735
|
reset_route_path = f"/form/{self.name}/reset"
|
|
735
736
|
|
|
736
737
|
@app.route(reset_route_path, methods=["POST"])
|
|
737
738
|
async def _instance_specific_reset_handler(req):
|
|
738
739
|
"""Handle form reset request for this specific form instance"""
|
|
739
|
-
logger.debug(f"Received POST request on {reset_route_path}")
|
|
740
740
|
# Calls the instance method to handle the logic
|
|
741
741
|
return await self.handle_reset_request()
|
|
742
742
|
|
|
743
|
-
logger.debug(
|
|
744
|
-
f"Registered reset route for form '{self.name}' at {reset_route_path}"
|
|
745
|
-
)
|
|
746
|
-
|
|
747
743
|
# Try the route with a more explicit pattern
|
|
748
744
|
route_pattern = f"/form/{self.name}/list/{{action}}/{{list_path:path}}"
|
|
749
|
-
logger.debug(f"Registering list action route: {route_pattern}")
|
|
750
745
|
|
|
751
746
|
@app.route(route_pattern, methods=["POST", "DELETE"])
|
|
752
747
|
async def list_action(req, action: str, list_path: str):
|
|
@@ -774,9 +769,6 @@ class PydanticForm(Generic[ModelType]):
|
|
|
774
769
|
return mui.Alert(str(exc), cls=mui.AlertT.error)
|
|
775
770
|
|
|
776
771
|
if req.method == "DELETE":
|
|
777
|
-
logger.debug(
|
|
778
|
-
f"Received DELETE request for {list_path} for form '{self.name}'"
|
|
779
|
-
)
|
|
780
772
|
return fh.Response(status_code=200, content="")
|
|
781
773
|
|
|
782
774
|
# === add (POST) ===
|
|
@@ -844,8 +836,15 @@ class PydanticForm(Generic[ModelType]):
|
|
|
844
836
|
"hx_swap": "innerHTML",
|
|
845
837
|
"hx_trigger": "click", # Explicit trigger on click
|
|
846
838
|
"hx_include": "closest form", # Include all form fields from the enclosing form
|
|
839
|
+
"hx_preserve": "scroll",
|
|
847
840
|
"uk_tooltip": "Update the form display based on current values (e.g., list item titles)",
|
|
848
841
|
"cls": mui.ButtonT.secondary,
|
|
842
|
+
**{
|
|
843
|
+
"hx-on::before-request": "window.saveAllAccordionStates && window.saveAllAccordionStates()"
|
|
844
|
+
},
|
|
845
|
+
**{
|
|
846
|
+
"hx-on::after-swap": "window.restoreAllAccordionStates && window.restoreAllAccordionStates()"
|
|
847
|
+
},
|
|
849
848
|
}
|
|
850
849
|
|
|
851
850
|
# Update with any additional attributes
|
|
@@ -881,6 +880,7 @@ class PydanticForm(Generic[ModelType]):
|
|
|
881
880
|
"hx_target": f"#{form_content_wrapper_id}", # Target the wrapper Div ID
|
|
882
881
|
"hx_swap": "innerHTML",
|
|
883
882
|
"hx_confirm": "Are you sure you want to reset the form to its initial values? Any unsaved changes will be lost.",
|
|
883
|
+
"hx_preserve": "scroll",
|
|
884
884
|
"uk_tooltip": "Reset the form fields to their original values",
|
|
885
885
|
"cls": mui.ButtonT.destructive, # Use danger style to indicate destructive action
|
|
886
886
|
}
|
|
@@ -914,7 +914,6 @@ class PydanticForm(Generic[ModelType]):
|
|
|
914
914
|
Raises:
|
|
915
915
|
ValidationError: If validation fails based on the model's rules
|
|
916
916
|
"""
|
|
917
|
-
logger.debug(f"Validating request for form '{self.name}'")
|
|
918
917
|
form_data = await req.form()
|
|
919
918
|
form_dict = dict(form_data)
|
|
920
919
|
|
fh_pydantic_form/registry.py
CHANGED
|
@@ -39,10 +39,7 @@ class FieldRendererRegistry:
|
|
|
39
39
|
|
|
40
40
|
def __new__(cls, *args, **kwargs):
|
|
41
41
|
if cls._instance is None:
|
|
42
|
-
logger.debug("Creating new FieldRendererRegistry singleton instance.")
|
|
43
42
|
cls._instance = super().__new__(cls)
|
|
44
|
-
else:
|
|
45
|
-
logger.debug("Returning existing FieldRendererRegistry singleton instance.")
|
|
46
43
|
return cls._instance
|
|
47
44
|
|
|
48
45
|
@classmethod
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fh-pydantic-form
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: a library to turn any pydantic BaseModel object into a fasthtml/monsterui input form
|
|
5
5
|
Project-URL: Homepage, https://github.com/Marcura/fh-pydantic-form
|
|
6
6
|
Project-URL: Repository, https://github.com/Marcura/fh-pydantic-form
|
|
@@ -30,15 +30,16 @@ Description-Content-Type: text/markdown
|
|
|
30
30
|
|
|
31
31
|
**Generate HTML forms from Pydantic models for your FastHTML applications.**
|
|
32
32
|
|
|
33
|
-
`fh-pydantic-form` simplifies creating web forms for [FastHTML](https://github.com/AnswerDotAI/fasthtml) by automatically generating the necessary HTML input elements based on your Pydantic model definitions. It integrates seamlessly with and leverages [MonsterUI](https://github.com/AnswerDotAI/monsterui) components for styling.
|
|
33
|
+
`fh-pydantic-form` simplifies creating web forms for [FastHTML](https://github.com/AnswerDotAI/fasthtml) by automatically generating the necessary HTML input elements based on your Pydantic model definitions. It integrates seamlessly with and leverages [MonsterUI](https://github.com/AnswerDotAI/monsterui) components for styling. This makes it ideal for annotation workflows for structured outputs: when the schema updates, so does your annotation app.
|
|
34
|
+
|
|
35
|
+
<img width="1072" alt="image" src="https://github.com/user-attachments/assets/9e189266-0118-4de2-a35c-1118f11b0581" />
|
|
34
36
|
|
|
35
|
-
<img width="1348" alt="image" src="https://github.com/user-attachments/assets/59cc4f10-6858-41cb-80ed-e735a883cf20" />
|
|
36
37
|
|
|
37
38
|
|
|
38
39
|
|
|
39
40
|
<details >
|
|
40
41
|
<summary>show demo screen recording</summary>
|
|
41
|
-
<video src="https://private-user-images.githubusercontent.com/27999937/
|
|
42
|
+
<video src="https://private-user-images.githubusercontent.com/27999937/462832274-a00096d9-47ee-485b-af0a-e74838ec6c6d.mp4" controls="controls">
|
|
42
43
|
</video>
|
|
43
44
|
</details>
|
|
44
45
|
|
|
@@ -867,6 +868,34 @@ form_renderer = PydanticForm("my_form", MyModel, initial_values=initial_values_d
|
|
|
867
868
|
|
|
868
869
|
The dictionary does not have to be complete, and we try to handle schema drift gracefully. If you exclude fields from the form, we fill those fields with the initial_values or the default values.
|
|
869
870
|
|
|
871
|
+
### Reusing Form Configuration with Different Values
|
|
872
|
+
|
|
873
|
+
The `with_initial_values()` method allows you to create a new form instance with the same configuration but different initial values:
|
|
874
|
+
|
|
875
|
+
```python
|
|
876
|
+
# Create a base form configuration
|
|
877
|
+
base_form = PydanticForm(
|
|
878
|
+
"product_form",
|
|
879
|
+
ProductModel,
|
|
880
|
+
disabled_fields=["id"],
|
|
881
|
+
label_colors={"name": "text-blue-600", "price": "text-green-600"},
|
|
882
|
+
spacing="compact"
|
|
883
|
+
)
|
|
884
|
+
|
|
885
|
+
# Create forms with different initial values using the same configuration
|
|
886
|
+
form_for_product_a = base_form.with_initial_values({"name": "Product A", "price": 29.99})
|
|
887
|
+
form_for_product_b = base_form.with_initial_values({"name": "Product B", "price": 45.50})
|
|
888
|
+
|
|
889
|
+
# Or with model instances
|
|
890
|
+
existing_product = ProductModel(name="Existing Product", price=19.99)
|
|
891
|
+
form_for_existing = base_form.with_initial_values(existing_product)
|
|
892
|
+
```
|
|
893
|
+
|
|
894
|
+
This is particularly useful for:
|
|
895
|
+
- **Editing workflows** where you need the same form configuration for different records
|
|
896
|
+
- **Template forms** where you want to reuse styling and field configurations
|
|
897
|
+
- **Bulk operations** where you process multiple items with the same form structure
|
|
898
|
+
|
|
870
899
|
|
|
871
900
|
|
|
872
901
|
### Schema Drift Resilience
|
|
@@ -996,6 +1025,7 @@ form_renderer = PydanticForm(
|
|
|
996
1025
|
| Method | Purpose |
|
|
997
1026
|
|--------|---------|
|
|
998
1027
|
| `render_inputs()` | Generate the HTML form inputs (without `<form>` wrapper) |
|
|
1028
|
+
| `with_initial_values(initial_values)` | Create a new form instance with same configuration but different initial values |
|
|
999
1029
|
| `refresh_button(text=None, **kwargs)` | Create a refresh button component |
|
|
1000
1030
|
| `reset_button(text=None, **kwargs)` | Create a reset button component |
|
|
1001
1031
|
| `register_routes(app)` | Register HTMX endpoints for list manipulation |
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
fh_pydantic_form/__init__.py,sha256=uNDN6UXIM25U7NazFi0Y9ivAeA8plERrRBk7TOd6P6M,4313
|
|
2
2
|
fh_pydantic_form/color_utils.py,sha256=M0HSXX0i-lSHkcsgesxw7d3PEAnLsZ46i_STymZAM_k,18271
|
|
3
|
-
fh_pydantic_form/comparison_form.py,sha256=
|
|
3
|
+
fh_pydantic_form/comparison_form.py,sha256=iljizwqia-9J4-2lVF4yDvoVkGE56FUfHDD3k2ejSnM,24270
|
|
4
4
|
fh_pydantic_form/constants.py,sha256=-N9wzkibFNn-V6cO8iWTQ7_xBvwSr2hBdq-m3apmW4M,169
|
|
5
5
|
fh_pydantic_form/defaults.py,sha256=Pwv46v7e43cykx4Pt01e4nw-6FBkHmPvTZK36ZTZqgA,6068
|
|
6
|
-
fh_pydantic_form/field_renderers.py,sha256=
|
|
7
|
-
fh_pydantic_form/form_parser.py,sha256=
|
|
8
|
-
fh_pydantic_form/form_renderer.py,sha256=
|
|
6
|
+
fh_pydantic_form/field_renderers.py,sha256=wX8XhesFH7Pt8l0stYR4FVQciVo2GBxADGnvwofu6YU,80944
|
|
7
|
+
fh_pydantic_form/form_parser.py,sha256=7GTOBNQSfJltDHZnM12FxTJj0X_IMWoDV3lJbDF3EpY,25879
|
|
8
|
+
fh_pydantic_form/form_renderer.py,sha256=3v4NPFQJ37m__kNo-sbNqaItR2AvcDzn5KRt44qCBTo,36198
|
|
9
9
|
fh_pydantic_form/list_path.py,sha256=AA8bmDmaYy4rlGIvQOOZ0fP2tgcimNUB2Re5aVGnYc8,5182
|
|
10
10
|
fh_pydantic_form/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
-
fh_pydantic_form/registry.py,sha256=
|
|
11
|
+
fh_pydantic_form/registry.py,sha256=b5zIjOpfmCUCs2njgp4PdDu70ioDIAfl49oU-Nf2pg4,4810
|
|
12
12
|
fh_pydantic_form/type_helpers.py,sha256=JUzHT8YrWj2_g7f_Wr2GL9i3BgP1zZftFrrO8xDPeis,7409
|
|
13
13
|
fh_pydantic_form/ui_style.py,sha256=UPK5OBwUVVTLnfvQ-yKukz2vbKZaT_GauaNB7OGc-Uw,3848
|
|
14
|
-
fh_pydantic_form-0.3.
|
|
15
|
-
fh_pydantic_form-0.3.
|
|
16
|
-
fh_pydantic_form-0.3.
|
|
17
|
-
fh_pydantic_form-0.3.
|
|
14
|
+
fh_pydantic_form-0.3.2.dist-info/METADATA,sha256=Yk4mV476Uy8JjfHjcUurMazvQbYALsSd1Lk5TnwuJJ4,38420
|
|
15
|
+
fh_pydantic_form-0.3.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
16
|
+
fh_pydantic_form-0.3.2.dist-info/licenses/LICENSE,sha256=AOi2eNK3D2aDycRHfPRiuACZ7WPBsKHTV2tTYNl7cls,577
|
|
17
|
+
fh_pydantic_form-0.3.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|