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.
- fh_pydantic_form/comparison_form.py +994 -5
- fh_pydantic_form/field_renderers.py +242 -55
- fh_pydantic_form/form_parser.py +82 -12
- fh_pydantic_form/form_renderer.py +99 -43
- fh_pydantic_form/type_helpers.py +22 -2
- {fh_pydantic_form-0.3.6.dist-info → fh_pydantic_form-0.3.8.dist-info}/METADATA +114 -4
- {fh_pydantic_form-0.3.6.dist-info → fh_pydantic_form-0.3.8.dist-info}/RECORD +9 -9
- {fh_pydantic_form-0.3.6.dist-info → fh_pydantic_form-0.3.8.dist-info}/WHEEL +0 -0
- {fh_pydantic_form-0.3.6.dist-info → fh_pydantic_form-0.3.8.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|
|
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
|
-
|
|
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
|