fh-pydantic-form 0.3.9__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.
@@ -0,0 +1,1637 @@
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 json
9
+ import logging
10
+ import re
11
+ from copy import deepcopy
12
+ from typing import (
13
+ Any,
14
+ Dict,
15
+ Generic,
16
+ List,
17
+ Optional,
18
+ Type,
19
+ TypeVar,
20
+ Union,
21
+ )
22
+
23
+ import fasthtml.common as fh
24
+ import monsterui.all as mui
25
+ from fastcore.xml import FT
26
+ from pydantic import BaseModel
27
+
28
+ from fh_pydantic_form.form_renderer import PydanticForm
29
+ from fh_pydantic_form.registry import FieldRendererRegistry
30
+ from fh_pydantic_form.type_helpers import (
31
+ MetricEntry,
32
+ MetricsDict,
33
+ _is_skip_json_schema_field,
34
+ )
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ # TypeVar for generic model typing
39
+ ModelType = TypeVar("ModelType", bound=BaseModel)
40
+
41
+
42
+ def comparison_form_js():
43
+ """JavaScript for comparison: sync accordions and handle JS-only copy operations."""
44
+ return fh.Script("""
45
+ // Helper functions for list item path detection
46
+ function isListItemPath(pathPrefix) {
47
+ // Check if path contains array index pattern like [0], [1], etc.
48
+ return /\[\d+\]/.test(pathPrefix);
49
+ }
50
+
51
+ function extractListFieldPath(pathPrefix) {
52
+ // Extract the list field path without the index
53
+ // e.g., "addresses[0]" -> "addresses"
54
+ return pathPrefix.replace(/\[\d+\].*$/, '');
55
+ }
56
+
57
+ function extractListIndex(pathPrefix) {
58
+ // Extract the index from path
59
+ // e.g., "addresses[0].street" -> 0
60
+ var match = pathPrefix.match(/\[(\d+)\]/);
61
+ return match ? parseInt(match[1]) : null;
62
+ }
63
+
64
+ // Copy function - pure JS implementation
65
+ window.fhpfPerformCopy = function(pathPrefix, currentPrefix, copyTarget) {
66
+ try {
67
+ // Set flag to prevent accordion sync
68
+ window.__fhpfCopyInProgress = true;
69
+
70
+ // Save all accordion states before copy
71
+ var accordionStates = [];
72
+ document.querySelectorAll('ul[uk-accordion] > li').forEach(function(li) {
73
+ accordionStates.push({
74
+ element: li,
75
+ isOpen: li.classList.contains('uk-open')
76
+ });
77
+ });
78
+
79
+ // Determine source prefix based on copy target
80
+ var sourcePrefix = (copyTarget === 'left') ? window.__fhpfRightPrefix : window.__fhpfLeftPrefix;
81
+
82
+ // Check if this is a list item copy operation
83
+ var isListItem = isListItemPath(pathPrefix);
84
+ var listFieldPath = null;
85
+ var listIndex = null;
86
+
87
+ if (isListItem) {
88
+ listFieldPath = extractListFieldPath(pathPrefix);
89
+ listIndex = extractListIndex(pathPrefix);
90
+ }
91
+
92
+ // Special handling for list item copies: add new item instead of overwriting
93
+ if (isListItem) {
94
+ // Find target list container
95
+ var targetPrefix = (copyTarget === 'left') ? window.__fhpfLeftPrefix : window.__fhpfRightPrefix;
96
+ var targetContainerId = targetPrefix.replace(/_$/, '') + '_' + listFieldPath + '_items_container';
97
+ var targetContainer = document.getElementById(targetContainerId);
98
+
99
+ if (targetContainer) {
100
+ // Find the "Add Item" button for the target list
101
+ var targetAddButton = targetContainer.parentElement.querySelector('button[hx-post*="/list/add/"]');
102
+
103
+ if (targetAddButton) {
104
+ // Capture the target list items BEFORE adding the new one
105
+ var targetListItemsBeforeAdd = Array.from(targetContainer.querySelectorAll(':scope > li'));
106
+ var targetLengthBefore = targetListItemsBeforeAdd.length;
107
+
108
+ // Determine the target position: insert after the source item's index, or at end if target is shorter
109
+ var sourceIndex = listIndex; // The index from the source path (e.g., reviews[2] -> 2)
110
+ var insertAfterIndex = Math.min(sourceIndex, targetLengthBefore - 1);
111
+
112
+ // Get the URL from the add button
113
+ var addUrl = targetAddButton.getAttribute('hx-post');
114
+
115
+ // Determine the insertion point
116
+ var insertBeforeElement = null;
117
+ if (insertAfterIndex >= 0 && insertAfterIndex < targetLengthBefore - 1) {
118
+ // Insert after insertAfterIndex, which means before insertAfterIndex+1
119
+ insertBeforeElement = targetListItemsBeforeAdd[insertAfterIndex + 1];
120
+ } else if (targetLengthBefore > 0) {
121
+ // Insert at the end: use afterend on the last item
122
+ insertBeforeElement = targetListItemsBeforeAdd[targetLengthBefore - 1];
123
+ }
124
+
125
+ // Make the HTMX request with custom swap target
126
+ if (insertBeforeElement) {
127
+ var swapStrategy = (insertAfterIndex >= targetLengthBefore - 1) ? 'afterend' : 'beforebegin';
128
+ // Use htmx.ajax to insert at specific position
129
+ htmx.ajax('POST', addUrl, {
130
+ target: '#' + insertBeforeElement.id,
131
+ swap: swapStrategy
132
+ });
133
+ } else {
134
+ // List is empty, insert into container
135
+ htmx.ajax('POST', addUrl, {
136
+ target: '#' + targetContainerId,
137
+ swap: 'beforeend'
138
+ });
139
+ }
140
+
141
+ // Wait for HTMX to complete the swap AND settle, then copy values
142
+ var copyCompleted = false;
143
+ var htmxSettled = false;
144
+ var newlyAddedElement = null;
145
+
146
+ // Listen for HTMX afterSwap event on the container to capture the newly added element
147
+ targetContainer.addEventListener('htmx:afterSwap', function onSwap(evt) {
148
+ // Parse the response to get the new element's ID
149
+ var tempDiv = document.createElement('div');
150
+ tempDiv.innerHTML = evt.detail.xhr.response;
151
+ var newElement = tempDiv.firstElementChild;
152
+ if (newElement && newElement.id) {
153
+ newlyAddedElement = newElement;
154
+ }
155
+ }, { once: true });
156
+
157
+ // Listen for HTMX afterSettle event
158
+ document.body.addEventListener('htmx:afterSettle', function onSettle(evt) {
159
+ htmxSettled = true;
160
+ document.body.removeEventListener('htmx:afterSettle', onSettle);
161
+ }, { once: true });
162
+
163
+ var maxAttempts = 100; // 100 attempts with exponential backoff = ~10 seconds total
164
+ var attempts = 0;
165
+
166
+ var checkAndCopy = function() {
167
+ attempts++;
168
+
169
+ // Calculate delay with exponential backoff: 50ms, 50ms, 100ms, 100ms, 200ms, ...
170
+ var delay = Math.min(50 * Math.pow(2, Math.floor(attempts / 2)), 500);
171
+
172
+ // Wait for HTMX to settle before proceeding
173
+ if (!htmxSettled && attempts < maxAttempts) {
174
+ setTimeout(checkAndCopy, delay);
175
+ return;
176
+ }
177
+
178
+ // If we timed out waiting for HTMX, give up
179
+ if (!htmxSettled) {
180
+ console.error('Timeout: HTMX did not settle after ' + attempts + ' attempts');
181
+ window.__fhpfCopyInProgress = false;
182
+ return;
183
+ }
184
+
185
+ // Find the newly added item using the ID we captured
186
+ var targetItems = targetContainer.querySelectorAll(':scope > li');
187
+ var newItem = null;
188
+ var newItemIndex = -1;
189
+
190
+ if (newlyAddedElement && newlyAddedElement.id) {
191
+ // Use the ID we captured from the HTMX response
192
+ newItem = document.getElementById(newlyAddedElement.id);
193
+
194
+ if (newItem) {
195
+ // Find its position in the list
196
+ for (var i = 0; i < targetItems.length; i++) {
197
+ if (targetItems[i] === newItem) {
198
+ newItemIndex = i;
199
+ break;
200
+ }
201
+ }
202
+ }
203
+ }
204
+
205
+ // Check if new item has been added
206
+ if (newItem) {
207
+
208
+ // Wait until the new item has input fields (indicating HTMX swap is complete)
209
+ var newItemInputs = newItem.querySelectorAll('[data-field-path]');
210
+
211
+ if (newItemInputs.length > 0) {
212
+ // New item is ready, now copy values from source item
213
+ copyCompleted = true;
214
+
215
+ // The new item might not contain the textarea with placeholder!
216
+ // Search the entire target container for the newest textarea with "new_" in the name
217
+ var targetPrefix = (copyTarget === 'left') ? window.__fhpfLeftPrefix : window.__fhpfRightPrefix;
218
+ var allInputsInContainer = targetContainer.querySelectorAll('[data-field-path^="' + listFieldPath + '["]');
219
+
220
+ var firstInput = null;
221
+ var newestTimestamp = 0;
222
+
223
+ for (var i = 0; i < allInputsInContainer.length; i++) {
224
+ var inputName = allInputsInContainer[i].name || allInputsInContainer[i].id;
225
+ if (inputName && inputName.startsWith(targetPrefix.replace(/_$/, '') + '_' + listFieldPath + '_new_')) {
226
+ // Extract timestamp from name
227
+ var match = inputName.match(/new_(\d+)/);
228
+ if (match) {
229
+ var timestamp = parseInt(match[1]);
230
+ if (timestamp > newestTimestamp) {
231
+ newestTimestamp = timestamp;
232
+ firstInput = allInputsInContainer[i];
233
+ }
234
+ }
235
+ }
236
+ }
237
+
238
+ if (!firstInput) {
239
+ firstInput = newItemInputs[0];
240
+ }
241
+
242
+ var firstInputPath = firstInput.getAttribute('data-field-path');
243
+ var firstInputName = firstInput.name || firstInput.id;
244
+
245
+ // Extract placeholder from name
246
+ // Pattern: "prefix_listfield_PLACEHOLDER" or "prefix_listfield_PLACEHOLDER_fieldname"
247
+ // For simple list items: "annotated_truth_key_features_new_123"
248
+ // For BaseModel list items: "annotated_truth_reviews_new_123_rating"
249
+ // We want just the placeholder part (new_123)
250
+ var searchStr = '_' + listFieldPath + '_';
251
+ var idx = firstInputName.indexOf(searchStr);
252
+ var actualPlaceholderIdx = null;
253
+
254
+ if (idx >= 0) {
255
+ var afterListField = firstInputName.substring(idx + searchStr.length);
256
+
257
+ // For BaseModel items with nested fields, the placeholder is between listfield and the next underscore
258
+ // Check if this looks like a nested field by checking if there's another underscore after "new_"
259
+ if (afterListField.startsWith('new_')) {
260
+ // Extract just "new_TIMESTAMP" part - stop at the next underscore after the timestamp
261
+ var parts = afterListField.split('_');
262
+ if (parts.length >= 2) {
263
+ // parts[0] = "new", parts[1] = timestamp, parts[2+] = field names
264
+ actualPlaceholderIdx = parts[0] + '_' + parts[1];
265
+ } else {
266
+ actualPlaceholderIdx = afterListField;
267
+ }
268
+ } else {
269
+ // Numeric index, just use it as-is
270
+ actualPlaceholderIdx = afterListField.split('_')[0];
271
+ }
272
+ } else {
273
+ console.error('Could not find "' + searchStr + '" in name: ' + firstInputName);
274
+ window.__fhpfCopyInProgress = false;
275
+ return;
276
+ }
277
+
278
+ // Use the actual placeholder index from the name attribute
279
+ var newPathPrefix = listFieldPath + '[' + actualPlaceholderIdx + ']';
280
+
281
+ // Now perform the standard copy operation with the new path
282
+ performStandardCopy(pathPrefix, newPathPrefix, sourcePrefix, copyTarget, accordionStates);
283
+
284
+ // Wait for copy to complete, then open accordion and highlight
285
+ // Note: We skip automatic refresh because the temporary item ID doesn't persist after refresh
286
+ // User can manually click the refresh button to update counts/summaries if needed
287
+ var waitForCopyComplete = function() {
288
+ if (!window.__fhpfCopyInProgress) {
289
+ // Copy operation is complete, now open and highlight the new item
290
+ setTimeout(function() {
291
+ // Re-find the item (it might have been affected by accordion restoration)
292
+ var copiedItem = document.getElementById(newItem.id);
293
+
294
+ if (copiedItem && window.UIkit) {
295
+ // Open the newly created accordion item
296
+ if (!copiedItem.classList.contains('uk-open')) {
297
+ var accordionParent = copiedItem.parentElement;
298
+ if (accordionParent && accordionParent.hasAttribute('uk-accordion')) {
299
+ var accordionComponent = UIkit.accordion(accordionParent);
300
+ if (accordionComponent) {
301
+ var itemIndex = Array.from(accordionParent.children).indexOf(copiedItem);
302
+ accordionComponent.toggle(itemIndex, false); // false = don't animate
303
+ }
304
+ } else {
305
+ // Manual fallback
306
+ copiedItem.classList.add('uk-open');
307
+ var content = copiedItem.querySelector('.uk-accordion-content');
308
+ if (content) {
309
+ content.hidden = false;
310
+ content.style.display = '';
311
+ }
312
+ }
313
+ }
314
+
315
+ // Apply visual highlight
316
+ setTimeout(function() {
317
+ copiedItem.style.transition = 'all 0.3s ease-in-out';
318
+ copiedItem.style.backgroundColor = '#dbeafe'; // Light blue background
319
+ copiedItem.style.borderLeft = '4px solid #3b82f6'; // Blue left border
320
+ copiedItem.style.borderRadius = '4px';
321
+
322
+ // Scroll into view
323
+ copiedItem.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
324
+
325
+ // Fade out the highlight after 3 seconds
326
+ setTimeout(function() {
327
+ copiedItem.style.backgroundColor = '';
328
+ copiedItem.style.borderLeft = '';
329
+ // Remove inline styles after transition
330
+ setTimeout(function() {
331
+ copiedItem.style.transition = '';
332
+ copiedItem.style.borderRadius = '';
333
+ }, 300);
334
+ }, 3000);
335
+ }, 100);
336
+ }
337
+ }, 100);
338
+ } else {
339
+ // Still in progress, check again in 50ms
340
+ setTimeout(waitForCopyComplete, 50);
341
+ }
342
+ };
343
+
344
+ // Start checking after a small delay
345
+ setTimeout(waitForCopyComplete, 100);
346
+
347
+ } else if (attempts < maxAttempts) {
348
+ // Not ready yet, try again with exponential backoff
349
+ setTimeout(checkAndCopy, delay);
350
+ } else {
351
+ console.error('Timeout: New list item not ready after ' + attempts + ' attempts');
352
+ window.__fhpfCopyInProgress = false;
353
+ }
354
+ } else if (attempts < maxAttempts) {
355
+ // Item not added yet, try again with exponential backoff
356
+ setTimeout(checkAndCopy, delay);
357
+ } else {
358
+ console.error('Timeout: New list item not found after ' + attempts + ' attempts');
359
+ window.__fhpfCopyInProgress = false;
360
+ }
361
+ };
362
+
363
+ // Start checking after a short delay to allow HTMX to initiate
364
+ setTimeout(checkAndCopy, 200);
365
+
366
+ // Exit early - the checkAndCopy function will handle the rest
367
+ return;
368
+ } else {
369
+ console.error('Could not find Add Item button for target list');
370
+ window.__fhpfCopyInProgress = false;
371
+ return;
372
+ }
373
+ } else {
374
+ console.error('Could not find target list container');
375
+ window.__fhpfCopyInProgress = false;
376
+ return;
377
+ }
378
+ }
379
+
380
+ // Non-list-item copy: standard behavior
381
+ // (Handle full list copy with length alignment before performing copy)
382
+ (function() {
383
+ // Detect if this is a "full list copy" of a list field:
384
+ // we treat it as a list if both sides have containers like "<prefix>_<path>_items_container"
385
+ var targetPrefix = (copyTarget === 'left') ? window.__fhpfLeftPrefix : window.__fhpfRightPrefix;
386
+
387
+ var baseIdPart = pathPrefix; // e.g. "addresses" or "key_features"
388
+ var sourceContainerId = sourcePrefix.replace(/_$/, '') + '_' + baseIdPart + '_items_container';
389
+ var targetContainerId = targetPrefix.replace(/_$/, '') + '_' + baseIdPart + '_items_container';
390
+
391
+ var sourceListContainer = document.getElementById(sourceContainerId);
392
+ var targetListContainer = document.getElementById(targetContainerId);
393
+
394
+ // Only do length alignment if BOTH containers exist (i.e., this field is a list on both sides)
395
+ if (sourceListContainer && targetListContainer) {
396
+ var sourceCount = sourceListContainer.querySelectorAll(':scope > li').length;
397
+ var targetCount = targetListContainer.querySelectorAll(':scope > li').length;
398
+
399
+ // If source has more items, add missing ones BEFORE copying values (case 3)
400
+ if (sourceCount > targetCount) {
401
+ var addBtn = targetListContainer.parentElement.querySelector('button[hx-post*="/list/add/"]');
402
+ if (addBtn) {
403
+ var addUrl = addBtn.getAttribute('hx-post');
404
+ var toAdd = sourceCount - targetCount;
405
+
406
+ // Queue the required number of additions at the END
407
+ // We'll use htmx.ajax with target=container and swap=beforeend
408
+ // Then wait for HTMX to settle and for the DOM to reflect the new length.
409
+ var added = 0;
410
+ var addOne = function(cb) {
411
+ htmx.ajax('POST', addUrl, {
412
+ target: '#' + targetContainerId,
413
+ swap: 'beforeend',
414
+ values: {} // no-op
415
+ });
416
+ added += 1;
417
+ cb && cb();
418
+ };
419
+
420
+ // Fire additions synchronously; HTMX will queue them
421
+ for (var i = 0; i < toAdd; i++) addOne();
422
+
423
+ // Wait for afterSettle AND correct length, then perform the copy
424
+ var attempts = 0, maxAttempts = 120; // ~6s @ 50ms backoff
425
+ var settled = false;
426
+
427
+ // Capture settle event once
428
+ var onSettle = function onSettleOnce() {
429
+ settled = true;
430
+ document.body.removeEventListener('htmx:afterSettle', onSettleOnce);
431
+ };
432
+ document.body.addEventListener('htmx:afterSettle', onSettle);
433
+
434
+ var waitAndCopy = function() {
435
+ attempts++;
436
+ var delay = Math.min(50 * Math.pow(1.15, attempts), 250);
437
+
438
+ var currentCount = targetListContainer.querySelectorAll(':scope > li').length;
439
+ if (settled && currentCount >= sourceCount) {
440
+ // Proceed with list copy by DOM position
441
+ performListCopyByPosition(sourceListContainer, targetListContainer, sourcePrefix, copyTarget, accordionStates, pathPrefix);
442
+ return;
443
+ }
444
+ if (attempts >= maxAttempts) {
445
+ console.error('Timeout aligning list lengths for full-list copy');
446
+ // Still do a best-effort copy
447
+ performListCopyByPosition(sourceListContainer, targetListContainer, sourcePrefix, copyTarget, accordionStates, pathPrefix);
448
+ return;
449
+ }
450
+ setTimeout(waitAndCopy, delay);
451
+ };
452
+
453
+ setTimeout(waitAndCopy, 50);
454
+ return; // Defer to waitAndCopy; don't fall through
455
+ } else {
456
+ console.warn('Full-list copy: add button not found on target; proceeding without length alignment.');
457
+ }
458
+ } else {
459
+ // Source has same or fewer items - use position-based copy for lists
460
+ performListCopyByPosition(sourceListContainer, targetListContainer, sourcePrefix, copyTarget, accordionStates, pathPrefix);
461
+ return;
462
+ }
463
+ }
464
+
465
+ // Default path (non-list fields or already aligned lists)
466
+ performStandardCopy(pathPrefix, pathPrefix, sourcePrefix, copyTarget, accordionStates);
467
+ })();
468
+
469
+ } catch (e) {
470
+ window.__fhpfCopyInProgress = false;
471
+ throw e;
472
+ }
473
+ };
474
+
475
+ // Copy list items by DOM position (handles different indices in source/target)
476
+ function performListCopyByPosition(sourceListContainer, targetListContainer, sourcePrefix, copyTarget, accordionStates, listFieldPath) {
477
+ try {
478
+ var sourceItems = sourceListContainer.querySelectorAll(':scope > li');
479
+ var targetItems = targetListContainer.querySelectorAll(':scope > li');
480
+ var targetPrefix = (copyTarget === 'left') ? window.__fhpfLeftPrefix : window.__fhpfRightPrefix;
481
+
482
+ // Copy each source item to corresponding target item by position
483
+ for (var i = 0; i < sourceItems.length && i < targetItems.length; i++) {
484
+ var sourceItem = sourceItems[i];
485
+ var targetItem = targetItems[i];
486
+
487
+ // Find all inputs within this source item
488
+ var sourceInputs = sourceItem.querySelectorAll('[data-field-path]');
489
+
490
+ Array.from(sourceInputs).forEach(function(sourceInput) {
491
+ var sourceFp = sourceInput.getAttribute('data-field-path');
492
+
493
+ // Extract the field path relative to the list item
494
+ // e.g., "addresses[0].street" -> ".street"
495
+ // or "tags[0]" -> ""
496
+ var listItemPattern = new RegExp('^' + listFieldPath.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&') + '\\\\[[^\\\\]]+\\\\]');
497
+ var relativePath = sourceFp.replace(listItemPattern, '');
498
+
499
+ // Find the corresponding input in the target item by looking for the same relative path
500
+ var targetInputs = targetItem.querySelectorAll('[data-field-path]');
501
+ var targetInput = null;
502
+
503
+ for (var j = 0; j < targetInputs.length; j++) {
504
+ var targetFp = targetInputs[j].getAttribute('data-field-path');
505
+ var targetRelativePath = targetFp.replace(listItemPattern, '');
506
+
507
+ if (targetRelativePath === relativePath) {
508
+ // Verify it belongs to the target form
509
+ var candidateName = null;
510
+ if (targetInputs[j].tagName === 'UK-SELECT') {
511
+ var nativeSelect = targetInputs[j].querySelector('select');
512
+ candidateName = nativeSelect ? nativeSelect.name : null;
513
+ } else {
514
+ candidateName = targetInputs[j].name;
515
+ }
516
+
517
+ if (candidateName && !candidateName.startsWith(sourcePrefix)) {
518
+ targetInput = targetInputs[j];
519
+ break;
520
+ }
521
+ }
522
+ }
523
+
524
+ if (!targetInput) {
525
+ return;
526
+ }
527
+
528
+ // Copy the value
529
+ var tag = sourceInput.tagName.toUpperCase();
530
+ var type = (sourceInput.type || '').toLowerCase();
531
+
532
+ if (type === 'checkbox') {
533
+ targetInput.checked = sourceInput.checked;
534
+ } else if (tag === 'SELECT') {
535
+ targetInput.value = sourceInput.value;
536
+ } else if (tag === 'UK-SELECT') {
537
+ var sourceNativeSelect = sourceInput.querySelector('select');
538
+ var targetNativeSelect = targetInput.querySelector('select');
539
+ if (sourceNativeSelect && targetNativeSelect) {
540
+ var sourceValue = sourceNativeSelect.value;
541
+
542
+ // Clear all selected attributes
543
+ for (var k = 0; k < targetNativeSelect.options.length; k++) {
544
+ targetNativeSelect.options[k].removeAttribute('selected');
545
+ targetNativeSelect.options[k].selected = false;
546
+ }
547
+
548
+ // Find and set the matching option
549
+ for (var k = 0; k < targetNativeSelect.options.length; k++) {
550
+ if (targetNativeSelect.options[k].value === sourceValue) {
551
+ targetNativeSelect.options[k].setAttribute('selected', 'selected');
552
+ targetNativeSelect.options[k].selected = true;
553
+ targetNativeSelect.selectedIndex = k;
554
+ targetNativeSelect.value = sourceValue;
555
+ break;
556
+ }
557
+ }
558
+
559
+ // Update the button display
560
+ var sourceButton = sourceInput.querySelector('button');
561
+ var targetButton = targetInput.querySelector('button');
562
+ if (sourceButton && targetButton) {
563
+ targetButton.innerHTML = sourceButton.innerHTML;
564
+ }
565
+ }
566
+ } else if (tag === 'TEXTAREA') {
567
+ var valueToSet = sourceInput.value;
568
+ targetInput.value = '';
569
+ targetInput.textContent = '';
570
+ targetInput.innerHTML = '';
571
+ targetInput.value = valueToSet;
572
+ targetInput.textContent = valueToSet;
573
+ targetInput.innerHTML = valueToSet;
574
+ targetInput.setAttribute('value', valueToSet);
575
+
576
+ var inputEvent = new Event('input', { bubbles: true });
577
+ var changeEvent = new Event('change', { bubbles: true });
578
+ targetInput.dispatchEvent(inputEvent);
579
+ targetInput.dispatchEvent(changeEvent);
580
+
581
+ try {
582
+ targetInput.focus();
583
+ targetInput.blur();
584
+ } catch (e) {
585
+ // Ignore errors
586
+ }
587
+ } else {
588
+ targetInput.value = sourceInput.value;
589
+ targetInput.dispatchEvent(new Event('input', { bubbles: true }));
590
+ targetInput.dispatchEvent(new Event('change', { bubbles: true }));
591
+ }
592
+ });
593
+ }
594
+
595
+ // Remove excess items from target if source has fewer items
596
+ for (var i = targetItems.length - 1; i >= sourceItems.length; i--) {
597
+ targetItems[i].remove();
598
+ }
599
+
600
+ // Restore accordion states
601
+ setTimeout(function() {
602
+ accordionStates.forEach(function(state) {
603
+ if (state.isOpen && !state.element.classList.contains('uk-open')) {
604
+ var accordionParent = state.element.parentElement;
605
+ if (accordionParent && window.UIkit) {
606
+ var accordionComponent = UIkit.accordion(accordionParent);
607
+ if (accordionComponent) {
608
+ var itemIndex = Array.from(accordionParent.children).indexOf(state.element);
609
+ accordionComponent.toggle(itemIndex, true);
610
+ } else {
611
+ state.element.classList.add('uk-open');
612
+ var content = state.element.querySelector('.uk-accordion-content');
613
+ if (content) {
614
+ content.hidden = false;
615
+ content.style.height = 'auto';
616
+ }
617
+ }
618
+ }
619
+ }
620
+ });
621
+
622
+ window.__fhpfCopyInProgress = false;
623
+
624
+ // Trigger a refresh on the target list field to update counts and titles
625
+ // Find the refresh button for the target list field
626
+ var targetListFieldWrapper = targetListContainer.closest('[data-path]');
627
+ if (targetListFieldWrapper) {
628
+ var refreshButton = targetListFieldWrapper.querySelector('button[hx-post*="/refresh"]');
629
+ if (refreshButton && window.htmx) {
630
+ // Trigger the HTMX refresh
631
+ htmx.trigger(refreshButton, 'click');
632
+ }
633
+ }
634
+ }, 150);
635
+
636
+ } catch (e) {
637
+ window.__fhpfCopyInProgress = false;
638
+ throw e;
639
+ }
640
+ }
641
+
642
+ // Extracted standard copy logic to allow reuse
643
+ function performStandardCopy(sourcePathPrefix, targetPathPrefix, sourcePrefix, copyTarget, accordionStates) {
644
+ try {
645
+ // Find all inputs with matching data-field-path from source
646
+ var allInputs = document.querySelectorAll('[data-field-path]');
647
+ var sourceInputs = Array.from(allInputs).filter(function(el) {
648
+ var fp = el.getAttribute('data-field-path');
649
+ if (!(fp === sourcePathPrefix || fp.startsWith(sourcePathPrefix + '.') || fp.startsWith(sourcePathPrefix + '['))) {
650
+ return false;
651
+ }
652
+
653
+ // Check if this element belongs to the source form
654
+ var elementName = null;
655
+ if (el.tagName === 'UK-SELECT') {
656
+ var nativeSelect = el.querySelector('select');
657
+ elementName = nativeSelect ? nativeSelect.name : null;
658
+ } else {
659
+ elementName = el.name;
660
+ }
661
+
662
+ return elementName && elementName.startsWith(sourcePrefix);
663
+ });
664
+
665
+ // Track updated selects to fire change events later
666
+ var updatedSelects = [];
667
+
668
+ var copiedCount = 0;
669
+ sourceInputs.forEach(function(sourceInput) {
670
+ var sourceFp = sourceInput.getAttribute('data-field-path');
671
+
672
+ // Map source field path to target field path
673
+ // If sourcePathPrefix != targetPathPrefix (list item case), we need to remap
674
+ var targetFp = sourceFp;
675
+ if (sourcePathPrefix !== targetPathPrefix) {
676
+ // Replace the source path prefix with target path prefix
677
+ if (sourceFp === sourcePathPrefix) {
678
+ targetFp = targetPathPrefix;
679
+ } else if (sourceFp.startsWith(sourcePathPrefix + '.')) {
680
+ targetFp = targetPathPrefix + sourceFp.substring(sourcePathPrefix.length);
681
+ } else if (sourceFp.startsWith(sourcePathPrefix + '[')) {
682
+ targetFp = targetPathPrefix + sourceFp.substring(sourcePathPrefix.length);
683
+ }
684
+ }
685
+
686
+ // Find target by data-field-path, then verify it's NOT from the source form
687
+ var candidates = document.querySelectorAll('[data-field-path="' + targetFp + '"]');
688
+ var targetInput = null;
689
+ for (var i = 0; i < candidates.length; i++) {
690
+ var candidate = candidates[i];
691
+ var candidateName = null;
692
+ if (candidate.tagName === 'UK-SELECT') {
693
+ var nativeSelect = candidate.querySelector('select');
694
+ candidateName = nativeSelect ? nativeSelect.name : null;
695
+ } else {
696
+ candidateName = candidate.name;
697
+ }
698
+ if (candidateName && !candidateName.startsWith(sourcePrefix)) {
699
+ targetInput = candidate;
700
+ break;
701
+ }
702
+ }
703
+
704
+ if (!targetInput) {
705
+ return;
706
+ }
707
+
708
+ var tag = sourceInput.tagName.toUpperCase();
709
+ var type = (sourceInput.type || '').toLowerCase();
710
+
711
+ if (type === 'checkbox') {
712
+ targetInput.checked = sourceInput.checked;
713
+ } else if (tag === 'SELECT') {
714
+ targetInput.value = sourceInput.value;
715
+ updatedSelects.push(targetInput);
716
+ } else if (tag === 'UK-SELECT') {
717
+ var sourceNativeSelect = sourceInput.querySelector('select');
718
+ var targetNativeSelect = targetInput.querySelector('select');
719
+ if (sourceNativeSelect && targetNativeSelect) {
720
+ var sourceValue = sourceNativeSelect.value;
721
+
722
+ // First, clear all selected attributes
723
+ for (var i = 0; i < targetNativeSelect.options.length; i++) {
724
+ targetNativeSelect.options[i].removeAttribute('selected');
725
+ targetNativeSelect.options[i].selected = false;
726
+ }
727
+
728
+ // Find and set the matching option
729
+ for (var i = 0; i < targetNativeSelect.options.length; i++) {
730
+ if (targetNativeSelect.options[i].value === sourceValue) {
731
+ targetNativeSelect.options[i].setAttribute('selected', 'selected');
732
+ targetNativeSelect.options[i].selected = true;
733
+ targetNativeSelect.selectedIndex = i;
734
+ targetNativeSelect.value = sourceValue;
735
+ break;
736
+ }
737
+ }
738
+
739
+ // Update the button display
740
+ var sourceButton = sourceInput.querySelector('button');
741
+ var targetButton = targetInput.querySelector('button');
742
+ if (sourceButton && targetButton) {
743
+ targetButton.innerHTML = sourceButton.innerHTML;
744
+ }
745
+
746
+ // Track this select for later event firing
747
+ updatedSelects.push(targetNativeSelect);
748
+ }
749
+ } else if (tag === 'TEXTAREA') {
750
+ // Set value multiple ways to ensure it sticks
751
+ var valueToSet = sourceInput.value;
752
+
753
+ // First, completely clear the textarea
754
+ targetInput.value = '';
755
+ targetInput.textContent = '';
756
+ targetInput.innerHTML = '';
757
+
758
+ // Then set the new value
759
+ // Method 1: Set value property
760
+ targetInput.value = valueToSet;
761
+
762
+ // Method 2: Set textContent
763
+ targetInput.textContent = valueToSet;
764
+
765
+ // Method 3: Set innerHTML
766
+ targetInput.innerHTML = valueToSet;
767
+
768
+ // Method 4: Use setAttribute
769
+ targetInput.setAttribute('value', valueToSet);
770
+
771
+ // Trigger input and change events to notify any UI components
772
+ var inputEvent = new Event('input', { bubbles: true });
773
+ var changeEvent = new Event('change', { bubbles: true });
774
+ targetInput.dispatchEvent(inputEvent);
775
+ targetInput.dispatchEvent(changeEvent);
776
+
777
+ // Force browser to re-render by triggering focus events
778
+ try {
779
+ targetInput.focus();
780
+ targetInput.blur();
781
+ } catch (e) {
782
+ // Ignore errors if focus/blur not supported
783
+ }
784
+
785
+ copiedCount++;
786
+ } else {
787
+ targetInput.value = sourceInput.value;
788
+ // Trigger events for any UI framework listening
789
+ targetInput.dispatchEvent(new Event('input', { bubbles: true }));
790
+ targetInput.dispatchEvent(new Event('change', { bubbles: true }));
791
+ copiedCount++;
792
+ }
793
+ });
794
+
795
+ // Handle list cleanup - remove excess items from target list
796
+ // Only do this when copying a whole list field (not individual items)
797
+ // Check if this is a list field by looking for a list container
798
+ if (sourcePathPrefix && !sourcePathPrefix.includes('[') && sourcePathPrefix === targetPathPrefix) {
799
+ // This is a top-level field (not a list item), check if it's a list field
800
+ // Try to find list containers for both source and target
801
+ var targetPrefix = (copyTarget === 'left') ? window.__fhpfLeftPrefix : window.__fhpfRightPrefix;
802
+
803
+ // Build container ID patterns - handle both with and without trailing underscore
804
+ var sourceContainerIdPattern = sourcePrefix.replace(/_$/, '') + '_' + sourcePathPrefix + '_items_container';
805
+ var targetContainerIdPattern = targetPrefix.replace(/_$/, '') + '_' + targetPathPrefix + '_items_container';
806
+
807
+ var sourceListContainer = document.getElementById(sourceContainerIdPattern);
808
+ var targetListContainer = document.getElementById(targetContainerIdPattern);
809
+
810
+ if (sourceListContainer && targetListContainer) {
811
+ // Both containers exist, this is a list field
812
+ // Count list items in source and target
813
+ var sourceItemCount = sourceListContainer.querySelectorAll(':scope > li').length;
814
+ var targetItems = targetListContainer.querySelectorAll(':scope > li');
815
+
816
+ // Remove excess items from target (from end backwards)
817
+ for (var i = targetItems.length - 1; i >= sourceItemCount; i--) {
818
+ targetItems[i].remove();
819
+ }
820
+ }
821
+ }
822
+
823
+ // Restore accordion states after a brief delay
824
+ setTimeout(function() {
825
+ accordionStates.forEach(function(state) {
826
+ if (state.isOpen && !state.element.classList.contains('uk-open')) {
827
+ // Use UIkit's toggle API to properly open the accordion
828
+ var accordionParent = state.element.parentElement;
829
+ if (accordionParent && window.UIkit) {
830
+ var accordionComponent = UIkit.accordion(accordionParent);
831
+ if (accordionComponent) {
832
+ var itemIndex = Array.from(accordionParent.children).indexOf(state.element);
833
+ accordionComponent.toggle(itemIndex, true);
834
+ } else {
835
+ // Fallback to manual class manipulation
836
+ state.element.classList.add('uk-open');
837
+ var content = state.element.querySelector('.uk-accordion-content');
838
+ if (content) {
839
+ content.hidden = false;
840
+ content.style.height = 'auto';
841
+ }
842
+ }
843
+ }
844
+ }
845
+ });
846
+
847
+ window.__fhpfCopyInProgress = false;
848
+
849
+ // Fire change events on updated selects AFTER accordion restoration
850
+ setTimeout(function() {
851
+ updatedSelects.forEach(function(select) {
852
+ select.dispatchEvent(new Event('change', { bubbles: true }));
853
+ });
854
+ }, 50);
855
+ }, 150);
856
+
857
+ } catch (e) {
858
+ window.__fhpfCopyInProgress = false;
859
+ throw e;
860
+ }
861
+ }
862
+
863
+ window.fhpfInitComparisonSync = function initComparisonSync(){
864
+ // 1) Wait until UIkit and its util are available
865
+ if (!window.UIkit || !UIkit.util) {
866
+ return setTimeout(initComparisonSync, 50);
867
+ }
868
+
869
+ // Fix native select name attributes (MonsterUI puts name on uk-select, not native select)
870
+ // IMPORTANT: Remove name from uk-select to avoid duplicate form submission
871
+ document.querySelectorAll('uk-select[name]').forEach(function(ukSelect) {
872
+ var nativeSelect = ukSelect.querySelector('select');
873
+ if (nativeSelect) {
874
+ var ukSelectName = ukSelect.getAttribute('name');
875
+ if (!nativeSelect.name && ukSelectName) {
876
+ nativeSelect.name = ukSelectName;
877
+ // Remove name from uk-select to prevent duplicate submission
878
+ ukSelect.removeAttribute('name');
879
+ }
880
+ }
881
+ });
882
+
883
+
884
+ // 2) Sync top-level accordions (BaseModelFieldRenderer)
885
+ UIkit.util.on(
886
+ document,
887
+ 'show hide', // UIkit fires plain 'show'/'hide'
888
+ 'ul[uk-accordion] > li', // only the top-level items
889
+ mirrorTopLevel
890
+ );
891
+
892
+ function mirrorTopLevel(ev) {
893
+ const sourceLi = ev.target.closest('li');
894
+ if (!sourceLi) return;
895
+
896
+ // Skip if copy operation is in progress
897
+ if (window.__fhpfCopyInProgress) {
898
+ return;
899
+ }
900
+
901
+ // Skip if this event is from a select/dropdown element
902
+ if (ev.target.closest('uk-select, select, [uk-select]')) {
903
+ return;
904
+ }
905
+
906
+ // Skip if this is a nested list item (let mirrorNestedListItems handle it)
907
+ if (sourceLi.closest('[id$="_items_container"]')) {
908
+ return;
909
+ }
910
+
911
+ // Find our grid-cell wrapper (both left & right share the same data-path)
912
+ const cell = sourceLi.closest('[data-path]');
913
+ if (!cell) return;
914
+ const path = cell.dataset.path;
915
+
916
+ // Determine index of this <li> inside its <ul>
917
+ const idx = Array.prototype.indexOf.call(
918
+ sourceLi.parentElement.children,
919
+ sourceLi
920
+ );
921
+ const opening = ev.type === 'show';
922
+
923
+ // Mirror on the other side
924
+ document
925
+ .querySelectorAll(`[data-path="${path}"]`)
926
+ .forEach(peerCell => {
927
+ if (peerCell === cell) return;
928
+
929
+ const peerAcc = peerCell.querySelector('ul[uk-accordion]');
930
+ if (!peerAcc || idx >= peerAcc.children.length) return;
931
+
932
+ const peerLi = peerAcc.children[idx];
933
+ const peerContent = peerLi.querySelector('.uk-accordion-content');
934
+
935
+ if (opening) {
936
+ peerLi.classList.add('uk-open');
937
+ if (peerContent) {
938
+ peerContent.hidden = false;
939
+ peerContent.style.height = 'auto';
940
+ }
941
+ } else {
942
+ peerLi.classList.remove('uk-open');
943
+ if (peerContent) {
944
+ peerContent.hidden = true;
945
+ }
946
+ }
947
+ });
948
+ }
949
+
950
+ // 3) Sync nested list item accordions (individual items within lists)
951
+ UIkit.util.on(
952
+ document,
953
+ 'show hide',
954
+ '[id$="_items_container"] > li', // only list items within items containers
955
+ mirrorNestedListItems
956
+ );
957
+
958
+ function mirrorNestedListItems(ev) {
959
+ const sourceLi = ev.target.closest('li');
960
+ if (!sourceLi) return;
961
+
962
+ // Skip if copy operation is in progress
963
+ if (window.__fhpfCopyInProgress) {
964
+ return;
965
+ }
966
+
967
+ // Skip if this event is from a select/dropdown element
968
+ if (ev.target.closest('uk-select, select, [uk-select]')) {
969
+ return;
970
+ }
971
+
972
+ // Skip if this event was triggered by our own sync
973
+ if (sourceLi.dataset.syncDisabled) {
974
+ return;
975
+ }
976
+
977
+ // Find the list container (items_container) that contains this item
978
+ const listContainer = sourceLi.closest('[id$="_items_container"]');
979
+ if (!listContainer) return;
980
+
981
+ // Find the grid cell wrapper with data-path
982
+ const cell = listContainer.closest('[data-path]');
983
+ if (!cell) return;
984
+ const path = cell.dataset.path;
985
+
986
+ // Determine index of this <li> within its list container
987
+ const listAccordion = sourceLi.parentElement;
988
+ const idx = Array.prototype.indexOf.call(listAccordion.children, sourceLi);
989
+ const opening = ev.type === 'show';
990
+
991
+ // Mirror on the other side
992
+ document
993
+ .querySelectorAll(`[data-path="${path}"]`)
994
+ .forEach(peerCell => {
995
+ if (peerCell === cell) return;
996
+
997
+ // Find the peer's list container
998
+ const peerListContainer = peerCell.querySelector('[id$="_items_container"]');
999
+ if (!peerListContainer) return;
1000
+
1001
+ // The list container IS the accordion itself (not a wrapper around it)
1002
+ let peerListAccordion;
1003
+ if (peerListContainer.hasAttribute('uk-accordion') && peerListContainer.tagName === 'UL') {
1004
+ peerListAccordion = peerListContainer;
1005
+ } else {
1006
+ peerListAccordion = peerListContainer.querySelector('ul[uk-accordion]');
1007
+ }
1008
+
1009
+ if (!peerListAccordion || idx >= peerListAccordion.children.length) return;
1010
+
1011
+ const peerLi = peerListAccordion.children[idx];
1012
+ const peerContent = peerLi.querySelector('.uk-accordion-content');
1013
+
1014
+ // Prevent event cascading by temporarily disabling our own event listener
1015
+ if (peerLi.dataset.syncDisabled) {
1016
+ return;
1017
+ }
1018
+
1019
+ // Mark this item as being synced to prevent loops
1020
+ peerLi.dataset.syncDisabled = 'true';
1021
+
1022
+ // Check current state and only sync if different
1023
+ const currentlyOpen = peerLi.classList.contains('uk-open');
1024
+
1025
+ if (currentlyOpen !== opening) {
1026
+ if (opening) {
1027
+ peerLi.classList.add('uk-open');
1028
+ if (peerContent) {
1029
+ peerContent.hidden = false;
1030
+ peerContent.style.height = 'auto';
1031
+ }
1032
+ } else {
1033
+ peerLi.classList.remove('uk-open');
1034
+ if (peerContent) {
1035
+ peerContent.hidden = true;
1036
+ }
1037
+ }
1038
+ }
1039
+
1040
+ // Re-enable sync after a short delay
1041
+ setTimeout(() => {
1042
+ delete peerLi.dataset.syncDisabled;
1043
+ }, 100);
1044
+ });
1045
+ }
1046
+
1047
+ // 4) Wrap the list-toggle so ListFieldRenderer accordions sync too
1048
+ if (typeof window.toggleListItems === 'function' && !window.__listSyncWrapped) {
1049
+ // guard to only wrap once
1050
+ window.__listSyncWrapped = true;
1051
+ const originalToggle = window.toggleListItems;
1052
+
1053
+ window.toggleListItems = function(containerId) {
1054
+ // a) Toggle this column first
1055
+ originalToggle(containerId);
1056
+
1057
+ // b) Find the enclosing data-path
1058
+ const container = document.getElementById(containerId);
1059
+ if (!container) return;
1060
+ const cell = container.closest('[data-path]');
1061
+ if (!cell) return;
1062
+ const path = cell.dataset.path;
1063
+
1064
+ // c) Find the peer's list-container by suffix match
1065
+ document
1066
+ .querySelectorAll(`[data-path="${path}"]`)
1067
+ .forEach(peerCell => {
1068
+ if (peerCell === cell) return;
1069
+
1070
+ // look up any [id$="_items_container"]
1071
+ const peerContainer = peerCell.querySelector('[id$="_items_container"]');
1072
+ if (peerContainer) {
1073
+ originalToggle(peerContainer.id);
1074
+ }
1075
+ });
1076
+ };
1077
+ }
1078
+ };
1079
+
1080
+ // Initial run
1081
+ window.fhpfInitComparisonSync();
1082
+
1083
+ // Re-run after HTMX swaps to maintain sync
1084
+ document.addEventListener('htmx:afterSwap', function(event) {
1085
+ window.fhpfInitComparisonSync();
1086
+ });
1087
+ """)
1088
+
1089
+
1090
+ class ComparisonForm(Generic[ModelType]):
1091
+ """
1092
+ Meta-renderer for side-by-side form comparison with metrics visualization
1093
+
1094
+ This class creates a two-column layout with synchronized accordions and
1095
+ visual comparison feedback (colors, tooltips, metric badges).
1096
+
1097
+ The ComparisonForm is a view-only composition helper; state management
1098
+ lives in the underlying PydanticForm instances.
1099
+ """
1100
+
1101
+ def __init__(
1102
+ self,
1103
+ name: str,
1104
+ left_form: PydanticForm[ModelType],
1105
+ right_form: PydanticForm[ModelType],
1106
+ *,
1107
+ left_label: str = "Reference",
1108
+ right_label: str = "Generated",
1109
+ copy_left: bool = False,
1110
+ copy_right: bool = False,
1111
+ ):
1112
+ """
1113
+ Initialize the comparison form
1114
+
1115
+ Args:
1116
+ name: Unique name for this comparison form
1117
+ left_form: Pre-constructed PydanticForm for left column
1118
+ right_form: Pre-constructed PydanticForm for right column
1119
+ left_label: Label for left column
1120
+ right_label: Label for right column
1121
+ copy_left: If True, show copy buttons in right column to copy to left
1122
+ copy_right: If True, show copy buttons in left column to copy to right
1123
+
1124
+ Raises:
1125
+ ValueError: If the two forms are not based on the same model class
1126
+ """
1127
+ # Validate that both forms use the same model
1128
+ if left_form.model_class is not right_form.model_class:
1129
+ raise ValueError(
1130
+ f"Both forms must be based on the same model class. "
1131
+ f"Got {left_form.model_class.__name__} and {right_form.model_class.__name__}"
1132
+ )
1133
+
1134
+ self.name = name
1135
+ self.left_form = left_form
1136
+ self.right_form = right_form
1137
+ self.model_class = left_form.model_class # Convenience reference
1138
+ self.left_label = left_label
1139
+ self.right_label = right_label
1140
+ self.copy_left = copy_left
1141
+ self.copy_right = copy_right
1142
+
1143
+ # Use spacing from left form (or could add override parameter if needed)
1144
+ self.spacing = left_form.spacing
1145
+
1146
+ def _get_field_path_string(self, field_path: List[str]) -> str:
1147
+ """Convert field path list to dot-notation string for comparison lookup"""
1148
+ return ".".join(field_path)
1149
+
1150
+ def _split_path(self, path: str) -> List[Union[str, int]]:
1151
+ """
1152
+ Split a dot/bracket path string into segments.
1153
+
1154
+ Examples:
1155
+ "author.name" -> ["author", "name"]
1156
+ "addresses[0].street" -> ["addresses", 0, "street"]
1157
+ "experience[2].company" -> ["experience", 2, "company"]
1158
+
1159
+ Args:
1160
+ path: Dot/bracket notation path string
1161
+
1162
+ Returns:
1163
+ List of path segments (strings and ints)
1164
+ """
1165
+ _INDEX = re.compile(r"(.+?)\[(\d+)\]$")
1166
+ parts: List[Union[str, int]] = []
1167
+
1168
+ for segment in path.split("."):
1169
+ m = _INDEX.match(segment)
1170
+ if m:
1171
+ # Segment has bracket notation like "name[3]"
1172
+ parts.append(m.group(1))
1173
+ parts.append(int(m.group(2)))
1174
+ else:
1175
+ parts.append(segment)
1176
+
1177
+ return parts
1178
+
1179
+ def _get_by_path(self, data: Dict[str, Any], path: str) -> tuple[bool, Any]:
1180
+ """
1181
+ Get a value from nested dict/list structure by path.
1182
+
1183
+ Args:
1184
+ data: The data structure to traverse
1185
+ path: Dot/bracket notation path string
1186
+
1187
+ Returns:
1188
+ Tuple of (found, value) where found is True if path exists, False otherwise
1189
+ """
1190
+ cur = data
1191
+ for seg in self._split_path(path):
1192
+ if isinstance(seg, int):
1193
+ if not isinstance(cur, list) or seg >= len(cur):
1194
+ return (False, None)
1195
+ cur = cur[seg]
1196
+ else:
1197
+ if not isinstance(cur, dict) or seg not in cur:
1198
+ return (False, None)
1199
+ cur = cur[seg]
1200
+ return (True, deepcopy(cur))
1201
+
1202
+ def _set_by_path(self, data: Dict[str, Any], path: str, value: Any) -> None:
1203
+ """
1204
+ Set a value in nested dict/list structure by path, creating intermediates.
1205
+
1206
+ Args:
1207
+ data: The data structure to modify
1208
+ path: Dot/bracket notation path string
1209
+ value: The value to set
1210
+ """
1211
+ cur = data
1212
+ parts = self._split_path(path)
1213
+
1214
+ for i, seg in enumerate(parts):
1215
+ is_last = i == len(parts) - 1
1216
+
1217
+ if is_last:
1218
+ # Set the final value
1219
+ if isinstance(seg, int):
1220
+ if not isinstance(cur, list):
1221
+ raise ValueError("Cannot set list index on non-list parent")
1222
+ # Extend list if needed
1223
+ while len(cur) <= seg:
1224
+ cur.append(None)
1225
+ cur[seg] = deepcopy(value)
1226
+ else:
1227
+ if not isinstance(cur, dict):
1228
+ raise ValueError("Cannot set dict key on non-dict parent")
1229
+ cur[seg] = deepcopy(value)
1230
+ else:
1231
+ # Navigate or create intermediate containers
1232
+ nxt = parts[i + 1]
1233
+
1234
+ if isinstance(seg, int):
1235
+ if not isinstance(cur, list):
1236
+ raise ValueError("Non-list where list expected")
1237
+ # Extend list if needed
1238
+ while len(cur) <= seg:
1239
+ cur.append({} if isinstance(nxt, str) else [])
1240
+ cur = cur[seg]
1241
+ else:
1242
+ if seg not in cur or not isinstance(cur[seg], (dict, list)):
1243
+ # Create appropriate container type
1244
+ cur[seg] = {} if isinstance(nxt, str) else []
1245
+ cur = cur[seg]
1246
+
1247
+ def _render_column(
1248
+ self,
1249
+ *,
1250
+ form: PydanticForm[ModelType],
1251
+ header_label: str,
1252
+ start_order: int,
1253
+ wrapper_id: str,
1254
+ ) -> FT:
1255
+ """
1256
+ Render a single column with CSS order values for grid alignment
1257
+
1258
+ Args:
1259
+ form: The PydanticForm instance for this column
1260
+ header_label: Label for the column header
1261
+ start_order: Starting order value (0 for left, 1 for right)
1262
+ wrapper_id: ID for the wrapper div
1263
+
1264
+ Returns:
1265
+ A div with class="contents" containing ordered grid items
1266
+ """
1267
+ # Header with order
1268
+ cells = [
1269
+ fh.Div(
1270
+ fh.H3(header_label, cls="text-lg font-semibold text-gray-700"),
1271
+ cls="pb-2 border-b",
1272
+ style=f"order:{start_order}",
1273
+ )
1274
+ ]
1275
+
1276
+ # Start at order + 2, increment by 2 for each field
1277
+ order_idx = start_order + 2
1278
+
1279
+ # Create renderers for each field
1280
+ registry = FieldRendererRegistry()
1281
+
1282
+ for field_name, field_info in self.model_class.model_fields.items():
1283
+ # Skip excluded fields
1284
+ if field_name in (form.exclude_fields or []):
1285
+ continue
1286
+
1287
+ # Skip SkipJsonSchema fields unless explicitly kept
1288
+ if _is_skip_json_schema_field(field_info) and not form._is_kept_skip_field(
1289
+ [field_name]
1290
+ ):
1291
+ continue
1292
+
1293
+ # Get value from form
1294
+ value = form.values_dict.get(field_name)
1295
+
1296
+ # Get path string for data-path attribute
1297
+ path_str = field_name
1298
+
1299
+ # Get renderer class
1300
+ renderer_cls = registry.get_renderer(field_name, field_info)
1301
+ if not renderer_cls:
1302
+ from fh_pydantic_form.field_renderers import StringFieldRenderer
1303
+
1304
+ renderer_cls = StringFieldRenderer
1305
+
1306
+ # Determine comparison-specific refresh endpoint
1307
+ comparison_refresh = f"/compare/{self.name}/{'left' if form is self.left_form else 'right'}/refresh"
1308
+
1309
+ # Get label color for this field if specified
1310
+ label_color = (
1311
+ form.label_colors.get(field_name)
1312
+ if hasattr(form, "label_colors")
1313
+ else None
1314
+ )
1315
+
1316
+ # Determine comparison copy settings
1317
+ # Show copy buttons on the SOURCE form (the form you're copying FROM)
1318
+ is_left_column = form is self.left_form
1319
+
1320
+ # If copy_left is enabled, show button on RIGHT form to copy TO left
1321
+ # If copy_right is enabled, show button on LEFT form to copy TO right
1322
+ if is_left_column:
1323
+ # This is the left form
1324
+ # Show copy button if we want to copy TO the right
1325
+ copy_feature_enabled = self.copy_right
1326
+ comparison_copy_target = "right" if copy_feature_enabled else None
1327
+ target_form = self.right_form
1328
+ else:
1329
+ # This is the right form
1330
+ # Show copy button if we want to copy TO the left
1331
+ copy_feature_enabled = self.copy_left
1332
+ comparison_copy_target = "left" if copy_feature_enabled else None
1333
+ target_form = self.left_form
1334
+
1335
+ # Enable copy button if:
1336
+ # 1. The feature is enabled (copy_left or copy_right)
1337
+ # 2. The TARGET form is NOT disabled (you can't copy into a disabled/read-only form)
1338
+ comparison_copy_enabled = (
1339
+ copy_feature_enabled and not target_form.disabled
1340
+ if target_form
1341
+ else False
1342
+ )
1343
+
1344
+ # Create renderer
1345
+ renderer = renderer_cls(
1346
+ field_name=field_name,
1347
+ field_info=field_info,
1348
+ value=value,
1349
+ prefix=form.base_prefix,
1350
+ disabled=form.disabled,
1351
+ spacing=form.spacing,
1352
+ field_path=[field_name],
1353
+ form_name=form.name,
1354
+ label_color=label_color, # Pass the label color if specified
1355
+ metrics_dict=form.metrics_dict, # Use form's own metrics
1356
+ keep_skip_json_pathset=form._keep_skip_json_pathset, # Pass keep_skip_json configuration
1357
+ refresh_endpoint_override=comparison_refresh, # Pass comparison-specific refresh endpoint
1358
+ comparison_copy_enabled=comparison_copy_enabled,
1359
+ comparison_copy_target=comparison_copy_target,
1360
+ comparison_name=self.name,
1361
+ )
1362
+
1363
+ # Render with data-path and order
1364
+ cells.append(
1365
+ fh.Div(
1366
+ renderer.render(),
1367
+ cls="",
1368
+ **{"data-path": path_str, "style": f"order:{order_idx}"},
1369
+ )
1370
+ )
1371
+
1372
+ order_idx += 2
1373
+
1374
+ # Return wrapper with display: contents
1375
+ return fh.Div(*cells, id=wrapper_id, cls="contents")
1376
+
1377
+ def render_inputs(self) -> FT:
1378
+ """
1379
+ Render the comparison form with side-by-side layout
1380
+
1381
+ Returns:
1382
+ A FastHTML component with CSS Grid layout
1383
+ """
1384
+ # Render left column with wrapper
1385
+ left_wrapper = self._render_column(
1386
+ form=self.left_form,
1387
+ header_label=self.left_label,
1388
+ start_order=0,
1389
+ wrapper_id=f"{self.left_form.name}-inputs-wrapper",
1390
+ )
1391
+
1392
+ # Render right column with wrapper
1393
+ right_wrapper = self._render_column(
1394
+ form=self.right_form,
1395
+ header_label=self.right_label,
1396
+ start_order=1,
1397
+ wrapper_id=f"{self.right_form.name}-inputs-wrapper",
1398
+ )
1399
+
1400
+ # Create the grid container with both wrappers
1401
+ grid_container = fh.Div(
1402
+ left_wrapper,
1403
+ right_wrapper,
1404
+ cls="fhpf-compare grid grid-cols-2 gap-x-6 gap-y-2 items-start",
1405
+ id=f"{self.name}-comparison-grid",
1406
+ )
1407
+
1408
+ # Emit prefix globals for the copy registry
1409
+ prefix_script = fh.Script(f"""
1410
+ window.__fhpfLeftPrefix = {json.dumps(self.left_form.base_prefix)};
1411
+ window.__fhpfRightPrefix = {json.dumps(self.right_form.base_prefix)};
1412
+ """)
1413
+
1414
+ return fh.Div(prefix_script, grid_container, cls="w-full")
1415
+
1416
+ def register_routes(self, app):
1417
+ """
1418
+ Register HTMX routes for the comparison form
1419
+
1420
+ Args:
1421
+ app: FastHTML app instance
1422
+ """
1423
+ # Register individual form routes (for list manipulation)
1424
+ self.left_form.register_routes(app)
1425
+ self.right_form.register_routes(app)
1426
+
1427
+ # Register comparison-specific reset/refresh routes
1428
+ def create_reset_handler(
1429
+ form: PydanticForm[ModelType],
1430
+ side: str,
1431
+ label: str,
1432
+ ):
1433
+ """Factory function to create reset handler with proper closure"""
1434
+
1435
+ async def handler(req):
1436
+ """Reset one side of the comparison form"""
1437
+ # Reset the form state
1438
+ await form.handle_reset_request()
1439
+
1440
+ # Render the entire column with proper ordering
1441
+ start_order = 0 if side == "left" else 1
1442
+ wrapper = self._render_column(
1443
+ form=form,
1444
+ header_label=label,
1445
+ start_order=start_order,
1446
+ wrapper_id=f"{form.name}-inputs-wrapper",
1447
+ )
1448
+ return wrapper
1449
+
1450
+ return handler
1451
+
1452
+ def create_refresh_handler(
1453
+ form: PydanticForm[ModelType],
1454
+ side: str,
1455
+ label: str,
1456
+ ):
1457
+ """Factory function to create refresh handler with proper closure"""
1458
+
1459
+ async def handler(req):
1460
+ """Refresh one side of the comparison form"""
1461
+ # Refresh the form state and capture any warnings
1462
+ refresh_result = await form.handle_refresh_request(req)
1463
+
1464
+ # Render the entire column with proper ordering
1465
+ start_order = 0 if side == "left" else 1
1466
+ wrapper = self._render_column(
1467
+ form=form,
1468
+ header_label=label,
1469
+ start_order=start_order,
1470
+ wrapper_id=f"{form.name}-inputs-wrapper",
1471
+ )
1472
+
1473
+ # If refresh returned a warning, include it in the response
1474
+ if isinstance(refresh_result, tuple) and len(refresh_result) == 2:
1475
+ alert, _ = refresh_result
1476
+ # Return both the alert and the wrapper
1477
+ return fh.Div(alert, wrapper)
1478
+ else:
1479
+ # No warning, just return the wrapper
1480
+ return wrapper
1481
+
1482
+ return handler
1483
+
1484
+ for side, form, label in [
1485
+ ("left", self.left_form, self.left_label),
1486
+ ("right", self.right_form, self.right_label),
1487
+ ]:
1488
+ assert form is not None
1489
+
1490
+ # Reset route
1491
+ reset_path = f"/compare/{self.name}/{side}/reset"
1492
+ reset_handler = create_reset_handler(form, side, label)
1493
+ app.route(reset_path, methods=["POST"])(reset_handler)
1494
+
1495
+ # Refresh route
1496
+ refresh_path = f"/compare/{self.name}/{side}/refresh"
1497
+ refresh_handler = create_refresh_handler(form, side, label)
1498
+ app.route(refresh_path, methods=["POST"])(refresh_handler)
1499
+
1500
+ # Note: Copy routes are not needed - copy is handled entirely in JavaScript
1501
+ # via window.fhpfPerformCopy() function called directly from onclick handlers
1502
+
1503
+ def form_wrapper(self, content: FT, form_id: Optional[str] = None) -> FT:
1504
+ """
1505
+ Wrap the comparison content in a form element with proper ID
1506
+
1507
+ Args:
1508
+ content: The form content to wrap
1509
+ form_id: Optional form ID (defaults to {name}-comparison-form)
1510
+
1511
+ Returns:
1512
+ A form element containing the content
1513
+ """
1514
+ form_id = form_id or f"{self.name}-comparison-form"
1515
+ wrapper_id = f"{self.name}-comparison-wrapper"
1516
+
1517
+ # Note: Removed hx_include="closest form" since the wrapper only contains foreign forms
1518
+ return mui.Form(
1519
+ fh.Div(content, id=wrapper_id),
1520
+ id=form_id,
1521
+ )
1522
+
1523
+ def _button_helper(self, *, side: str, action: str, text: str, **kwargs) -> FT:
1524
+ """
1525
+ Helper method to create buttons that target comparison-specific routes
1526
+
1527
+ Args:
1528
+ side: "left" or "right"
1529
+ action: "reset" or "refresh"
1530
+ text: Button text
1531
+ **kwargs: Additional button attributes
1532
+
1533
+ Returns:
1534
+ A button component
1535
+ """
1536
+ form = self.left_form if side == "left" else self.right_form
1537
+
1538
+ # Create prefix-based selector
1539
+ prefix_selector = f"form [name^='{form.base_prefix}']"
1540
+
1541
+ # Set default attributes
1542
+ kwargs.setdefault("hx_post", f"/compare/{self.name}/{side}/{action}")
1543
+ kwargs.setdefault("hx_target", f"#{form.name}-inputs-wrapper")
1544
+ kwargs.setdefault("hx_swap", "innerHTML")
1545
+ kwargs.setdefault("hx_include", prefix_selector)
1546
+ kwargs.setdefault("hx_preserve", "scroll")
1547
+
1548
+ # Delegate to the underlying form's button method
1549
+ button_method = getattr(form, f"{action}_button")
1550
+ return button_method(text, **kwargs)
1551
+
1552
+ def left_reset_button(self, text: Optional[str] = None, **kwargs) -> FT:
1553
+ """Create a reset button for the left form"""
1554
+ return self._button_helper(
1555
+ side="left", action="reset", text=text or "↩️ Reset Left", **kwargs
1556
+ )
1557
+
1558
+ def left_refresh_button(self, text: Optional[str] = None, **kwargs) -> FT:
1559
+ """Create a refresh button for the left form"""
1560
+ return self._button_helper(
1561
+ side="left", action="refresh", text=text or "🔄 Refresh Left", **kwargs
1562
+ )
1563
+
1564
+ def right_reset_button(self, text: Optional[str] = None, **kwargs) -> FT:
1565
+ """Create a reset button for the right form"""
1566
+ return self._button_helper(
1567
+ side="right", action="reset", text=text or "↩️ Reset Right", **kwargs
1568
+ )
1569
+
1570
+ def right_refresh_button(self, text: Optional[str] = None, **kwargs) -> FT:
1571
+ """Create a refresh button for the right form"""
1572
+ return self._button_helper(
1573
+ side="right", action="refresh", text=text or "🔄 Refresh Right", **kwargs
1574
+ )
1575
+
1576
+
1577
+ def simple_diff_metrics(
1578
+ left_data: BaseModel | Dict[str, Any],
1579
+ right_data: BaseModel | Dict[str, Any],
1580
+ model_class: Type[BaseModel],
1581
+ ) -> MetricsDict:
1582
+ """
1583
+ Simple helper to generate metrics based on equality
1584
+
1585
+ Args:
1586
+ left_data: Reference data
1587
+ right_data: Data to compare
1588
+ model_class: Model class for structure
1589
+
1590
+ Returns:
1591
+ MetricsDict with simple equality-based metrics
1592
+ """
1593
+ metrics_dict = {}
1594
+
1595
+ # Convert to dicts if needed
1596
+ if hasattr(left_data, "model_dump"):
1597
+ left_dict = left_data.model_dump()
1598
+ else:
1599
+ left_dict = left_data or {}
1600
+
1601
+ if hasattr(right_data, "model_dump"):
1602
+ right_dict = right_data.model_dump()
1603
+ else:
1604
+ right_dict = right_data or {}
1605
+
1606
+ # Compare each field
1607
+ for field_name in model_class.model_fields:
1608
+ left_val = left_dict.get(field_name)
1609
+ right_val = right_dict.get(field_name)
1610
+
1611
+ if left_val == right_val:
1612
+ metrics_dict[field_name] = MetricEntry(
1613
+ metric=1.0, color="green", comment="Values match exactly"
1614
+ )
1615
+ elif left_val is None or right_val is None:
1616
+ metrics_dict[field_name] = MetricEntry(
1617
+ metric=0.0, color="orange", comment="One value is missing"
1618
+ )
1619
+ else:
1620
+ # Try to compute similarity for strings
1621
+ if isinstance(left_val, str) and isinstance(right_val, str):
1622
+ # Simple character overlap ratio
1623
+ common = sum(1 for a, b in zip(left_val, right_val) if a == b)
1624
+ max_len = max(len(left_val), len(right_val))
1625
+ similarity = common / max_len if max_len > 0 else 0
1626
+
1627
+ metrics_dict[field_name] = MetricEntry(
1628
+ metric=round(similarity, 2),
1629
+ comment=f"String similarity: {similarity:.0%}",
1630
+ )
1631
+ else:
1632
+ metrics_dict[field_name] = MetricEntry(
1633
+ metric=0.0,
1634
+ comment=f"Different values: {left_val} vs {right_val}",
1635
+ )
1636
+
1637
+ return metrics_dict