fh-pydantic-form 0.2.5__py3-none-any.whl → 0.3.0__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 +16 -4
- fh_pydantic_form/color_utils.py +598 -0
- fh_pydantic_form/comparison_form.py +599 -0
- fh_pydantic_form/field_renderers.py +570 -17
- fh_pydantic_form/form_renderer.py +111 -46
- fh_pydantic_form/type_helpers.py +37 -6
- fh_pydantic_form/ui_style.py +2 -0
- {fh_pydantic_form-0.2.5.dist-info → fh_pydantic_form-0.3.0.dist-info}/METADATA +359 -6
- fh_pydantic_form-0.3.0.dist-info/RECORD +17 -0
- fh_pydantic_form-0.2.5.dist-info/RECORD +0 -15
- {fh_pydantic_form-0.2.5.dist-info → fh_pydantic_form-0.3.0.dist-info}/WHEEL +0 -0
- {fh_pydantic_form-0.2.5.dist-info → fh_pydantic_form-0.3.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ComparisonForm - Side-by-side form comparison with metrics visualization
|
|
3
|
+
|
|
4
|
+
This module provides a meta-renderer that displays two PydanticForm instances
|
|
5
|
+
side-by-side with visual comparison feedback and synchronized accordion states.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from typing import (
|
|
10
|
+
Any,
|
|
11
|
+
Dict,
|
|
12
|
+
Generic,
|
|
13
|
+
List,
|
|
14
|
+
Optional,
|
|
15
|
+
Type,
|
|
16
|
+
TypeVar,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
import fasthtml.common as fh
|
|
20
|
+
import monsterui.all as mui
|
|
21
|
+
from fastcore.xml import FT
|
|
22
|
+
from pydantic import BaseModel
|
|
23
|
+
|
|
24
|
+
from fh_pydantic_form.form_renderer import PydanticForm
|
|
25
|
+
from fh_pydantic_form.registry import FieldRendererRegistry
|
|
26
|
+
from fh_pydantic_form.type_helpers import MetricEntry, MetricsDict
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
# TypeVar for generic model typing
|
|
31
|
+
ModelType = TypeVar("ModelType", bound=BaseModel)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def comparison_form_js():
|
|
35
|
+
"""JavaScript for comparison: sync top-level and list accordions."""
|
|
36
|
+
return fh.Script("""
|
|
37
|
+
window.fhpfInitComparisonSync = function initComparisonSync(){
|
|
38
|
+
// 1) Wait until UIkit and its util are available
|
|
39
|
+
if (!window.UIkit || !UIkit.util) {
|
|
40
|
+
return setTimeout(initComparisonSync, 50);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 2) Sync top-level accordions (BaseModelFieldRenderer)
|
|
44
|
+
UIkit.util.on(
|
|
45
|
+
document,
|
|
46
|
+
'show hide', // UIkit fires plain 'show'/'hide'
|
|
47
|
+
'ul[uk-accordion] > li', // only the top-level items
|
|
48
|
+
mirrorTopLevel
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
function mirrorTopLevel(ev) {
|
|
52
|
+
const sourceLi = ev.target.closest('li');
|
|
53
|
+
if (!sourceLi) return;
|
|
54
|
+
|
|
55
|
+
// Find our grid-cell wrapper (both left & right share the same data-path)
|
|
56
|
+
const cell = sourceLi.closest('[data-path]');
|
|
57
|
+
if (!cell) return;
|
|
58
|
+
const path = cell.dataset.path;
|
|
59
|
+
|
|
60
|
+
// Determine index of this <li> inside its <ul>
|
|
61
|
+
const idx = Array.prototype.indexOf.call(
|
|
62
|
+
sourceLi.parentElement.children,
|
|
63
|
+
sourceLi
|
|
64
|
+
);
|
|
65
|
+
const opening = ev.type === 'show';
|
|
66
|
+
|
|
67
|
+
// Mirror on the other side
|
|
68
|
+
document
|
|
69
|
+
.querySelectorAll(`[data-path="${path}"]`)
|
|
70
|
+
.forEach(peerCell => {
|
|
71
|
+
if (peerCell === cell) return;
|
|
72
|
+
|
|
73
|
+
const peerAcc = peerCell.querySelector('ul[uk-accordion]');
|
|
74
|
+
if (!peerAcc || idx >= peerAcc.children.length) return;
|
|
75
|
+
|
|
76
|
+
const peerLi = peerAcc.children[idx];
|
|
77
|
+
const peerContent = peerLi.querySelector('.uk-accordion-content');
|
|
78
|
+
|
|
79
|
+
if (opening) {
|
|
80
|
+
peerLi.classList.add('uk-open');
|
|
81
|
+
if (peerContent) {
|
|
82
|
+
peerContent.hidden = false;
|
|
83
|
+
peerContent.style.height = 'auto';
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
peerLi.classList.remove('uk-open');
|
|
87
|
+
if (peerContent) {
|
|
88
|
+
peerContent.hidden = true;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 3) Wrap the list-toggle so ListFieldRenderer accordions sync too
|
|
95
|
+
if (typeof window.toggleListItems === 'function' && !window.__listSyncWrapped) {
|
|
96
|
+
// guard to only wrap once
|
|
97
|
+
window.__listSyncWrapped = true;
|
|
98
|
+
const originalToggle = window.toggleListItems;
|
|
99
|
+
|
|
100
|
+
window.toggleListItems = function(containerId) {
|
|
101
|
+
// a) Toggle this column first
|
|
102
|
+
originalToggle(containerId);
|
|
103
|
+
|
|
104
|
+
// b) Find the enclosing data-path
|
|
105
|
+
const container = document.getElementById(containerId);
|
|
106
|
+
if (!container) return;
|
|
107
|
+
const cell = container.closest('[data-path]');
|
|
108
|
+
if (!cell) return;
|
|
109
|
+
const path = cell.dataset.path;
|
|
110
|
+
|
|
111
|
+
// c) Find the peer's list-container by suffix match
|
|
112
|
+
document
|
|
113
|
+
.querySelectorAll(`[data-path="${path}"]`)
|
|
114
|
+
.forEach(peerCell => {
|
|
115
|
+
if (peerCell === cell) return;
|
|
116
|
+
|
|
117
|
+
// look up any [id$="_items_container"]
|
|
118
|
+
const peerContainer = peerCell.querySelector('[id$="_items_container"]');
|
|
119
|
+
if (peerContainer) {
|
|
120
|
+
originalToggle(peerContainer.id);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// Initial run
|
|
128
|
+
window.fhpfInitComparisonSync();
|
|
129
|
+
|
|
130
|
+
// Re-run after HTMX swaps to maintain sync
|
|
131
|
+
document.addEventListener('htmx:afterSwap', function(event) {
|
|
132
|
+
// Re-initialize the comparison sync
|
|
133
|
+
window.fhpfInitComparisonSync();
|
|
134
|
+
});
|
|
135
|
+
""")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class ComparisonForm(Generic[ModelType]):
|
|
139
|
+
"""
|
|
140
|
+
Meta-renderer for side-by-side form comparison with metrics visualization
|
|
141
|
+
|
|
142
|
+
This class creates a two-column layout with synchronized accordions and
|
|
143
|
+
visual comparison feedback (colors, tooltips, metric badges).
|
|
144
|
+
|
|
145
|
+
The ComparisonForm is a view-only composition helper; state management
|
|
146
|
+
lives in the underlying PydanticForm instances.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
def __init__(
|
|
150
|
+
self,
|
|
151
|
+
name: str,
|
|
152
|
+
left_form: PydanticForm[ModelType],
|
|
153
|
+
right_form: PydanticForm[ModelType],
|
|
154
|
+
*,
|
|
155
|
+
left_label: str = "Reference",
|
|
156
|
+
right_label: str = "Generated",
|
|
157
|
+
):
|
|
158
|
+
"""
|
|
159
|
+
Initialize the comparison form
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
name: Unique name for this comparison form
|
|
163
|
+
left_form: Pre-constructed PydanticForm for left column
|
|
164
|
+
right_form: Pre-constructed PydanticForm for right column
|
|
165
|
+
left_label: Label for left column
|
|
166
|
+
right_label: Label for right column
|
|
167
|
+
|
|
168
|
+
Raises:
|
|
169
|
+
ValueError: If the two forms are not based on the same model class
|
|
170
|
+
"""
|
|
171
|
+
# Validate that both forms use the same model
|
|
172
|
+
if left_form.model_class is not right_form.model_class:
|
|
173
|
+
raise ValueError(
|
|
174
|
+
f"Both forms must be based on the same model class. "
|
|
175
|
+
f"Got {left_form.model_class.__name__} and {right_form.model_class.__name__}"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
self.name = name
|
|
179
|
+
self.left_form = left_form
|
|
180
|
+
self.right_form = right_form
|
|
181
|
+
self.model_class = left_form.model_class # Convenience reference
|
|
182
|
+
self.left_label = left_label
|
|
183
|
+
self.right_label = right_label
|
|
184
|
+
|
|
185
|
+
# Use spacing from left form (or could add override parameter if needed)
|
|
186
|
+
self.spacing = left_form.spacing
|
|
187
|
+
|
|
188
|
+
def _get_field_path_string(self, field_path: List[str]) -> str:
|
|
189
|
+
"""Convert field path list to dot-notation string for comparison lookup"""
|
|
190
|
+
return ".".join(field_path)
|
|
191
|
+
|
|
192
|
+
def _render_column(
|
|
193
|
+
self,
|
|
194
|
+
*,
|
|
195
|
+
form: PydanticForm[ModelType],
|
|
196
|
+
header_label: str,
|
|
197
|
+
start_order: int,
|
|
198
|
+
wrapper_id: str,
|
|
199
|
+
) -> FT:
|
|
200
|
+
"""
|
|
201
|
+
Render a single column with CSS order values for grid alignment
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
form: The PydanticForm instance for this column
|
|
205
|
+
header_label: Label for the column header
|
|
206
|
+
start_order: Starting order value (0 for left, 1 for right)
|
|
207
|
+
wrapper_id: ID for the wrapper div
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
A div with class="contents" containing ordered grid items
|
|
211
|
+
"""
|
|
212
|
+
# Header with order
|
|
213
|
+
cells = [
|
|
214
|
+
fh.Div(
|
|
215
|
+
fh.H3(header_label, cls="text-lg font-semibold text-gray-700"),
|
|
216
|
+
cls="pb-2 border-b",
|
|
217
|
+
style=f"order:{start_order}",
|
|
218
|
+
)
|
|
219
|
+
]
|
|
220
|
+
|
|
221
|
+
# Start at order + 2, increment by 2 for each field
|
|
222
|
+
order_idx = start_order + 2
|
|
223
|
+
|
|
224
|
+
# Create renderers for each field
|
|
225
|
+
registry = FieldRendererRegistry()
|
|
226
|
+
|
|
227
|
+
for field_name, field_info in self.model_class.model_fields.items():
|
|
228
|
+
# Skip excluded fields
|
|
229
|
+
if field_name in (form.exclude_fields or []):
|
|
230
|
+
continue
|
|
231
|
+
|
|
232
|
+
# Get value from form
|
|
233
|
+
value = form.values_dict.get(field_name)
|
|
234
|
+
|
|
235
|
+
# Get path string for data-path attribute
|
|
236
|
+
path_str = field_name
|
|
237
|
+
|
|
238
|
+
# Get renderer class
|
|
239
|
+
renderer_cls = registry.get_renderer(field_name, field_info)
|
|
240
|
+
if not renderer_cls:
|
|
241
|
+
from fh_pydantic_form.field_renderers import StringFieldRenderer
|
|
242
|
+
|
|
243
|
+
renderer_cls = StringFieldRenderer
|
|
244
|
+
|
|
245
|
+
# Determine comparison-specific refresh endpoint
|
|
246
|
+
comparison_refresh = f"/compare/{self.name}/{'left' if form is self.left_form else 'right'}/refresh"
|
|
247
|
+
|
|
248
|
+
# Create renderer
|
|
249
|
+
renderer = renderer_cls(
|
|
250
|
+
field_name=field_name,
|
|
251
|
+
field_info=field_info,
|
|
252
|
+
value=value,
|
|
253
|
+
prefix=form.base_prefix,
|
|
254
|
+
disabled=form.disabled,
|
|
255
|
+
spacing=form.spacing,
|
|
256
|
+
field_path=[field_name],
|
|
257
|
+
form_name=form.name,
|
|
258
|
+
metrics_dict=form.metrics_dict, # Use form's own metrics
|
|
259
|
+
refresh_endpoint_override=comparison_refresh, # Pass comparison-specific refresh endpoint
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
# Render with data-path and order
|
|
263
|
+
cells.append(
|
|
264
|
+
fh.Div(
|
|
265
|
+
renderer.render(),
|
|
266
|
+
cls="",
|
|
267
|
+
**{"data-path": path_str, "style": f"order:{order_idx}"},
|
|
268
|
+
)
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
order_idx += 2
|
|
272
|
+
|
|
273
|
+
# Return wrapper with display: contents
|
|
274
|
+
return fh.Div(*cells, id=wrapper_id, cls="contents")
|
|
275
|
+
|
|
276
|
+
# def _create_field_pairs(
|
|
277
|
+
# self,
|
|
278
|
+
# ) -> List[Tuple[str, BaseFieldRenderer, BaseFieldRenderer]]:
|
|
279
|
+
# """
|
|
280
|
+
# Create pairs of renderers (left, right) for each field path
|
|
281
|
+
|
|
282
|
+
# Returns:
|
|
283
|
+
# List of (path_string, left_renderer, right_renderer) tuples
|
|
284
|
+
# """
|
|
285
|
+
# pairs = []
|
|
286
|
+
# registry = FieldRendererRegistry()
|
|
287
|
+
|
|
288
|
+
# # Walk through model fields to create renderer pairs
|
|
289
|
+
# for field_name, field_info in self.model_class.model_fields.items():
|
|
290
|
+
# # Skip fields that are excluded in either form
|
|
291
|
+
# if field_name in (self.left_form.exclude_fields or []) or field_name in (
|
|
292
|
+
# self.right_form.exclude_fields or []
|
|
293
|
+
# ):
|
|
294
|
+
# logger.debug(
|
|
295
|
+
# f"Skipping field '{field_name}' - excluded in one or both forms"
|
|
296
|
+
# )
|
|
297
|
+
# continue
|
|
298
|
+
|
|
299
|
+
# # Get values from each form
|
|
300
|
+
# left_value = self.left_form.values_dict.get(field_name)
|
|
301
|
+
# right_value = self.right_form.values_dict.get(field_name)
|
|
302
|
+
|
|
303
|
+
# # Get the path string for comparison lookup
|
|
304
|
+
# path_str = field_name
|
|
305
|
+
# left_comparison_metric = self.left_metrics.get(path_str)
|
|
306
|
+
# right_comparison_metric = self.right_metrics.get(path_str)
|
|
307
|
+
|
|
308
|
+
# # Get renderer class
|
|
309
|
+
# renderer_cls = registry.get_renderer(field_name, field_info)
|
|
310
|
+
# if not renderer_cls:
|
|
311
|
+
# from fh_pydantic_form.field_renderers import StringFieldRenderer
|
|
312
|
+
|
|
313
|
+
# renderer_cls = StringFieldRenderer
|
|
314
|
+
|
|
315
|
+
# # Create left renderer
|
|
316
|
+
# left_renderer = renderer_cls(
|
|
317
|
+
# field_name=field_name,
|
|
318
|
+
# field_info=field_info,
|
|
319
|
+
# value=left_value,
|
|
320
|
+
# prefix=self.left_form.base_prefix,
|
|
321
|
+
# disabled=self.left_form.disabled,
|
|
322
|
+
# spacing=self.left_form.spacing,
|
|
323
|
+
# field_path=[field_name],
|
|
324
|
+
# form_name=self.left_form.name,
|
|
325
|
+
# comparison=left_comparison_metric,
|
|
326
|
+
# comparison_map=self.left_metrics, # Pass the full comparison map
|
|
327
|
+
# )
|
|
328
|
+
|
|
329
|
+
# # Create right renderer
|
|
330
|
+
# right_renderer = renderer_cls(
|
|
331
|
+
# field_name=field_name,
|
|
332
|
+
# field_info=field_info,
|
|
333
|
+
# value=right_value,
|
|
334
|
+
# prefix=self.right_form.base_prefix,
|
|
335
|
+
# disabled=self.right_form.disabled,
|
|
336
|
+
# spacing=self.right_form.spacing,
|
|
337
|
+
# field_path=[field_name],
|
|
338
|
+
# form_name=self.right_form.name,
|
|
339
|
+
# comparison=right_comparison_metric,
|
|
340
|
+
# comparison_map=self.right_metrics, # Pass the full comparison map
|
|
341
|
+
# )
|
|
342
|
+
|
|
343
|
+
# pairs.append((path_str, left_renderer, right_renderer))
|
|
344
|
+
|
|
345
|
+
# return pairs
|
|
346
|
+
|
|
347
|
+
def render_inputs(self) -> FT:
|
|
348
|
+
"""
|
|
349
|
+
Render the comparison form with side-by-side layout
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
A FastHTML component with CSS Grid layout
|
|
353
|
+
"""
|
|
354
|
+
# Render left column with wrapper
|
|
355
|
+
left_wrapper = self._render_column(
|
|
356
|
+
form=self.left_form,
|
|
357
|
+
header_label=self.left_label,
|
|
358
|
+
start_order=0,
|
|
359
|
+
wrapper_id=f"{self.left_form.name}-inputs-wrapper",
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
# Render right column with wrapper
|
|
363
|
+
right_wrapper = self._render_column(
|
|
364
|
+
form=self.right_form,
|
|
365
|
+
header_label=self.right_label,
|
|
366
|
+
start_order=1,
|
|
367
|
+
wrapper_id=f"{self.right_form.name}-inputs-wrapper",
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
# Create the grid container with both wrappers
|
|
371
|
+
grid_container = fh.Div(
|
|
372
|
+
left_wrapper,
|
|
373
|
+
right_wrapper,
|
|
374
|
+
cls="fhpf-compare grid grid-cols-2 gap-x-6 gap-y-2 items-start",
|
|
375
|
+
id=f"{self.name}-comparison-grid",
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
return fh.Div(grid_container, cls="w-full")
|
|
379
|
+
|
|
380
|
+
def register_routes(self, app):
|
|
381
|
+
"""
|
|
382
|
+
Register HTMX routes for the comparison form
|
|
383
|
+
|
|
384
|
+
Args:
|
|
385
|
+
app: FastHTML app instance
|
|
386
|
+
"""
|
|
387
|
+
# Register individual form routes (for list manipulation)
|
|
388
|
+
self.left_form.register_routes(app)
|
|
389
|
+
self.right_form.register_routes(app)
|
|
390
|
+
|
|
391
|
+
# Register comparison-specific reset/refresh routes
|
|
392
|
+
def create_reset_handler(
|
|
393
|
+
form: PydanticForm[ModelType],
|
|
394
|
+
side: str,
|
|
395
|
+
label: str,
|
|
396
|
+
):
|
|
397
|
+
"""Factory function to create reset handler with proper closure"""
|
|
398
|
+
|
|
399
|
+
async def handler(req):
|
|
400
|
+
"""Reset one side of the comparison form"""
|
|
401
|
+
# Reset the form state
|
|
402
|
+
await form.handle_reset_request()
|
|
403
|
+
|
|
404
|
+
# Render the entire column with proper ordering
|
|
405
|
+
start_order = 0 if side == "left" else 1
|
|
406
|
+
wrapper = self._render_column(
|
|
407
|
+
form=form,
|
|
408
|
+
header_label=label,
|
|
409
|
+
start_order=start_order,
|
|
410
|
+
wrapper_id=f"{form.name}-inputs-wrapper",
|
|
411
|
+
)
|
|
412
|
+
return wrapper
|
|
413
|
+
|
|
414
|
+
return handler
|
|
415
|
+
|
|
416
|
+
def create_refresh_handler(
|
|
417
|
+
form: PydanticForm[ModelType],
|
|
418
|
+
side: str,
|
|
419
|
+
label: str,
|
|
420
|
+
):
|
|
421
|
+
"""Factory function to create refresh handler with proper closure"""
|
|
422
|
+
|
|
423
|
+
async def handler(req):
|
|
424
|
+
"""Refresh one side of the comparison form"""
|
|
425
|
+
# Refresh the form state and capture any warnings
|
|
426
|
+
refresh_result = await form.handle_refresh_request(req)
|
|
427
|
+
|
|
428
|
+
# Render the entire column with proper ordering
|
|
429
|
+
start_order = 0 if side == "left" else 1
|
|
430
|
+
wrapper = self._render_column(
|
|
431
|
+
form=form,
|
|
432
|
+
header_label=label,
|
|
433
|
+
start_order=start_order,
|
|
434
|
+
wrapper_id=f"{form.name}-inputs-wrapper",
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
# If refresh returned a warning, include it in the response
|
|
438
|
+
if isinstance(refresh_result, tuple) and len(refresh_result) == 2:
|
|
439
|
+
alert, _ = refresh_result
|
|
440
|
+
# Return both the alert and the wrapper
|
|
441
|
+
return fh.Div(alert, wrapper)
|
|
442
|
+
else:
|
|
443
|
+
# No warning, just return the wrapper
|
|
444
|
+
return wrapper
|
|
445
|
+
|
|
446
|
+
return handler
|
|
447
|
+
|
|
448
|
+
for side, form, label in [
|
|
449
|
+
("left", self.left_form, self.left_label),
|
|
450
|
+
("right", self.right_form, self.right_label),
|
|
451
|
+
]:
|
|
452
|
+
assert form is not None
|
|
453
|
+
|
|
454
|
+
# Reset route
|
|
455
|
+
reset_path = f"/compare/{self.name}/{side}/reset"
|
|
456
|
+
reset_handler = create_reset_handler(form, side, label)
|
|
457
|
+
app.route(reset_path, methods=["POST"])(reset_handler)
|
|
458
|
+
logger.debug(f"Registered comparison reset route: {reset_path}")
|
|
459
|
+
|
|
460
|
+
# Refresh route
|
|
461
|
+
refresh_path = f"/compare/{self.name}/{side}/refresh"
|
|
462
|
+
refresh_handler = create_refresh_handler(form, side, label)
|
|
463
|
+
app.route(refresh_path, methods=["POST"])(refresh_handler)
|
|
464
|
+
logger.debug(f"Registered comparison refresh route: {refresh_path}")
|
|
465
|
+
|
|
466
|
+
def form_wrapper(self, content: FT, form_id: Optional[str] = None) -> FT:
|
|
467
|
+
"""
|
|
468
|
+
Wrap the comparison content in a form element with proper ID
|
|
469
|
+
|
|
470
|
+
Args:
|
|
471
|
+
content: The form content to wrap
|
|
472
|
+
form_id: Optional form ID (defaults to {name}-comparison-form)
|
|
473
|
+
|
|
474
|
+
Returns:
|
|
475
|
+
A form element containing the content
|
|
476
|
+
"""
|
|
477
|
+
form_id = form_id or f"{self.name}-comparison-form"
|
|
478
|
+
wrapper_id = f"{self.name}-comparison-wrapper"
|
|
479
|
+
|
|
480
|
+
# Note: Removed hx_include="closest form" since the wrapper only contains foreign forms
|
|
481
|
+
return mui.Form(
|
|
482
|
+
fh.Div(content, id=wrapper_id),
|
|
483
|
+
id=form_id,
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
def _button_helper(self, *, side: str, action: str, text: str, **kwargs) -> FT:
|
|
487
|
+
"""
|
|
488
|
+
Helper method to create buttons that target comparison-specific routes
|
|
489
|
+
|
|
490
|
+
Args:
|
|
491
|
+
side: "left" or "right"
|
|
492
|
+
action: "reset" or "refresh"
|
|
493
|
+
text: Button text
|
|
494
|
+
**kwargs: Additional button attributes
|
|
495
|
+
|
|
496
|
+
Returns:
|
|
497
|
+
A button component
|
|
498
|
+
"""
|
|
499
|
+
form = self.left_form if side == "left" else self.right_form
|
|
500
|
+
|
|
501
|
+
# Create prefix-based selector
|
|
502
|
+
prefix_selector = f"form [name^='{form.base_prefix}']"
|
|
503
|
+
|
|
504
|
+
# Set default attributes
|
|
505
|
+
kwargs.setdefault("hx_post", f"/compare/{self.name}/{side}/{action}")
|
|
506
|
+
kwargs.setdefault("hx_target", f"#{form.name}-inputs-wrapper")
|
|
507
|
+
kwargs.setdefault("hx_swap", "innerHTML")
|
|
508
|
+
kwargs.setdefault("hx_include", prefix_selector)
|
|
509
|
+
|
|
510
|
+
# Delegate to the underlying form's button method
|
|
511
|
+
button_method = getattr(form, f"{action}_button")
|
|
512
|
+
return button_method(text, **kwargs)
|
|
513
|
+
|
|
514
|
+
def left_reset_button(self, text: Optional[str] = None, **kwargs) -> FT:
|
|
515
|
+
"""Create a reset button for the left form"""
|
|
516
|
+
return self._button_helper(
|
|
517
|
+
side="left", action="reset", text=text or "↩️ Reset Left", **kwargs
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
def left_refresh_button(self, text: Optional[str] = None, **kwargs) -> FT:
|
|
521
|
+
"""Create a refresh button for the left form"""
|
|
522
|
+
return self._button_helper(
|
|
523
|
+
side="left", action="refresh", text=text or "🔄 Refresh Left", **kwargs
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
def right_reset_button(self, text: Optional[str] = None, **kwargs) -> FT:
|
|
527
|
+
"""Create a reset button for the right form"""
|
|
528
|
+
return self._button_helper(
|
|
529
|
+
side="right", action="reset", text=text or "↩️ Reset Right", **kwargs
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
def right_refresh_button(self, text: Optional[str] = None, **kwargs) -> FT:
|
|
533
|
+
"""Create a refresh button for the right form"""
|
|
534
|
+
return self._button_helper(
|
|
535
|
+
side="right", action="refresh", text=text or "🔄 Refresh Right", **kwargs
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def simple_diff_metrics(
|
|
540
|
+
left_data: BaseModel | Dict[str, Any],
|
|
541
|
+
right_data: BaseModel | Dict[str, Any],
|
|
542
|
+
model_class: Type[BaseModel],
|
|
543
|
+
) -> MetricsDict:
|
|
544
|
+
"""
|
|
545
|
+
Simple helper to generate metrics based on equality
|
|
546
|
+
|
|
547
|
+
Args:
|
|
548
|
+
left_data: Reference data
|
|
549
|
+
right_data: Data to compare
|
|
550
|
+
model_class: Model class for structure
|
|
551
|
+
|
|
552
|
+
Returns:
|
|
553
|
+
MetricsDict with simple equality-based metrics
|
|
554
|
+
"""
|
|
555
|
+
metrics_dict = {}
|
|
556
|
+
|
|
557
|
+
# Convert to dicts if needed
|
|
558
|
+
if hasattr(left_data, "model_dump"):
|
|
559
|
+
left_dict = left_data.model_dump()
|
|
560
|
+
else:
|
|
561
|
+
left_dict = left_data or {}
|
|
562
|
+
|
|
563
|
+
if hasattr(right_data, "model_dump"):
|
|
564
|
+
right_dict = right_data.model_dump()
|
|
565
|
+
else:
|
|
566
|
+
right_dict = right_data or {}
|
|
567
|
+
|
|
568
|
+
# Compare each field
|
|
569
|
+
for field_name in model_class.model_fields:
|
|
570
|
+
left_val = left_dict.get(field_name)
|
|
571
|
+
right_val = right_dict.get(field_name)
|
|
572
|
+
|
|
573
|
+
if left_val == right_val:
|
|
574
|
+
metrics_dict[field_name] = MetricEntry(
|
|
575
|
+
metric=1.0, color="green", comment="Values match exactly"
|
|
576
|
+
)
|
|
577
|
+
elif left_val is None or right_val is None:
|
|
578
|
+
metrics_dict[field_name] = MetricEntry(
|
|
579
|
+
metric=0.0, color="orange", comment="One value is missing"
|
|
580
|
+
)
|
|
581
|
+
else:
|
|
582
|
+
# Try to compute similarity for strings
|
|
583
|
+
if isinstance(left_val, str) and isinstance(right_val, str):
|
|
584
|
+
# Simple character overlap ratio
|
|
585
|
+
common = sum(1 for a, b in zip(left_val, right_val) if a == b)
|
|
586
|
+
max_len = max(len(left_val), len(right_val))
|
|
587
|
+
similarity = common / max_len if max_len > 0 else 0
|
|
588
|
+
|
|
589
|
+
metrics_dict[field_name] = MetricEntry(
|
|
590
|
+
metric=round(similarity, 2),
|
|
591
|
+
comment=f"String similarity: {similarity:.0%}",
|
|
592
|
+
)
|
|
593
|
+
else:
|
|
594
|
+
metrics_dict[field_name] = MetricEntry(
|
|
595
|
+
metric=0.0,
|
|
596
|
+
comment=f"Different values: {left_val} vs {right_val}",
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
return metrics_dict
|