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