First - great project, wonderful documentation!
I am using dal with HTMX to create an autocomplete field in a modal window. (HTMX loads the modal window from a different View/url and appends it to the current page.) When I visit the HTMX URL directly, everything works fine. But, when I use HTMX to append that same working page to my existing page's DOM, the dropdown menu isn't initializing correctly.
I see a bunch of stuff on Stack Overflow that references this kind of issue, but it's always (?) caused by forgetting lo load Javascript or form tags. I also see #1270 which is the only other reference to HTMX, but this seems to be a bit different than both of those.
I have a feeling that stuff is getting loaded in the wrong order - that is, HTMX dumps the scripts into the DOM, and they execute on the page before the form has a chance to initialize. I believe this because the form works the second time it's loaded, or the third time if I mess with the order the libraries are loaded in. I am new to HTMX, so it's possible the PEBKAC.
I was able to hack together a very ugly but functioning solution as follows. In the template that my HTMX view returns, I added jquery:
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.1/jquery.min.js"></script>
{{ form.media }}
{% load crispy_forms_tags %}
... some generic html here ...
<p>{% crispy form form.helper %}</p>
Then, I hacked autocomplete_light.js to split the load event listener into a separate function so I could call it manually:
window.addEventListener("load", function () {
console.log('This is never called.');
dal_init();
});
function dal_init() {
console.log('This gets called twice, I am not sure why.');
// Check if `django.jQuery` exists otherwise set `django.jQuery` to non namespaced jQuery.
window.django = window.django || {};
if (!django.hasOwnProperty('jQuery') && jQuery !== 'undefined') {
django.jQuery = jQuery;
}
...the rest of this is unchanged except for the ) removed at the end of what used to be window.addEventListener(
Finally, I added this snippet to autocomplete_light.js to call my newly created function:
$( document ).ready(function() {
dal_init();
});
This works, every time, as far as I can tell. I have already invested too much time in this to spend more time figuring out the root cause and putting together a PR, but maybe this will help someone else who is using this with HTMX (most likely me after I upgrade DAL in a year or two and my hacks break).
Here is my complete autocomplete_light.js file:
/*!
* Django Autocomplete Light
*/
var yl = yl || {};
yl.functions = yl.functions || {};
/**
* Register your own JS function for DAL.
*
* @param name The name of your function. This should be the same as the widget
* `autocomplete_function` property value.
* @param func The callback that will initialize your custom autocomplete.
*/
yl.registerFunction = function (name, func) {
if (this.functions.hasOwnProperty(name)) {
// This function already exists to show an error and skip.
console.error('The DAL function "' + name + '" has already been registered.');
return
}
if (typeof func != 'function') {
// It's not a function kill it.
throw new Error('The custom DAL function must be a function.');
}
this.functions[name] = func;
var event = new CustomEvent('dal-function-registered.' + name, {detail: {name: name, func: func}})
window.dispatchEvent(event);
};
window.addEventListener("load", function () {
console.log('This is never called.');
dal_init();
});
function dal_init() {
console.log('This gets called twice, I am not sure why.');
// Check if `django.jQuery` exists otherwise set `django.jQuery` to non namespaced jQuery.
window.django = window.django || {};
if (!django.hasOwnProperty('jQuery') && jQuery !== 'undefined') {
django.jQuery = jQuery;
}
(function ($) {
$.fn.getFormPrefix = function () {
/* Get the form prefix for a field.
*
* For example:
*
* $(':input[name$=owner]').getFormsetPrefix()
*
* Would return an empty string for an input with name 'owner' but would return
* 'inline_model-0-' for an input named 'inline_model-0-owner'.
*/
var parts = $(this).attr('name').split('-');
var prefix = '';
for (var i in parts) {
var testPrefix = parts.slice(0, -i).join('-');
if (!testPrefix.length) continue;
testPrefix += '-';
var result = $(':input[name^=' + testPrefix + ']')
if (result.length) {
return testPrefix;
}
}
return '';
}
$.fn.getFormPrefixes = function () {
/*
* Get the form prefixes for a field, from the most specific to the least.
*
* For example:
*
* $(':input[name$=owner]').getFormPrefixes()
*
* Would return:
* - [''] for an input named 'owner'.
* - ['inline_model-0-', ''] for an input named 'inline_model-0-owner' (i.e. nested with a nested inline).
* - ['sections-0-items-0-', 'sections-0-', ''] for an input named 'sections-0-items-0-product'
* (i.e. nested multiple time with django-nested-admin).
*/
var parts = $(this).attr('name').split('-').slice(0, -1);
var prefixes = [];
for (i = 0; i < parts.length; i += 2) {
var testPrefix = parts.slice(0, -i || parts.length).join('-');
if (!testPrefix.length)
continue;
testPrefix += '-';
var result = $(':input[name^=' + testPrefix + ']')
if (result.length)
prefixes.push(testPrefix);
}
prefixes.push('');
return prefixes;
}
/*
* This ensures the Language file is loaded and passes it our jQuery.
*/
if (typeof dalLoadLanguage !== 'undefined') {
dalLoadLanguage($);
} else {
document.addEventListener('dal-language-loaded', function (e) {
// `e.lang` is the language that was loaded.
dalLoadLanguage($);
})
}
// Fire init event for yl.registerFunction() execution.
var event = new CustomEvent('dal-init-function');
document.dispatchEvent(event);
var initialized = [];
$.fn.excludeTemplateForms = function() {
// exclude elements that contain '__prefix__' in their id
// these are used by django formsets for template forms
return this.not('[id*=__prefix__]').filter(function() {
// exclude elements that contain '-empty-' in their ids
// these are used by django-nested-admin for nested template formsets
// note that the filter also ensures that 'empty' is not actually the related_name for some relation
// by ensuring that it is not surrounded by numbers on both sides
return !this.id.match(/-empty-/) || this.id.match(/-\d+-empty-\d+-/);
});
}
/**
* Initialize a field element. This function calls the registered init function
* and ensures that the element is only initialized once.
*
* @param element The field to be initialized
*/
function initialize(element) {
if (typeof element === 'undefined' || typeof element === 'number') {
element = this;
}
// Ensure element is not already initialized.
if (initialized.indexOf(element) >= 0) {
return;
}
// The DAL function to execute.
var dalFunction = $(element).attr('data-autocomplete-light-function');
if (yl.functions.hasOwnProperty(dalFunction) && typeof yl.functions[dalFunction] == 'function') {
// If the function has been registered call it.
yl.functions[dalFunction]($, element);
} else if (yl.functions.hasOwnProperty(dalFunction)) {
// If the function exists but has not been registered wait for it to be registered.
window.addEventListener('dal-function-registered.' + dalFunction, function (e) {
yl.functions[dalFunction]($, element);
})
} else {
// Otherwise notify that the function should be registered.
console.warn('Your custom DAL function "' + dalFunction + '" uses a deprecated event listener that will be removed in future versions. https://django-autocomplete-light.readthedocs.io/en/master/tutorial.html#overriding-javascript-code')
}
// Fire init event for custom function execution.
// DEPRECATED
$(element).trigger('autocompleteLightInitialize');
// Add element to the array of already initialized fields
initialized.push(element);
// creates and dispatches the event to notify of the initialization completed
var dalElementInitializedEvent = new CustomEvent("dal-element-initialized", {
detail: {
element: element,
}
});
document.dispatchEvent(dalElementInitializedEvent);
}
if (!window.__dal__initialize) {
window.__dal__initialize = initialize;
$(document).ready(function () {
$('[data-autocomplete-light-function]').excludeTemplateForms().each(initialize);
});
if ('MutationObserver' in window) {
new MutationObserver(function (mutations) {
var mutationRecord;
var addedNode;
for (var i = 0; i < mutations.length; i++) {
mutationRecord = mutations[i];
if (mutationRecord.addedNodes.length > 0) {
for (var j = 0; j < mutationRecord.addedNodes.length; j++) {
addedNode = mutationRecord.addedNodes[j];
$(addedNode).find('[data-autocomplete-light-function]').excludeTemplateForms().each(initialize);
}
}
}
}).observe(document.documentElement, {childList: true, subtree: true});
} else {
$(document).on('DOMNodeInserted', function (e) {
$(e.target).find('[data-autocomplete-light-function]').excludeTemplateForms().each(initialize);
});
}
}
// using jQuery
function getCookie(name) {
var cookieValue = null;
if (document.cookie && document.cookie != '') {
var cookies = document.cookie.split(';');
for (var i = 0; i < cookies.length; i++) {
var cookie = $.trim(cookies[i]);
// Does this cookie string begin with the name we want?
if (cookie.substring(0, name.length + 1) == (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
document.csrftoken = getCookie('csrftoken');
if (document.csrftoken === null) {
// Try to get CSRF token from DOM when cookie is missing
var $csrf = $('form :input[name="csrfmiddlewaretoken"]');
if ($csrf.length > 0) {
document.csrftoken = $csrf[0].value;
}
}
})(django.jQuery);
// Does the same thing as django's admin/js/autocomplete.js, but uses yl.jQuery.
(function ($) {
'use strict';
var init = function ($element, options) {
var settings = $.extend({
ajax: {
data: function (params) {
return {
term: params.term,
page: params.page,
app_label: $element.data('app-label'),
model_name: $element.data('model-name'),
field_name: $element.data('field-name')
};
}
}
}, options);
$element.select2(settings);
};
$.fn.djangoAdminSelect2 = function (options) {
var settings = $.extend({}, options);
$.each(this, function (i, element) {
var $element = $(element);
init($element, settings);
});
return this;
};
$(function () {
// Initialize all autocomplete widgets except the one in the template
// form used when a new formset is added.
$('.admin-autocomplete').not('[name*=__prefix__]').djangoAdminSelect2();
});
$(document).on('formset:added', (function () {
return function (event, $newFormset) {
return $newFormset.find('.admin-autocomplete').djangoAdminSelect2();
};
})(this));
}(django.jQuery));
(function ($, yl) {
yl.forwardHandlerRegistry = yl.forwardHandlerRegistry || {};
yl.registerForwardHandler = function (name, handler) {
yl.forwardHandlerRegistry[name] = handler;
};
yl.getForwardHandler = function (name) {
return yl.forwardHandlerRegistry[name];
};
function getForwardStrategy(element) {
var checkForCheckboxes = function () {
var all = true;
$.each(element, function (ix, e) {
if ($(e).attr("type") !== "checkbox") {
all = false;
}
});
return all;
};
if (element.length === 1 &&
element.attr("type") === "checkbox" &&
element.attr("value") === undefined) {
// Single checkbox without 'value' attribute
// Boolean field
return "exists";
} else if (element.length === 1 &&
element.attr("multiple") !== undefined) {
// Multiple by HTML semantics. E. g. multiple select
// Multiple choice field
return "multiple";
} else if (checkForCheckboxes()) {
// Multiple checkboxes or one checkbox with 'value' attribute.
// Multiple choice field represented by checkboxes
return "multiple";
} else {
// Other cases
return "single";
}
}
/**
* Get fields with name `name` relative to `element` with considering form
* prefixes.
* @param element the element
* @param name name of the field
* @returns jQuery object with found fields or empty jQuery object if no
* field was found
*/
yl.getFieldRelativeTo = function (element, name) {
var prefixes = $(element).getFormPrefixes();
for (var i = 0; i < prefixes.length; i++) {
var fieldSelector = "[name=" + prefixes[i] + name + "]";
var field = $(fieldSelector);
if (field.length) {
return field;
}
}
return $();
};
/**
* Get field value which is put to forwarded dictionary
* @param field the field
* @returns forwarded value
*/
yl.getValueFromField = function (field) {
var strategy = getForwardStrategy(field);
var serializedField = $(field).serializeArray();
if ((serializedField == false) && ($(field).prop('disabled'))) {
$(field).prop('disabled', false);
serializedField = $(field).serializeArray();
$(field).prop('disabled', true);
}
var getSerializedFieldElementAt = function (index) {
// Return serializedField[index]
// or null if something went wrong
if (serializedField.length > index) {
return serializedField[index];
} else {
return null;
}
};
var getValueOf = function (elem) {
// Return elem.value
// or null if something went wrong
if (elem.hasOwnProperty("value") &&
elem.value !== undefined
) {
return elem.value;
} else {
return null;
}
};
var getSerializedFieldValueAt = function (index) {
// Return serializedField[index].value
// or null if something went wrong
var elem = getSerializedFieldElementAt(index);
if (elem !== null) {
return getValueOf(elem);
} else {
return null;
}
};
if (strategy === "multiple") {
return serializedField.map(
function (item) {
return getValueOf(item);
}
);
} else if (strategy === "exists") {
return serializedField.length > 0;
} else {
return getSerializedFieldValueAt(0);
}
};
yl.getForwards = function (element) {
var forwardElem,
forwardList,
forwardedData,
divSelector,
form;
divSelector = "div.dal-forward-conf#dal-forward-conf-for-" +
element.attr("id") + ", " +
"div.dal-forward-conf#dal-forward-conf-for_" +
element.attr("id");
form = element.length > 0 ? $(element[0].form) : $();
forwardElem =
form.find(divSelector).find('script');
if (forwardElem.length === 0) {
return;
}
try {
forwardList = JSON.parse(forwardElem.text());
} catch (e) {
return;
}
if (!Array.isArray(forwardList)) {
return;
}
forwardedData = {};
$.each(forwardList, function (ix, field) {
var srcName, dstName;
if (field.type === "const") {
forwardedData[field.dst] = field.val;
} else if (field.type === "self") {
if (field.hasOwnProperty("dst")) {
dstName = field.dst;
} else {
dstName = "self";
}
forwardedData[dstName] = yl.getValueFromField(element);
} else if (field.type === "field") {
srcName = field.src;
if (field.hasOwnProperty("dst")) {
dstName = field.dst;
} else {
dstName = srcName;
}
var forwardedField = yl.getFieldRelativeTo(element, srcName);
if (!forwardedField.length) {
return;
}
forwardedData[dstName] = yl.getValueFromField(forwardedField);
} else if (field.type === "javascript") {
var handler = yl.getForwardHandler(field.handler);
forwardedData[field.dst || field.handler] = handler(element);
}
});
return JSON.stringify(forwardedData);
};
})(django.jQuery, yl);
}
$( document ).ready(function() {
dal_init();
});