{% extends "django_tomselect/tomselect.html" %} {% comment %} This template is used to set up the global TomSelect configuration and initialization logic once per page. Blocks: - block tomselect_global_setup: Add global initialization logic, such as the MutationObserver for dynamic content. Overriding Example: To override the global setup logic, you can create a custom template in your Django app. For example, create a file named `templates/django_tomselect/tomselect_setup.html` in your app's templates directory. Then, in that file, you can override the block: {% load i18n %} {% load django_tomselect %} {% block tomselect_global_setup %} {% endblock tomselect_global_setup %} This will replace the original code block in the `tomselect_setup.html` template. {% endcomment %} {% load i18n %} {% load django_tomselect %} {% block tomselect_global_setup %} // Global namespace for django-tomselect if (!window.djangoTomSelect) { window.djangoTomSelect = window.djangoTomSelect || { configs: new Map(), instances: new Map(), initialized: false, // Track if we've already set up global handlers observer: null, // Store MutationObserver reference for cleanup htmxSwapContainers: new WeakSet(), // Containers already queued via htmx:afterSwap REINIT_DELAY: 100, // Delay for reinitialization in ms OBSERVER_DEBOUNCE: 50, // Debounce time for mutation observer // Walk back `levelsUp` formset segments from a nested prefix. // Counts `-\d+-` matches as segment boundaries (consistent with applyIndices), // so hyphenated formset names like "orders-2-line-items-3-" still truncate // correctly to "orders-2-" for levelsUp=1. // - levelsUp <= 0: returns prefix unchanged. // - levelsUp === number of segments: valid "top-level" target, returns ''. // - levelsUp > number of segments: misconfig, returns prefix unchanged // (mirrored by findSimilarConfig's sliceIndices so both sides stay in sync). truncatePrefix: function(prefix, levelsUp) { if (!levelsUp || levelsUp <= 0) return prefix; const matches = []; prefix.replace(/-\d+-/g, function(m, offset) { matches.push({ end: offset + m.length }); return m; }); if (levelsUp > matches.length) return prefix; const keepThrough = matches.length - levelsUp - 1; return keepThrough < 0 ? '' : prefix.slice(0, matches[keepThrough].end); }, // Skip observer-driven reinitialization for containers already queued by htmx:afterSwap. isInsidePendingHtmxSwap: function(node) { let current = node; while (current && current.nodeType === 1) { if (this.htmxSwapContainers.has(current)) { return true; } current = current.parentElement; } return false; }, // Deep clone config while preserving functions cloneConfig: function(config) { const cloned = {}; for (let key in config) { if (config.hasOwnProperty(key)) { if (typeof config[key] === 'function') { cloned[key] = config[key]; } else if (config[key] instanceof RegExp) { cloned[key] = new RegExp(config[key]); } else if (Array.isArray(config[key])) { cloned[key] = [...config[key]]; } else if (typeof config[key] === 'object' && config[key] !== null) { cloned[key] = this.cloneConfig(config[key]); } else { cloned[key] = config[key]; } } } return cloned; }, // Reset a TomSelect instance's state resetTomSelectState: function(instance, config) { if (!instance) return; // Clear current state instance.clear(); instance.clearOptions(); instance.clearCache(); instance.settings.pagination = {}; if (instance.dropdown_content) { instance.dropdown_content.scrollTop = 0; } // Reset URLs const urlFunction = config.originalFirstUrl || instance.settings.originalFirstUrl; if (urlFunction) { instance.settings.firstUrl = urlFunction; instance.settings.setNextUrl = urlFunction; } instance.clearPagination(); // Mark as preloaded so that onFocus -> preload() won't // fire another load() after we've already loaded fresh // data below. Without this, on the first focus the // preload-triggered load() would call getUrl(""), which // virtual_scroll routes to the stored "next page" URL // (set by checkAndLoadMore + setNextUrl when this load's // response is processed), so the dropdown ends up fetching // page 2 and replacing the fresh page-1 options. if (instance.wrapper) { instance.wrapper.classList.add('preloaded'); } // Signal that the next dropdown open should land at // scrollTop=0. The companion consumer lives in // tomselect.html's onDropdownOpen. See the matching comment // there for why this is scoped via a flag rather than // resetting unconditionally on every open. instance._scrollResetPending = true; // Reload with empty query instance.load('', () => { console.log("%c Received callback data after reset", "color:red;"); }); // Set reset flag const resetVar = config.resetVarName || instance.settings.resetVarName; if (resetVar && typeof window[resetVar] !== 'undefined') { window[resetVar] = true; } }, // Prepare the HTML element before initialization prepareElement: function(element) { // Remove any existing TomSelect wrappers const wrappers = element.parentNode.querySelectorAll('.ts-wrapper'); wrappers.forEach(wrapper => { if (wrapper.contains(element)) { wrapper.parentNode.insertBefore(element, wrapper); wrapper.remove(); } }); // Reset the select element to its original state element.classList.remove('tomselected', 'ts-hidden-accessible'); element.style.display = ''; element.removeAttribute('tabindex'); element.removeAttribute('data-ts-hidden'); }, // Set up dependent/exclude field handlers setupFieldHandlers: function(instance, config) { const hasFilterFields = config.filterFields && config.filterFields.length > 0; const hasExcludeFields = config.excludeFields && config.excludeFields.length > 0; if (!config.dependentField && !config.excludeField && !hasFilterFields && !hasExcludeFields) return; const self = this; const change_handler = function(value) { self.resetTomSelectState(instance, config); }; // Helper to set up handler for a single field ID const setupHandler = function(fieldId) { const fieldInstance = self.instances.get(fieldId); if (fieldInstance) { fieldInstance.on('change', change_handler); } }; // Set up handlers for multiple filter fields if (hasFilterFields) { config.filterFields.forEach(setupHandler); } else if (config.dependentField) { // Legacy single dependent field setupHandler(config.dependentField); } // Set up handlers for multiple exclude fields if (hasExcludeFields) { config.excludeFields.forEach(setupHandler); } else if (config.excludeField) { // Legacy single exclude field setupHandler(config.excludeField); } }, // Initialize a new TomSelect instance initialize: function(element, config) { if (!(element instanceof HTMLElement)) { console.warn('Invalid element provided to djangoTomSelect.initialize'); return null; } try { // Clean up any existing instance this.destroy(element); // Prepare the element this.prepareElement(element); // Create new instance const instance = new TomSelect(element, config); this.instances.set(element.id, instance); this.configs.set(element.id, config); // Ensure the original select element has the ts-hidden-accessible class if (!element.classList.contains('ts-hidden-accessible')) { element.classList.add('ts-hidden-accessible'); } // Set up dependent/exclude field handlers after initialization this.setupFieldHandlers(instance, config); return instance; } catch (error) { console.error('Error initializing TomSelect:', error); return null; } }, // Clean up an existing instance destroy: function(element) { if (!(element instanceof HTMLElement)) return; try { // Check for existing TomSelect instance if (element.tomselect) { element.tomselect.destroy(); } // Clean up our stored instance const instance = this.instances.get(element.id); if (instance) { instance.destroy(); this.instances.delete(element.id); this.configs.delete(element.id); } } catch (error) { console.warn('Error destroying TomSelect instance:', error); } }, // Find a similar configuration based on ID pattern findSimilarConfig: function(selectId) { // Normalize every `-\d+-` occurrence so flat AND nested formsets match. const normalizeId = (id) => id.replace(/-\d+-/g, '-X-'); // Walk each `-\d+-` in `id`, substituting the matching new index. // If `id` has MORE occurrences than `indices` provides, surplus // positions are left unchanged (the original `m` is returned). const applyIndices = (id, indices) => { let i = 0; return id.replace(/-\d+-/g, (m) => { if (i >= indices.length) return m; return `-${indices[i++]}-`; }); }; // Extract the new indices in document order. Using replace + callback // instead of matchAll keeps this inline template compatible with // older browsers (matchAll is ES2020). const newIndices = []; selectId.replace(/-(\d+)-/g, (_, n) => { newIndices.push(n); return ''; }); const targetKey = normalizeId(selectId); const similarId = Array.from(this.configs.keys()).find( (id) => normalizeId(id) === targetKey ); if (!similarId) return null; // Clone the config const config = this.cloneConfig(this.configs.get(similarId)); // Clean instance-specific settings delete config.items; delete config.renderCache; // onInitialize is template-baked from the source row's // selected_options. cloneConfig copies it by reference, so // leaving it in place would pre-select the source row's // options on the cloned row. Cloned rows must start empty. delete config.onInitialize; // Generate a new unique reset variable name for this instance const newResetVar = `wasReset_${selectId.replace(/-/g, '_')}`; window[newResetVar] = false; // Store the reset variable name in the config for the load function to use config._resetVarName = newResetVar; config.resetVarName = newResetVar; // Extract the new form prefix from the select ID // e.g., "id_formset-1-field" -> "formset-1-" // e.g., "id_orders-2-items-3-product" -> "orders-2-items-3-" const idWithoutPrefix = selectId.replace(/^id_/, ''); const lastDashIndex = idWithoutPrefix.lastIndexOf('-'); const newFormPrefix = lastDashIndex !== -1 ? idWithoutPrefix.slice(0, lastDashIndex + 1) : ''; // Update the formPrefix in config config.formPrefix = newFormPrefix; // Rewrite filter/exclude field IDs with the new indices, positionally. // When a filter declares `levelsUp > 0`, the source lives N formset // levels above the select, so we slice newIndices to drop the inner // N positions before applying. This matches truncatePrefix's behavior // on the URL-builder side, keeping handler-ID and AJAX-URL field // lookups in sync. const filterCfg = config.filterConfig; const fieldFilters = (filterCfg && Array.isArray(filterCfg.filters)) ? filterCfg.filters.filter((f) => f.sourceType === 'field') : null; const fieldExcludes = (filterCfg && Array.isArray(filterCfg.excludes)) ? filterCfg.excludes.filter((e) => e.sourceType === 'field') : null; const sliceIndices = (lu) => { if (!lu || lu <= 0) return newIndices; // Mirror truncatePrefix: misconfig (lu > depth) leaves stored ID // unchanged so both sides target the same (broken) lookup. if (lu > newIndices.length) return newIndices; // Equal-depth (lu === newIndices.length) yields []; applyIndices // then leaves any -\d+- in the stored ID untouched, matching the // empty-prefix case on the URL side. return newIndices.slice(0, newIndices.length - lu); }; if (config.filterFields && Array.isArray(config.filterFields)) { config.filterFields = config.filterFields.map((fieldId, i) => { const lu = (fieldFilters && fieldFilters[i] && fieldFilters[i].levelsUp) || 0; return applyIndices(fieldId, sliceIndices(lu)); }); } if (config.dependentField) { config.dependentField = applyIndices(config.dependentField, newIndices); } if (config.excludeFields && Array.isArray(config.excludeFields)) { config.excludeFields = config.excludeFields.map((fieldId, i) => { const lu = (fieldExcludes && fieldExcludes[i] && fieldExcludes[i].levelsUp) || 0; return applyIndices(fieldId, sliceIndices(lu)); }); } if (config.excludeField) { config.excludeField = applyIndices(config.excludeField, newIndices); } // Rebuild originalFirstUrl AND firstUrl with the new prefix using the // stored factory function. The closure in the old originalFirstUrl // captures the template row's prefix; both `firstUrl` (used by // TomSelect's getUrl on normal loads) and `originalFirstUrl` (used // on reset by resetTomSelectState and load) must point at the // new-prefix function or the cloned row will issue AJAX requests // with the wrong filter prefix. // // Re-invoke createFirstUrlFunction here (rather than reusing the // source widget's originalFirstUrl reference) so each clone also // gets its own fieldCache map. See companion comment in // tomselect.html#createFirstUrlFunction. if (config.createFirstUrlFunction && config.filterConfig) { config.originalFirstUrl = config.createFirstUrlFunction(newFormPrefix, config.filterConfig); config.firstUrl = config.originalFirstUrl; } // The cloned config's resetVarName (set above) is consumed directly by // the per-widget load/shouldLoad via this.settings.resetVarName. No // load wrapper needed; rewriting load here would just leak more closure // state from the source widget into the clone. return config; }, // Update hidden accessibility classes fixAccessibilityClasses: function(container) { container.querySelectorAll('.ts-wrapper').forEach(wrapper => { const associatedSelects = wrapper.parentNode.querySelectorAll('select[data-tomselect]'); associatedSelects.forEach(select => { if (!select.classList.contains('ts-hidden-accessible')) { select.classList.add('ts-hidden-accessible'); } }); }); }, // Reinitialize TomSelect on dynamic content reinitialize: function(container) { // Increased timeout to ensure DOM is fully rendered setTimeout(() => { try { // Find all select elements that need initialization const selects = container.querySelectorAll('select[data-tomselect]'); selects.forEach(select => { // Get config from original instance if available let config = this.configs.get(select.id); // If no stored config, try to get from a similar widget if (!config) { config = this.findSimilarConfig(select.id); } if (config && !select.tomselect) { // Only initialize if no live TomSelect instance exists. // With use_htmx=True the widget's inline script calls // initialize() synchronously during the HTMX swap, so by // the time this deferred reinitialize() fires the instance // is already live. this.initialize(select, config); } }); // Fix any remaining selects that might need the ts-hidden-accessible class this.fixAccessibilityClasses(container); } finally { this.htmxSwapContainers.delete(container); } }, this.REINIT_DELAY); }, // Create a debounced function debounce: function(fn, delay) { let timeoutId; return function(...args) { clearTimeout(timeoutId); timeoutId = setTimeout(() => fn.apply(null, args), delay); }; }, // Process mutation records to find containers needing reinitialization processObserverMutations: function(mutations) { const containersToInit = new Set(); mutations.forEach(mutation => { mutation.addedNodes.forEach(node => { if (node.nodeType === 1) { // ELEMENT_NODE if (this.isInsidePendingHtmxSwap(node)) { return; } // Check if this node contains select elements if (node.querySelector('select[data-tomselect]')) { containersToInit.add(node); } // Also check if this is a form or container that might need initialization if (node.tagName === 'FORM' || node.classList.contains('tab-pane') || node.id === 'waterEntryFormContent' || node.classList.contains('tab-content')) { containersToInit.add(node); } } }); // Also check for attribute changes that might affect TomSelect if (mutation.type === 'attributes' && mutation.target.nodeType === 1 && mutation.attributeName === 'class') { const target = mutation.target; if (this.isInsidePendingHtmxSwap(target)) { return; } if (target.hasAttribute('classList') && target.classList.contains('active') && (target.classList.contains('tab-pane') || target.parentNode.classList.contains('tab-content'))) { containersToInit.add(target); } } }); return containersToInit; }, // Setup MutationObserver for dynamic content setupObserver: function() { const self = this; // Disconnect existing observer if any (prevents memory leaks on re-setup) if (this.observer) { this.observer.disconnect(); this.observer = null; } // Observer for dynamic content this.observer = new MutationObserver( this.debounce((mutations) => { // Track containers that need reinitialization const containersToInit = self.processObserverMutations(mutations); // Process all containers that need reinitialization containersToInit.forEach(container => { self.reinitialize(container); }); }, this.OBSERVER_DEBOUNCE) ); // Start observing the DOM this.observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] }); }, // Cleanup method to disconnect observer and destroy instances // Call this when navigating away in SPA applications cleanup: function() { // Disconnect the MutationObserver if (this.observer) { this.observer.disconnect(); this.observer = null; } // Collect candidate wasReset_* global names from both Maps and // delete them from window. The wasReset_ prefix guard prevents // an overridden template or stray config from clobbering // unrelated globals if resetVarName ever holds an arbitrary key. const resetNames = new Set(); const collect = (name) => { if (typeof name === 'string' && name.indexOf('wasReset_') === 0) { resetNames.add(name); } }; const addIdFallback = (id) => { if (id != null) collect('wasReset_' + String(id).replace(/-/g, '_')); }; this.configs.forEach((cfg, id) => { if (cfg) { collect(cfg.resetVarName); collect(cfg._resetVarName); } addIdFallback(id); }); this.instances.forEach((inst, id) => { const s = inst && inst.settings; if (s) { collect(s.resetVarName); collect(s._resetVarName); } addIdFallback(id); }); resetNames.forEach((name) => { delete window[name]; }); // Destroy all TomSelect instances this.instances.forEach((instance, id) => { try { if (instance && typeof instance.destroy === 'function') { instance.destroy(); } } catch (e) { console.warn('Error destroying TomSelect instance:', e); } }); // Clear stored configs and instances this.instances.clear(); this.configs.clear(); this.initialized = false; }, // Setup HTMX event handlers setupHtmxHandlers: function() { const self = this; // Handle htmx events - this works regardless of the use_htmx setting // It will only fire if htmx is present and making content swaps if (typeof(document.addEventListener) === 'function') { document.addEventListener('htmx:afterSwap', function(event) { // Only process if htmx event has proper structure if (event && event.detail && event.detail.target) { // Get the container that was just updated by htmx let container = event.detail.target; // For outerHTML swaps, event.detail.target is the OLD // element that was replaced -- now detached from the DOM. // Fall back to finding the replacement by ID. if (!container.isConnected) { if (container.id) { container = document.getElementById(container.id); } if (!container) { container = document.body; } } self.htmxSwapContainers.add(container); self.reinitialize(container); } }); } }, // Setup SPA navigation cleanup listeners setupSpaCleanup: function() { const self = this; const cleanupHandler = function() { self.cleanup(); }; // Standard browser navigation window.addEventListener('pagehide', cleanupHandler); // Turbo (Hotwire) navigation document.addEventListener('turbo:before-visit', cleanupHandler); // Turbolinks (legacy) navigation document.addEventListener('turbolinks:before-visit', cleanupHandler); }, // Setup all global event handlers (called once) setupHandlers: function() { if (this.initialized) return; // Setup observers and event handlers this.setupObserver(); this.setupHtmxHandlers(); this.setupSpaCleanup(); this.initialized = true; } }; // Setup handlers based on context {% if not widget.use_htmx %} // For regular HTTP requests, set up handlers when DOM is loaded document.addEventListener('DOMContentLoaded', () => { window.djangoTomSelect.setupHandlers(); }); {% else %} // For htmx contexts, set up handlers immediately window.djangoTomSelect.setupHandlers(); {% endif %} } {% endblock tomselect_global_setup %}