fh-pydantic-form 0.3.6__py3-none-any.whl → 0.3.8__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.

@@ -5,7 +5,10 @@ This module provides a meta-renderer that displays two PydanticForm instances
5
5
  side-by-side with visual comparison feedback and synchronized accordion states.
6
6
  """
7
7
 
8
+ import json
8
9
  import logging
10
+ import re
11
+ from copy import deepcopy
9
12
  from typing import (
10
13
  Any,
11
14
  Dict,
@@ -14,6 +17,7 @@ from typing import (
14
17
  Optional,
15
18
  Type,
16
19
  TypeVar,
20
+ Union,
17
21
  )
18
22
 
19
23
  import fasthtml.common as fh
@@ -32,14 +36,847 @@ ModelType = TypeVar("ModelType", bound=BaseModel)
32
36
 
33
37
 
34
38
  def comparison_form_js():
35
- """JavaScript for comparison: sync top-level and list accordions."""
39
+ """JavaScript for comparison: sync accordions and handle JS-only copy operations."""
36
40
  return fh.Script("""
41
+ // Helper functions for list item path detection
42
+ function isListItemPath(pathPrefix) {
43
+ // Check if path contains array index pattern like [0], [1], etc.
44
+ return /\[\d+\]/.test(pathPrefix);
45
+ }
46
+
47
+ function extractListFieldPath(pathPrefix) {
48
+ // Extract the list field path without the index
49
+ // e.g., "addresses[0]" -> "addresses"
50
+ return pathPrefix.replace(/\[\d+\].*$/, '');
51
+ }
52
+
53
+ function extractListIndex(pathPrefix) {
54
+ // Extract the index from path
55
+ // e.g., "addresses[0].street" -> 0
56
+ var match = pathPrefix.match(/\[(\d+)\]/);
57
+ return match ? parseInt(match[1]) : null;
58
+ }
59
+
60
+ // Copy function - pure JS implementation
61
+ window.fhpfPerformCopy = function(pathPrefix, currentPrefix, copyTarget) {
62
+ try {
63
+ // Set flag to prevent accordion sync
64
+ window.__fhpfCopyInProgress = true;
65
+
66
+ // Save all accordion states before copy
67
+ var accordionStates = [];
68
+ document.querySelectorAll('ul[uk-accordion] > li').forEach(function(li) {
69
+ accordionStates.push({
70
+ element: li,
71
+ isOpen: li.classList.contains('uk-open')
72
+ });
73
+ });
74
+
75
+ // Determine source prefix based on copy target
76
+ var sourcePrefix = (copyTarget === 'left') ? window.__fhpfRightPrefix : window.__fhpfLeftPrefix;
77
+
78
+ // Check if this is a list item copy operation
79
+ var isListItem = isListItemPath(pathPrefix);
80
+ var listFieldPath = null;
81
+ var listIndex = null;
82
+
83
+ if (isListItem) {
84
+ listFieldPath = extractListFieldPath(pathPrefix);
85
+ listIndex = extractListIndex(pathPrefix);
86
+ }
87
+
88
+ // Special handling for list item copies: add new item instead of overwriting
89
+ if (isListItem) {
90
+ // Find target list container
91
+ var targetPrefix = (copyTarget === 'left') ? window.__fhpfLeftPrefix : window.__fhpfRightPrefix;
92
+ var targetContainerId = targetPrefix.replace(/_$/, '') + '_' + listFieldPath + '_items_container';
93
+ var targetContainer = document.getElementById(targetContainerId);
94
+
95
+ if (targetContainer) {
96
+ // Find the "Add Item" button for the target list
97
+ var targetAddButton = targetContainer.parentElement.querySelector('button[hx-post*="/list/add/"]');
98
+
99
+ if (targetAddButton) {
100
+ // Capture the target list items BEFORE adding the new one
101
+ var targetListItemsBeforeAdd = Array.from(targetContainer.querySelectorAll(':scope > li'));
102
+ var targetLengthBefore = targetListItemsBeforeAdd.length;
103
+
104
+ // Determine the target position: insert after the source item's index, or at end if target is shorter
105
+ var sourceIndex = listIndex; // The index from the source path (e.g., reviews[2] -> 2)
106
+ var insertAfterIndex = Math.min(sourceIndex, targetLengthBefore - 1);
107
+
108
+ // Get the URL from the add button
109
+ var addUrl = targetAddButton.getAttribute('hx-post');
110
+
111
+ // Determine the insertion point
112
+ var insertBeforeElement = null;
113
+ if (insertAfterIndex >= 0 && insertAfterIndex < targetLengthBefore - 1) {
114
+ // Insert after insertAfterIndex, which means before insertAfterIndex+1
115
+ insertBeforeElement = targetListItemsBeforeAdd[insertAfterIndex + 1];
116
+ } else if (targetLengthBefore > 0) {
117
+ // Insert at the end: use afterend on the last item
118
+ insertBeforeElement = targetListItemsBeforeAdd[targetLengthBefore - 1];
119
+ }
120
+
121
+ // Make the HTMX request with custom swap target
122
+ if (insertBeforeElement) {
123
+ var swapStrategy = (insertAfterIndex >= targetLengthBefore - 1) ? 'afterend' : 'beforebegin';
124
+ // Use htmx.ajax to insert at specific position
125
+ htmx.ajax('POST', addUrl, {
126
+ target: '#' + insertBeforeElement.id,
127
+ swap: swapStrategy
128
+ });
129
+ } else {
130
+ // List is empty, insert into container
131
+ htmx.ajax('POST', addUrl, {
132
+ target: '#' + targetContainerId,
133
+ swap: 'beforeend'
134
+ });
135
+ }
136
+
137
+ // Wait for HTMX to complete the swap AND settle, then copy values
138
+ var copyCompleted = false;
139
+ var htmxSettled = false;
140
+ var newlyAddedElement = null;
141
+
142
+ // Listen for HTMX afterSwap event on the container to capture the newly added element
143
+ targetContainer.addEventListener('htmx:afterSwap', function onSwap(evt) {
144
+ // Parse the response to get the new element's ID
145
+ var tempDiv = document.createElement('div');
146
+ tempDiv.innerHTML = evt.detail.xhr.response;
147
+ var newElement = tempDiv.firstElementChild;
148
+ if (newElement && newElement.id) {
149
+ newlyAddedElement = newElement;
150
+ }
151
+ }, { once: true });
152
+
153
+ // Listen for HTMX afterSettle event
154
+ document.body.addEventListener('htmx:afterSettle', function onSettle(evt) {
155
+ htmxSettled = true;
156
+ document.body.removeEventListener('htmx:afterSettle', onSettle);
157
+ }, { once: true });
158
+
159
+ var maxAttempts = 100; // 100 attempts with exponential backoff = ~10 seconds total
160
+ var attempts = 0;
161
+
162
+ var checkAndCopy = function() {
163
+ attempts++;
164
+
165
+ // Calculate delay with exponential backoff: 50ms, 50ms, 100ms, 100ms, 200ms, ...
166
+ var delay = Math.min(50 * Math.pow(2, Math.floor(attempts / 2)), 500);
167
+
168
+ // Wait for HTMX to settle before proceeding
169
+ if (!htmxSettled && attempts < maxAttempts) {
170
+ setTimeout(checkAndCopy, delay);
171
+ return;
172
+ }
173
+
174
+ // If we timed out waiting for HTMX, give up
175
+ if (!htmxSettled) {
176
+ console.error('Timeout: HTMX did not settle after ' + attempts + ' attempts');
177
+ window.__fhpfCopyInProgress = false;
178
+ return;
179
+ }
180
+
181
+ // Find the newly added item using the ID we captured
182
+ var targetItems = targetContainer.querySelectorAll(':scope > li');
183
+ var newItem = null;
184
+ var newItemIndex = -1;
185
+
186
+ if (newlyAddedElement && newlyAddedElement.id) {
187
+ // Use the ID we captured from the HTMX response
188
+ newItem = document.getElementById(newlyAddedElement.id);
189
+
190
+ if (newItem) {
191
+ // Find its position in the list
192
+ for (var i = 0; i < targetItems.length; i++) {
193
+ if (targetItems[i] === newItem) {
194
+ newItemIndex = i;
195
+ break;
196
+ }
197
+ }
198
+ }
199
+ }
200
+
201
+ // Check if new item has been added
202
+ if (newItem) {
203
+
204
+ // Wait until the new item has input fields (indicating HTMX swap is complete)
205
+ var newItemInputs = newItem.querySelectorAll('[data-field-path]');
206
+
207
+ if (newItemInputs.length > 0) {
208
+ // New item is ready, now copy values from source item
209
+ copyCompleted = true;
210
+
211
+ // The new item might not contain the textarea with placeholder!
212
+ // Search the entire target container for the newest textarea with "new_" in the name
213
+ var targetPrefix = (copyTarget === 'left') ? window.__fhpfLeftPrefix : window.__fhpfRightPrefix;
214
+ var allInputsInContainer = targetContainer.querySelectorAll('[data-field-path^="' + listFieldPath + '["]');
215
+
216
+ var firstInput = null;
217
+ var newestTimestamp = 0;
218
+
219
+ for (var i = 0; i < allInputsInContainer.length; i++) {
220
+ var inputName = allInputsInContainer[i].name || allInputsInContainer[i].id;
221
+ if (inputName && inputName.startsWith(targetPrefix.replace(/_$/, '') + '_' + listFieldPath + '_new_')) {
222
+ // Extract timestamp from name
223
+ var match = inputName.match(/new_(\d+)/);
224
+ if (match) {
225
+ var timestamp = parseInt(match[1]);
226
+ if (timestamp > newestTimestamp) {
227
+ newestTimestamp = timestamp;
228
+ firstInput = allInputsInContainer[i];
229
+ }
230
+ }
231
+ }
232
+ }
233
+
234
+ if (!firstInput) {
235
+ firstInput = newItemInputs[0];
236
+ }
237
+
238
+ var firstInputPath = firstInput.getAttribute('data-field-path');
239
+ var firstInputName = firstInput.name || firstInput.id;
240
+
241
+ // Extract placeholder from name
242
+ // Pattern: "prefix_listfield_PLACEHOLDER" or "prefix_listfield_PLACEHOLDER_fieldname"
243
+ // For simple list items: "annotated_truth_key_features_new_123"
244
+ // For BaseModel list items: "annotated_truth_reviews_new_123_rating"
245
+ // We want just the placeholder part (new_123)
246
+ var searchStr = '_' + listFieldPath + '_';
247
+ var idx = firstInputName.indexOf(searchStr);
248
+ var actualPlaceholderIdx = null;
249
+
250
+ if (idx >= 0) {
251
+ var afterListField = firstInputName.substring(idx + searchStr.length);
252
+
253
+ // For BaseModel items with nested fields, the placeholder is between listfield and the next underscore
254
+ // Check if this looks like a nested field by checking if there's another underscore after "new_"
255
+ if (afterListField.startsWith('new_')) {
256
+ // Extract just "new_TIMESTAMP" part - stop at the next underscore after the timestamp
257
+ var parts = afterListField.split('_');
258
+ if (parts.length >= 2) {
259
+ // parts[0] = "new", parts[1] = timestamp, parts[2+] = field names
260
+ actualPlaceholderIdx = parts[0] + '_' + parts[1];
261
+ } else {
262
+ actualPlaceholderIdx = afterListField;
263
+ }
264
+ } else {
265
+ // Numeric index, just use it as-is
266
+ actualPlaceholderIdx = afterListField.split('_')[0];
267
+ }
268
+ } else {
269
+ console.error('Could not find "' + searchStr + '" in name: ' + firstInputName);
270
+ window.__fhpfCopyInProgress = false;
271
+ return;
272
+ }
273
+
274
+ // Use the actual placeholder index from the name attribute
275
+ var newPathPrefix = listFieldPath + '[' + actualPlaceholderIdx + ']';
276
+
277
+ // Now perform the standard copy operation with the new path
278
+ performStandardCopy(pathPrefix, newPathPrefix, sourcePrefix, copyTarget, accordionStates);
279
+
280
+ // Wait for copy to complete, then open accordion and highlight
281
+ // Note: We skip automatic refresh because the temporary item ID doesn't persist after refresh
282
+ // User can manually click the refresh button to update counts/summaries if needed
283
+ var waitForCopyComplete = function() {
284
+ if (!window.__fhpfCopyInProgress) {
285
+ // Copy operation is complete, now open and highlight the new item
286
+ setTimeout(function() {
287
+ // Re-find the item (it might have been affected by accordion restoration)
288
+ var copiedItem = document.getElementById(newItem.id);
289
+
290
+ if (copiedItem && window.UIkit) {
291
+ // Open the newly created accordion item
292
+ if (!copiedItem.classList.contains('uk-open')) {
293
+ var accordionParent = copiedItem.parentElement;
294
+ if (accordionParent && accordionParent.hasAttribute('uk-accordion')) {
295
+ var accordionComponent = UIkit.accordion(accordionParent);
296
+ if (accordionComponent) {
297
+ var itemIndex = Array.from(accordionParent.children).indexOf(copiedItem);
298
+ accordionComponent.toggle(itemIndex, false); // false = don't animate
299
+ }
300
+ } else {
301
+ // Manual fallback
302
+ copiedItem.classList.add('uk-open');
303
+ var content = copiedItem.querySelector('.uk-accordion-content');
304
+ if (content) {
305
+ content.hidden = false;
306
+ content.style.display = '';
307
+ }
308
+ }
309
+ }
310
+
311
+ // Apply visual highlight
312
+ setTimeout(function() {
313
+ copiedItem.style.transition = 'all 0.3s ease-in-out';
314
+ copiedItem.style.backgroundColor = '#dbeafe'; // Light blue background
315
+ copiedItem.style.borderLeft = '4px solid #3b82f6'; // Blue left border
316
+ copiedItem.style.borderRadius = '4px';
317
+
318
+ // Scroll into view
319
+ copiedItem.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
320
+
321
+ // Fade out the highlight after 3 seconds
322
+ setTimeout(function() {
323
+ copiedItem.style.backgroundColor = '';
324
+ copiedItem.style.borderLeft = '';
325
+ // Remove inline styles after transition
326
+ setTimeout(function() {
327
+ copiedItem.style.transition = '';
328
+ copiedItem.style.borderRadius = '';
329
+ }, 300);
330
+ }, 3000);
331
+ }, 100);
332
+ }
333
+ }, 100);
334
+ } else {
335
+ // Still in progress, check again in 50ms
336
+ setTimeout(waitForCopyComplete, 50);
337
+ }
338
+ };
339
+
340
+ // Start checking after a small delay
341
+ setTimeout(waitForCopyComplete, 100);
342
+
343
+ } else if (attempts < maxAttempts) {
344
+ // Not ready yet, try again with exponential backoff
345
+ setTimeout(checkAndCopy, delay);
346
+ } else {
347
+ console.error('Timeout: New list item not ready after ' + attempts + ' attempts');
348
+ window.__fhpfCopyInProgress = false;
349
+ }
350
+ } else if (attempts < maxAttempts) {
351
+ // Item not added yet, try again with exponential backoff
352
+ setTimeout(checkAndCopy, delay);
353
+ } else {
354
+ console.error('Timeout: New list item not found after ' + attempts + ' attempts');
355
+ window.__fhpfCopyInProgress = false;
356
+ }
357
+ };
358
+
359
+ // Start checking after a short delay to allow HTMX to initiate
360
+ setTimeout(checkAndCopy, 200);
361
+
362
+ // Exit early - the checkAndCopy function will handle the rest
363
+ return;
364
+ } else {
365
+ console.error('Could not find Add Item button for target list');
366
+ window.__fhpfCopyInProgress = false;
367
+ return;
368
+ }
369
+ } else {
370
+ console.error('Could not find target list container');
371
+ window.__fhpfCopyInProgress = false;
372
+ return;
373
+ }
374
+ }
375
+
376
+ // Non-list-item copy: standard behavior
377
+ // (Handle full list copy with length alignment before performing copy)
378
+ (function() {
379
+ // Detect if this is a "full list copy" of a list field:
380
+ // we treat it as a list if both sides have containers like "<prefix>_<path>_items_container"
381
+ var targetPrefix = (copyTarget === 'left') ? window.__fhpfLeftPrefix : window.__fhpfRightPrefix;
382
+
383
+ var baseIdPart = pathPrefix; // e.g. "addresses" or "key_features"
384
+ var sourceContainerId = sourcePrefix.replace(/_$/, '') + '_' + baseIdPart + '_items_container';
385
+ var targetContainerId = targetPrefix.replace(/_$/, '') + '_' + baseIdPart + '_items_container';
386
+
387
+ var sourceListContainer = document.getElementById(sourceContainerId);
388
+ var targetListContainer = document.getElementById(targetContainerId);
389
+
390
+ // Only do length alignment if BOTH containers exist (i.e., this field is a list on both sides)
391
+ if (sourceListContainer && targetListContainer) {
392
+ var sourceCount = sourceListContainer.querySelectorAll(':scope > li').length;
393
+ var targetCount = targetListContainer.querySelectorAll(':scope > li').length;
394
+
395
+ // If source has more items, add missing ones BEFORE copying values (case 3)
396
+ if (sourceCount > targetCount) {
397
+ var addBtn = targetListContainer.parentElement.querySelector('button[hx-post*="/list/add/"]');
398
+ if (addBtn) {
399
+ var addUrl = addBtn.getAttribute('hx-post');
400
+ var toAdd = sourceCount - targetCount;
401
+
402
+ // Queue the required number of additions at the END
403
+ // We'll use htmx.ajax with target=container and swap=beforeend
404
+ // Then wait for HTMX to settle and for the DOM to reflect the new length.
405
+ var added = 0;
406
+ var addOne = function(cb) {
407
+ htmx.ajax('POST', addUrl, {
408
+ target: '#' + targetContainerId,
409
+ swap: 'beforeend',
410
+ values: {} // no-op
411
+ });
412
+ added += 1;
413
+ cb && cb();
414
+ };
415
+
416
+ // Fire additions synchronously; HTMX will queue them
417
+ for (var i = 0; i < toAdd; i++) addOne();
418
+
419
+ // Wait for afterSettle AND correct length, then perform the copy
420
+ var attempts = 0, maxAttempts = 120; // ~6s @ 50ms backoff
421
+ var settled = false;
422
+
423
+ // Capture settle event once
424
+ var onSettle = function onSettleOnce() {
425
+ settled = true;
426
+ document.body.removeEventListener('htmx:afterSettle', onSettleOnce);
427
+ };
428
+ document.body.addEventListener('htmx:afterSettle', onSettle);
429
+
430
+ var waitAndCopy = function() {
431
+ attempts++;
432
+ var delay = Math.min(50 * Math.pow(1.15, attempts), 250);
433
+
434
+ var currentCount = targetListContainer.querySelectorAll(':scope > li').length;
435
+ if (settled && currentCount >= sourceCount) {
436
+ // Proceed with list copy by DOM position
437
+ performListCopyByPosition(sourceListContainer, targetListContainer, sourcePrefix, copyTarget, accordionStates, pathPrefix);
438
+ return;
439
+ }
440
+ if (attempts >= maxAttempts) {
441
+ console.error('Timeout aligning list lengths for full-list copy');
442
+ // Still do a best-effort copy
443
+ performListCopyByPosition(sourceListContainer, targetListContainer, sourcePrefix, copyTarget, accordionStates, pathPrefix);
444
+ return;
445
+ }
446
+ setTimeout(waitAndCopy, delay);
447
+ };
448
+
449
+ setTimeout(waitAndCopy, 50);
450
+ return; // Defer to waitAndCopy; don't fall through
451
+ } else {
452
+ console.warn('Full-list copy: add button not found on target; proceeding without length alignment.');
453
+ }
454
+ } else {
455
+ // Source has same or fewer items - use position-based copy for lists
456
+ performListCopyByPosition(sourceListContainer, targetListContainer, sourcePrefix, copyTarget, accordionStates, pathPrefix);
457
+ return;
458
+ }
459
+ }
460
+
461
+ // Default path (non-list fields or already aligned lists)
462
+ performStandardCopy(pathPrefix, pathPrefix, sourcePrefix, copyTarget, accordionStates);
463
+ })();
464
+
465
+ } catch (e) {
466
+ window.__fhpfCopyInProgress = false;
467
+ throw e;
468
+ }
469
+ };
470
+
471
+ // Copy list items by DOM position (handles different indices in source/target)
472
+ function performListCopyByPosition(sourceListContainer, targetListContainer, sourcePrefix, copyTarget, accordionStates, listFieldPath) {
473
+ try {
474
+ var sourceItems = sourceListContainer.querySelectorAll(':scope > li');
475
+ var targetItems = targetListContainer.querySelectorAll(':scope > li');
476
+ var targetPrefix = (copyTarget === 'left') ? window.__fhpfLeftPrefix : window.__fhpfRightPrefix;
477
+
478
+ // Copy each source item to corresponding target item by position
479
+ for (var i = 0; i < sourceItems.length && i < targetItems.length; i++) {
480
+ var sourceItem = sourceItems[i];
481
+ var targetItem = targetItems[i];
482
+
483
+ // Find all inputs within this source item
484
+ var sourceInputs = sourceItem.querySelectorAll('[data-field-path]');
485
+
486
+ Array.from(sourceInputs).forEach(function(sourceInput) {
487
+ var sourceFp = sourceInput.getAttribute('data-field-path');
488
+
489
+ // Extract the field path relative to the list item
490
+ // e.g., "addresses[0].street" -> ".street"
491
+ // or "tags[0]" -> ""
492
+ var listItemPattern = new RegExp('^' + listFieldPath.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&') + '\\\\[[^\\\\]]+\\\\]');
493
+ var relativePath = sourceFp.replace(listItemPattern, '');
494
+
495
+ // Find the corresponding input in the target item by looking for the same relative path
496
+ var targetInputs = targetItem.querySelectorAll('[data-field-path]');
497
+ var targetInput = null;
498
+
499
+ for (var j = 0; j < targetInputs.length; j++) {
500
+ var targetFp = targetInputs[j].getAttribute('data-field-path');
501
+ var targetRelativePath = targetFp.replace(listItemPattern, '');
502
+
503
+ if (targetRelativePath === relativePath) {
504
+ // Verify it belongs to the target form
505
+ var candidateName = null;
506
+ if (targetInputs[j].tagName === 'UK-SELECT') {
507
+ var nativeSelect = targetInputs[j].querySelector('select');
508
+ candidateName = nativeSelect ? nativeSelect.name : null;
509
+ } else {
510
+ candidateName = targetInputs[j].name;
511
+ }
512
+
513
+ if (candidateName && !candidateName.startsWith(sourcePrefix)) {
514
+ targetInput = targetInputs[j];
515
+ break;
516
+ }
517
+ }
518
+ }
519
+
520
+ if (!targetInput) {
521
+ return;
522
+ }
523
+
524
+ // Copy the value
525
+ var tag = sourceInput.tagName.toUpperCase();
526
+ var type = (sourceInput.type || '').toLowerCase();
527
+
528
+ if (type === 'checkbox') {
529
+ targetInput.checked = sourceInput.checked;
530
+ } else if (tag === 'SELECT') {
531
+ targetInput.value = sourceInput.value;
532
+ } else if (tag === 'UK-SELECT') {
533
+ var sourceNativeSelect = sourceInput.querySelector('select');
534
+ var targetNativeSelect = targetInput.querySelector('select');
535
+ if (sourceNativeSelect && targetNativeSelect) {
536
+ var sourceValue = sourceNativeSelect.value;
537
+
538
+ // Clear all selected attributes
539
+ for (var k = 0; k < targetNativeSelect.options.length; k++) {
540
+ targetNativeSelect.options[k].removeAttribute('selected');
541
+ targetNativeSelect.options[k].selected = false;
542
+ }
543
+
544
+ // Find and set the matching option
545
+ for (var k = 0; k < targetNativeSelect.options.length; k++) {
546
+ if (targetNativeSelect.options[k].value === sourceValue) {
547
+ targetNativeSelect.options[k].setAttribute('selected', 'selected');
548
+ targetNativeSelect.options[k].selected = true;
549
+ targetNativeSelect.selectedIndex = k;
550
+ targetNativeSelect.value = sourceValue;
551
+ break;
552
+ }
553
+ }
554
+
555
+ // Update the button display
556
+ var sourceButton = sourceInput.querySelector('button');
557
+ var targetButton = targetInput.querySelector('button');
558
+ if (sourceButton && targetButton) {
559
+ targetButton.innerHTML = sourceButton.innerHTML;
560
+ }
561
+ }
562
+ } else if (tag === 'TEXTAREA') {
563
+ var valueToSet = sourceInput.value;
564
+ targetInput.value = '';
565
+ targetInput.textContent = '';
566
+ targetInput.innerHTML = '';
567
+ targetInput.value = valueToSet;
568
+ targetInput.textContent = valueToSet;
569
+ targetInput.innerHTML = valueToSet;
570
+ targetInput.setAttribute('value', valueToSet);
571
+
572
+ var inputEvent = new Event('input', { bubbles: true });
573
+ var changeEvent = new Event('change', { bubbles: true });
574
+ targetInput.dispatchEvent(inputEvent);
575
+ targetInput.dispatchEvent(changeEvent);
576
+
577
+ try {
578
+ targetInput.focus();
579
+ targetInput.blur();
580
+ } catch (e) {
581
+ // Ignore errors
582
+ }
583
+ } else {
584
+ targetInput.value = sourceInput.value;
585
+ targetInput.dispatchEvent(new Event('input', { bubbles: true }));
586
+ targetInput.dispatchEvent(new Event('change', { bubbles: true }));
587
+ }
588
+ });
589
+ }
590
+
591
+ // Remove excess items from target if source has fewer items
592
+ for (var i = targetItems.length - 1; i >= sourceItems.length; i--) {
593
+ targetItems[i].remove();
594
+ }
595
+
596
+ // Restore accordion states
597
+ setTimeout(function() {
598
+ accordionStates.forEach(function(state) {
599
+ if (state.isOpen && !state.element.classList.contains('uk-open')) {
600
+ var accordionParent = state.element.parentElement;
601
+ if (accordionParent && window.UIkit) {
602
+ var accordionComponent = UIkit.accordion(accordionParent);
603
+ if (accordionComponent) {
604
+ var itemIndex = Array.from(accordionParent.children).indexOf(state.element);
605
+ accordionComponent.toggle(itemIndex, true);
606
+ } else {
607
+ state.element.classList.add('uk-open');
608
+ var content = state.element.querySelector('.uk-accordion-content');
609
+ if (content) {
610
+ content.hidden = false;
611
+ content.style.height = 'auto';
612
+ }
613
+ }
614
+ }
615
+ }
616
+ });
617
+
618
+ window.__fhpfCopyInProgress = false;
619
+
620
+ // Trigger a refresh on the target list field to update counts and titles
621
+ // Find the refresh button for the target list field
622
+ var targetListFieldWrapper = targetListContainer.closest('[data-path]');
623
+ if (targetListFieldWrapper) {
624
+ var refreshButton = targetListFieldWrapper.querySelector('button[hx-post*="/refresh"]');
625
+ if (refreshButton && window.htmx) {
626
+ // Trigger the HTMX refresh
627
+ htmx.trigger(refreshButton, 'click');
628
+ }
629
+ }
630
+ }, 150);
631
+
632
+ } catch (e) {
633
+ window.__fhpfCopyInProgress = false;
634
+ throw e;
635
+ }
636
+ }
637
+
638
+ // Extracted standard copy logic to allow reuse
639
+ function performStandardCopy(sourcePathPrefix, targetPathPrefix, sourcePrefix, copyTarget, accordionStates) {
640
+ try {
641
+ // Find all inputs with matching data-field-path from source
642
+ var allInputs = document.querySelectorAll('[data-field-path]');
643
+ var sourceInputs = Array.from(allInputs).filter(function(el) {
644
+ var fp = el.getAttribute('data-field-path');
645
+ if (!(fp === sourcePathPrefix || fp.startsWith(sourcePathPrefix + '.') || fp.startsWith(sourcePathPrefix + '['))) {
646
+ return false;
647
+ }
648
+
649
+ // Check if this element belongs to the source form
650
+ var elementName = null;
651
+ if (el.tagName === 'UK-SELECT') {
652
+ var nativeSelect = el.querySelector('select');
653
+ elementName = nativeSelect ? nativeSelect.name : null;
654
+ } else {
655
+ elementName = el.name;
656
+ }
657
+
658
+ return elementName && elementName.startsWith(sourcePrefix);
659
+ });
660
+
661
+ // Track updated selects to fire change events later
662
+ var updatedSelects = [];
663
+
664
+ var copiedCount = 0;
665
+ sourceInputs.forEach(function(sourceInput) {
666
+ var sourceFp = sourceInput.getAttribute('data-field-path');
667
+
668
+ // Map source field path to target field path
669
+ // If sourcePathPrefix != targetPathPrefix (list item case), we need to remap
670
+ var targetFp = sourceFp;
671
+ if (sourcePathPrefix !== targetPathPrefix) {
672
+ // Replace the source path prefix with target path prefix
673
+ if (sourceFp === sourcePathPrefix) {
674
+ targetFp = targetPathPrefix;
675
+ } else if (sourceFp.startsWith(sourcePathPrefix + '.')) {
676
+ targetFp = targetPathPrefix + sourceFp.substring(sourcePathPrefix.length);
677
+ } else if (sourceFp.startsWith(sourcePathPrefix + '[')) {
678
+ targetFp = targetPathPrefix + sourceFp.substring(sourcePathPrefix.length);
679
+ }
680
+ }
681
+
682
+ // Find target by data-field-path, then verify it's NOT from the source form
683
+ var candidates = document.querySelectorAll('[data-field-path="' + targetFp + '"]');
684
+ var targetInput = null;
685
+ for (var i = 0; i < candidates.length; i++) {
686
+ var candidate = candidates[i];
687
+ var candidateName = null;
688
+ if (candidate.tagName === 'UK-SELECT') {
689
+ var nativeSelect = candidate.querySelector('select');
690
+ candidateName = nativeSelect ? nativeSelect.name : null;
691
+ } else {
692
+ candidateName = candidate.name;
693
+ }
694
+ if (candidateName && !candidateName.startsWith(sourcePrefix)) {
695
+ targetInput = candidate;
696
+ break;
697
+ }
698
+ }
699
+
700
+ if (!targetInput) {
701
+ return;
702
+ }
703
+
704
+ var tag = sourceInput.tagName.toUpperCase();
705
+ var type = (sourceInput.type || '').toLowerCase();
706
+
707
+ if (type === 'checkbox') {
708
+ targetInput.checked = sourceInput.checked;
709
+ } else if (tag === 'SELECT') {
710
+ targetInput.value = sourceInput.value;
711
+ updatedSelects.push(targetInput);
712
+ } else if (tag === 'UK-SELECT') {
713
+ var sourceNativeSelect = sourceInput.querySelector('select');
714
+ var targetNativeSelect = targetInput.querySelector('select');
715
+ if (sourceNativeSelect && targetNativeSelect) {
716
+ var sourceValue = sourceNativeSelect.value;
717
+
718
+ // First, clear all selected attributes
719
+ for (var i = 0; i < targetNativeSelect.options.length; i++) {
720
+ targetNativeSelect.options[i].removeAttribute('selected');
721
+ targetNativeSelect.options[i].selected = false;
722
+ }
723
+
724
+ // Find and set the matching option
725
+ for (var i = 0; i < targetNativeSelect.options.length; i++) {
726
+ if (targetNativeSelect.options[i].value === sourceValue) {
727
+ targetNativeSelect.options[i].setAttribute('selected', 'selected');
728
+ targetNativeSelect.options[i].selected = true;
729
+ targetNativeSelect.selectedIndex = i;
730
+ targetNativeSelect.value = sourceValue;
731
+ break;
732
+ }
733
+ }
734
+
735
+ // Update the button display
736
+ var sourceButton = sourceInput.querySelector('button');
737
+ var targetButton = targetInput.querySelector('button');
738
+ if (sourceButton && targetButton) {
739
+ targetButton.innerHTML = sourceButton.innerHTML;
740
+ }
741
+
742
+ // Track this select for later event firing
743
+ updatedSelects.push(targetNativeSelect);
744
+ }
745
+ } else if (tag === 'TEXTAREA') {
746
+ // Set value multiple ways to ensure it sticks
747
+ var valueToSet = sourceInput.value;
748
+
749
+ // First, completely clear the textarea
750
+ targetInput.value = '';
751
+ targetInput.textContent = '';
752
+ targetInput.innerHTML = '';
753
+
754
+ // Then set the new value
755
+ // Method 1: Set value property
756
+ targetInput.value = valueToSet;
757
+
758
+ // Method 2: Set textContent
759
+ targetInput.textContent = valueToSet;
760
+
761
+ // Method 3: Set innerHTML
762
+ targetInput.innerHTML = valueToSet;
763
+
764
+ // Method 4: Use setAttribute
765
+ targetInput.setAttribute('value', valueToSet);
766
+
767
+ // Trigger input and change events to notify any UI components
768
+ var inputEvent = new Event('input', { bubbles: true });
769
+ var changeEvent = new Event('change', { bubbles: true });
770
+ targetInput.dispatchEvent(inputEvent);
771
+ targetInput.dispatchEvent(changeEvent);
772
+
773
+ // Force browser to re-render by triggering focus events
774
+ try {
775
+ targetInput.focus();
776
+ targetInput.blur();
777
+ } catch (e) {
778
+ // Ignore errors if focus/blur not supported
779
+ }
780
+
781
+ copiedCount++;
782
+ } else {
783
+ targetInput.value = sourceInput.value;
784
+ // Trigger events for any UI framework listening
785
+ targetInput.dispatchEvent(new Event('input', { bubbles: true }));
786
+ targetInput.dispatchEvent(new Event('change', { bubbles: true }));
787
+ copiedCount++;
788
+ }
789
+ });
790
+
791
+ // Handle list cleanup - remove excess items from target list
792
+ // Only do this when copying a whole list field (not individual items)
793
+ // Check if this is a list field by looking for a list container
794
+ if (sourcePathPrefix && !sourcePathPrefix.includes('[') && sourcePathPrefix === targetPathPrefix) {
795
+ // This is a top-level field (not a list item), check if it's a list field
796
+ // Try to find list containers for both source and target
797
+ var targetPrefix = (copyTarget === 'left') ? window.__fhpfLeftPrefix : window.__fhpfRightPrefix;
798
+
799
+ // Build container ID patterns - handle both with and without trailing underscore
800
+ var sourceContainerIdPattern = sourcePrefix.replace(/_$/, '') + '_' + sourcePathPrefix + '_items_container';
801
+ var targetContainerIdPattern = targetPrefix.replace(/_$/, '') + '_' + targetPathPrefix + '_items_container';
802
+
803
+ var sourceListContainer = document.getElementById(sourceContainerIdPattern);
804
+ var targetListContainer = document.getElementById(targetContainerIdPattern);
805
+
806
+ if (sourceListContainer && targetListContainer) {
807
+ // Both containers exist, this is a list field
808
+ // Count list items in source and target
809
+ var sourceItemCount = sourceListContainer.querySelectorAll(':scope > li').length;
810
+ var targetItems = targetListContainer.querySelectorAll(':scope > li');
811
+
812
+ // Remove excess items from target (from end backwards)
813
+ for (var i = targetItems.length - 1; i >= sourceItemCount; i--) {
814
+ targetItems[i].remove();
815
+ }
816
+ }
817
+ }
818
+
819
+ // Restore accordion states after a brief delay
820
+ setTimeout(function() {
821
+ accordionStates.forEach(function(state) {
822
+ if (state.isOpen && !state.element.classList.contains('uk-open')) {
823
+ // Use UIkit's toggle API to properly open the accordion
824
+ var accordionParent = state.element.parentElement;
825
+ if (accordionParent && window.UIkit) {
826
+ var accordionComponent = UIkit.accordion(accordionParent);
827
+ if (accordionComponent) {
828
+ var itemIndex = Array.from(accordionParent.children).indexOf(state.element);
829
+ accordionComponent.toggle(itemIndex, true);
830
+ } else {
831
+ // Fallback to manual class manipulation
832
+ state.element.classList.add('uk-open');
833
+ var content = state.element.querySelector('.uk-accordion-content');
834
+ if (content) {
835
+ content.hidden = false;
836
+ content.style.height = 'auto';
837
+ }
838
+ }
839
+ }
840
+ }
841
+ });
842
+
843
+ window.__fhpfCopyInProgress = false;
844
+
845
+ // Fire change events on updated selects AFTER accordion restoration
846
+ setTimeout(function() {
847
+ updatedSelects.forEach(function(select) {
848
+ select.dispatchEvent(new Event('change', { bubbles: true }));
849
+ });
850
+ }, 50);
851
+ }, 150);
852
+
853
+ } catch (e) {
854
+ window.__fhpfCopyInProgress = false;
855
+ throw e;
856
+ }
857
+ }
858
+
37
859
  window.fhpfInitComparisonSync = function initComparisonSync(){
38
860
  // 1) Wait until UIkit and its util are available
39
861
  if (!window.UIkit || !UIkit.util) {
40
862
  return setTimeout(initComparisonSync, 50);
41
863
  }
42
864
 
865
+ // Fix native select name attributes (MonsterUI puts name on uk-select, not native select)
866
+ // IMPORTANT: Remove name from uk-select to avoid duplicate form submission
867
+ document.querySelectorAll('uk-select[name]').forEach(function(ukSelect) {
868
+ var nativeSelect = ukSelect.querySelector('select');
869
+ if (nativeSelect) {
870
+ var ukSelectName = ukSelect.getAttribute('name');
871
+ if (!nativeSelect.name && ukSelectName) {
872
+ nativeSelect.name = ukSelectName;
873
+ // Remove name from uk-select to prevent duplicate submission
874
+ ukSelect.removeAttribute('name');
875
+ }
876
+ }
877
+ });
878
+
879
+
43
880
  // 2) Sync top-level accordions (BaseModelFieldRenderer)
44
881
  UIkit.util.on(
45
882
  document,
@@ -52,6 +889,11 @@ window.fhpfInitComparisonSync = function initComparisonSync(){
52
889
  const sourceLi = ev.target.closest('li');
53
890
  if (!sourceLi) return;
54
891
 
892
+ // Skip if copy operation is in progress
893
+ if (window.__fhpfCopyInProgress) {
894
+ return;
895
+ }
896
+
55
897
  // Skip if this event is from a select/dropdown element
56
898
  if (ev.target.closest('uk-select, select, [uk-select]')) {
57
899
  return;
@@ -112,12 +954,17 @@ window.fhpfInitComparisonSync = function initComparisonSync(){
112
954
  function mirrorNestedListItems(ev) {
113
955
  const sourceLi = ev.target.closest('li');
114
956
  if (!sourceLi) return;
115
-
957
+
958
+ // Skip if copy operation is in progress
959
+ if (window.__fhpfCopyInProgress) {
960
+ return;
961
+ }
962
+
116
963
  // Skip if this event is from a select/dropdown element
117
964
  if (ev.target.closest('uk-select, select, [uk-select]')) {
118
965
  return;
119
966
  }
120
-
967
+
121
968
  // Skip if this event was triggered by our own sync
122
969
  if (sourceLi.dataset.syncDisabled) {
123
970
  return;
@@ -231,7 +1078,6 @@ window.fhpfInitComparisonSync();
231
1078
 
232
1079
  // Re-run after HTMX swaps to maintain sync
233
1080
  document.addEventListener('htmx:afterSwap', function(event) {
234
- // Re-initialize the comparison sync
235
1081
  window.fhpfInitComparisonSync();
236
1082
  });
237
1083
  """)
@@ -256,6 +1102,8 @@ class ComparisonForm(Generic[ModelType]):
256
1102
  *,
257
1103
  left_label: str = "Reference",
258
1104
  right_label: str = "Generated",
1105
+ copy_left: bool = False,
1106
+ copy_right: bool = False,
259
1107
  ):
260
1108
  """
261
1109
  Initialize the comparison form
@@ -266,6 +1114,8 @@ class ComparisonForm(Generic[ModelType]):
266
1114
  right_form: Pre-constructed PydanticForm for right column
267
1115
  left_label: Label for left column
268
1116
  right_label: Label for right column
1117
+ copy_left: If True, show copy buttons in right column to copy to left
1118
+ copy_right: If True, show copy buttons in left column to copy to right
269
1119
 
270
1120
  Raises:
271
1121
  ValueError: If the two forms are not based on the same model class
@@ -283,6 +1133,8 @@ class ComparisonForm(Generic[ModelType]):
283
1133
  self.model_class = left_form.model_class # Convenience reference
284
1134
  self.left_label = left_label
285
1135
  self.right_label = right_label
1136
+ self.copy_left = copy_left
1137
+ self.copy_right = copy_right
286
1138
 
287
1139
  # Use spacing from left form (or could add override parameter if needed)
288
1140
  self.spacing = left_form.spacing
@@ -291,6 +1143,103 @@ class ComparisonForm(Generic[ModelType]):
291
1143
  """Convert field path list to dot-notation string for comparison lookup"""
292
1144
  return ".".join(field_path)
293
1145
 
1146
+ def _split_path(self, path: str) -> List[Union[str, int]]:
1147
+ """
1148
+ Split a dot/bracket path string into segments.
1149
+
1150
+ Examples:
1151
+ "author.name" -> ["author", "name"]
1152
+ "addresses[0].street" -> ["addresses", 0, "street"]
1153
+ "experience[2].company" -> ["experience", 2, "company"]
1154
+
1155
+ Args:
1156
+ path: Dot/bracket notation path string
1157
+
1158
+ Returns:
1159
+ List of path segments (strings and ints)
1160
+ """
1161
+ _INDEX = re.compile(r"(.+?)\[(\d+)\]$")
1162
+ parts: List[Union[str, int]] = []
1163
+
1164
+ for segment in path.split("."):
1165
+ m = _INDEX.match(segment)
1166
+ if m:
1167
+ # Segment has bracket notation like "name[3]"
1168
+ parts.append(m.group(1))
1169
+ parts.append(int(m.group(2)))
1170
+ else:
1171
+ parts.append(segment)
1172
+
1173
+ return parts
1174
+
1175
+ def _get_by_path(self, data: Dict[str, Any], path: str) -> tuple[bool, Any]:
1176
+ """
1177
+ Get a value from nested dict/list structure by path.
1178
+
1179
+ Args:
1180
+ data: The data structure to traverse
1181
+ path: Dot/bracket notation path string
1182
+
1183
+ Returns:
1184
+ Tuple of (found, value) where found is True if path exists, False otherwise
1185
+ """
1186
+ cur = data
1187
+ for seg in self._split_path(path):
1188
+ if isinstance(seg, int):
1189
+ if not isinstance(cur, list) or seg >= len(cur):
1190
+ return (False, None)
1191
+ cur = cur[seg]
1192
+ else:
1193
+ if not isinstance(cur, dict) or seg not in cur:
1194
+ return (False, None)
1195
+ cur = cur[seg]
1196
+ return (True, deepcopy(cur))
1197
+
1198
+ def _set_by_path(self, data: Dict[str, Any], path: str, value: Any) -> None:
1199
+ """
1200
+ Set a value in nested dict/list structure by path, creating intermediates.
1201
+
1202
+ Args:
1203
+ data: The data structure to modify
1204
+ path: Dot/bracket notation path string
1205
+ value: The value to set
1206
+ """
1207
+ cur = data
1208
+ parts = self._split_path(path)
1209
+
1210
+ for i, seg in enumerate(parts):
1211
+ is_last = i == len(parts) - 1
1212
+
1213
+ if is_last:
1214
+ # Set the final value
1215
+ if isinstance(seg, int):
1216
+ if not isinstance(cur, list):
1217
+ raise ValueError("Cannot set list index on non-list parent")
1218
+ # Extend list if needed
1219
+ while len(cur) <= seg:
1220
+ cur.append(None)
1221
+ cur[seg] = deepcopy(value)
1222
+ else:
1223
+ if not isinstance(cur, dict):
1224
+ raise ValueError("Cannot set dict key on non-dict parent")
1225
+ cur[seg] = deepcopy(value)
1226
+ else:
1227
+ # Navigate or create intermediate containers
1228
+ nxt = parts[i + 1]
1229
+
1230
+ if isinstance(seg, int):
1231
+ if not isinstance(cur, list):
1232
+ raise ValueError("Non-list where list expected")
1233
+ # Extend list if needed
1234
+ while len(cur) <= seg:
1235
+ cur.append({} if isinstance(nxt, str) else [])
1236
+ cur = cur[seg]
1237
+ else:
1238
+ if seg not in cur or not isinstance(cur[seg], (dict, list)):
1239
+ # Create appropriate container type
1240
+ cur[seg] = {} if isinstance(nxt, str) else []
1241
+ cur = cur[seg]
1242
+
294
1243
  def _render_column(
295
1244
  self,
296
1245
  *,
@@ -354,6 +1303,34 @@ class ComparisonForm(Generic[ModelType]):
354
1303
  else None
355
1304
  )
356
1305
 
1306
+ # Determine comparison copy settings
1307
+ # Show copy buttons on the SOURCE form (the form you're copying FROM)
1308
+ is_left_column = form is self.left_form
1309
+
1310
+ # If copy_left is enabled, show button on RIGHT form to copy TO left
1311
+ # If copy_right is enabled, show button on LEFT form to copy TO right
1312
+ if is_left_column:
1313
+ # This is the left form
1314
+ # Show copy button if we want to copy TO the right
1315
+ copy_feature_enabled = self.copy_right
1316
+ comparison_copy_target = "right" if copy_feature_enabled else None
1317
+ target_form = self.right_form
1318
+ else:
1319
+ # This is the right form
1320
+ # Show copy button if we want to copy TO the left
1321
+ copy_feature_enabled = self.copy_left
1322
+ comparison_copy_target = "left" if copy_feature_enabled else None
1323
+ target_form = self.left_form
1324
+
1325
+ # Enable copy button if:
1326
+ # 1. The feature is enabled (copy_left or copy_right)
1327
+ # 2. The TARGET form is NOT disabled (you can't copy into a disabled/read-only form)
1328
+ comparison_copy_enabled = (
1329
+ copy_feature_enabled and not target_form.disabled
1330
+ if target_form
1331
+ else False
1332
+ )
1333
+
357
1334
  # Create renderer
358
1335
  renderer = renderer_cls(
359
1336
  field_name=field_name,
@@ -367,6 +1344,9 @@ class ComparisonForm(Generic[ModelType]):
367
1344
  label_color=label_color, # Pass the label color if specified
368
1345
  metrics_dict=form.metrics_dict, # Use form's own metrics
369
1346
  refresh_endpoint_override=comparison_refresh, # Pass comparison-specific refresh endpoint
1347
+ comparison_copy_enabled=comparison_copy_enabled,
1348
+ comparison_copy_target=comparison_copy_target,
1349
+ comparison_name=self.name,
370
1350
  )
371
1351
 
372
1352
  # Render with data-path and order
@@ -414,7 +1394,13 @@ class ComparisonForm(Generic[ModelType]):
414
1394
  id=f"{self.name}-comparison-grid",
415
1395
  )
416
1396
 
417
- return fh.Div(grid_container, cls="w-full")
1397
+ # Emit prefix globals for the copy registry
1398
+ prefix_script = fh.Script(f"""
1399
+ window.__fhpfLeftPrefix = {json.dumps(self.left_form.base_prefix)};
1400
+ window.__fhpfRightPrefix = {json.dumps(self.right_form.base_prefix)};
1401
+ """)
1402
+
1403
+ return fh.Div(prefix_script, grid_container, cls="w-full")
418
1404
 
419
1405
  def register_routes(self, app):
420
1406
  """
@@ -500,6 +1486,9 @@ class ComparisonForm(Generic[ModelType]):
500
1486
  refresh_handler = create_refresh_handler(form, side, label)
501
1487
  app.route(refresh_path, methods=["POST"])(refresh_handler)
502
1488
 
1489
+ # Note: Copy routes are not needed - copy is handled entirely in JavaScript
1490
+ # via window.fhpfPerformCopy() function called directly from onclick handlers
1491
+
503
1492
  def form_wrapper(self, content: FT, form_id: Optional[str] = None) -> FT:
504
1493
  """
505
1494
  Wrap the comparison content in a form element with proper ID