{% 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 // Helper function to add a URL parameter addUrlParam: function(url, paramName, paramValue) { return `${url}&${paramName}=${encodeURIComponent(paramValue)}`; }, // 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(); // 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.className = element.className.replace(/\btomselected\b/g, ''); 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) { // Match patterns like "id_formset-0-field" or "id_prefix-0-field" const similarId = Array.from(this.configs.keys()).find(id => id.replace(/-\d+-/, '-X-') === selectId.replace(/-\d+-/, '-X-')); 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; // 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-" const idWithoutPrefix = selectId.replace(/^id_/, ''); const lastDashIndex = idWithoutPrefix.lastIndexOf('-'); const newFormPrefix = lastDashIndex !== -1 ? idWithoutPrefix.slice(0, lastDashIndex + 1) : ''; const formIndex = idWithoutPrefix.match(/-(\d+)-/)?.[1] || '0'; // Update the formPrefix in config config.formPrefix = newFormPrefix; // Update filter/exclude field IDs with new prefix if (config.filterFields && Array.isArray(config.filterFields)) { config.filterFields = config.filterFields.map(fieldId => { // Replace old prefix with new prefix in field ID // e.g., "id_formset-0-parent" -> "id_formset-1-parent" return fieldId.replace(/-\d+-/, `-${formIndex}-`); }); } if (config.dependentField) { config.dependentField = config.dependentField.replace(/-\d+-/, `-${formIndex}-`); } if (config.excludeFields && Array.isArray(config.excludeFields)) { config.excludeFields = config.excludeFields.map(fieldId => { return fieldId.replace(/-\d+-/, `-${formIndex}-`); }); } if (config.excludeField) { config.excludeField = config.excludeField.replace(/-\d+-/, `-${formIndex}-`); } // Rebuild originalFirstUrl with the new prefix using the stored factory function // This is critical for formsets - the closure in originalFirstUrl captures the old prefix if (config.createFirstUrlFunction && config.filterConfig) { config.originalFirstUrl = config.createFirstUrlFunction(newFormPrefix, config.filterConfig); } // If there's an original load function, wrap it to use the new reset variable // This avoids the fragile approach of string-replacing function code if (typeof config.load === 'function') { const originalLoad = config.load; const resetVarName = newResetVar; // Create a wrapper that intercepts the reset variable check config.load = function(query, callback) { // Temporarily set this instance's reset var name for any code that needs it const self = this; if (self.settings) { self.settings._currentResetVar = resetVarName; } // Call the original load function return originalLoad.call(this, query, callback); }; } 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; } // 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 %}