Django Formsets Delete glitchy when using DAL + Dynamic Formsets

Hi Team,

I’ve been experimenting with Django Autocomplete Light (using Select2widgets) and Django Dynamic Formsets - loading the JS frontend to dynamically add and remove forms in a formset.

Adding formsets and saving it works, editing formsets and adding more works. However when deleting more then 1 form from a formset, it just fails to work. Yet marks it as a completed form and therefore loads displays my success message etc. I’m stuck and not too sure where to look. I understand from reading the source code, that is_valid ignores items with deleted flag, so therefore it appears the form is valid and passes the check. However it’s not actually deleting the forms that are flagged.

Form.py

class OrderItemForm(forms.ModelForm):
    class Meta:
        model = OrderItem
        fields = ("product", "quantity")
        widgets = {
            "quantity": forms.TextInput(
                attrs={"class": "form-control", "type": "number", "min": "1"}
            ),
            "product": autocomplete.ModelSelect2(
                url="product:autocomplete_product",
                attrs={"data-theme": "bootstrap5"},
            ),
        }

View.py

class OrderUpdate(LoginRequiredMixin, AuditableMixin, SuccessMessageMixin, UpdateView):
    """
    Simple Formset view for updating orders, leverages jquery addins for
    dynamic management of formsets. Hides deleted items on the frontend
    which enables Django formset manager to manage items
    """

    model = Order
    template_name = "orders_update_view.html"
    success_url = reverse_lazy("orders:order_list")
    form_class = OrderUpdateForm
    success_message = "Order has been updated"

    def get_context_data(self, **kwargs):
        context = super(OrderUpdate, self).get_context_data(**kwargs)
        if self.request.POST:
            context["formset"] = OrderFormSet(self.request.POST, instance=self.object)
            context["formset"].full_clean()
        else:
            context["formset"] = OrderFormSet(instance=self.object)
        return context

    def form_valid(self, form):
        context = self.get_context_data(form=form)
        formset = context["formset"]
        if formset.is_valid():
            response = super().form_valid(form)
            formset.instance = self.object
            formset.save()
            return response
        else:
            return super().form_invalid(form)

Formset.js

/**

 * jQuery Formset 1.5-pre

 * @author Stanislaus Madueke (stan DOT madueke AT gmail DOT com)

 * @requires jQuery 1.2.6 or later

 *

 * Copyright (c) 2009, Stanislaus Madueke

 * All rights reserved.

 *

 * Licensed under the New BSD License

 * See: http://www.opensource.org/licenses/bsd-license.php

 */

;(function($) {

    $.fn.formset = function(opts)

    {

        var options = $.extend({}, $.fn.formset.defaults, opts),

            flatExtraClasses = options.extraClasses.join(' '),

            totalForms = $('#id_' + options.prefix + '-TOTAL_FORMS'),

            maxForms = $('#id_' + options.prefix + '-MAX_NUM_FORMS'),

            minForms = $('#id_' + options.prefix + '-MIN_NUM_FORMS'),

            childElementSelector = 'input,select,textarea,label,div',

            $$ = $(this),

            applyExtraClasses = function(row, ndx) {

                if (options.extraClasses) {

                    row.removeClass(flatExtraClasses);

                    row.addClass(options.extraClasses[ndx % options.extraClasses.length]);

                }

            },

            updateElementIndex = function(elem, prefix, ndx) {

                var idRegex = new RegExp(prefix + '-(\\d+|__prefix__)-'),

                    replacement = prefix + '-' + ndx + '-';

                if (elem.attr("for")) elem.attr("for", elem.attr("for").replace(idRegex, replacement));

                if (elem.attr('id')) elem.attr('id', elem.attr('id').replace(idRegex, replacement));

                if (elem.attr('name')) elem.attr('name', elem.attr('name').replace(idRegex, replacement));

            },

            hasChildElements = function(row) {

                return row.find(childElementSelector).length > 0;

            },

            showAddButton = function() {

                return maxForms.length == 0 ||   // For Django versions pre 1.2

                    (maxForms.val() == '' || (maxForms.val() - totalForms.val() > 0));

            },

            /**

            * Indicates whether delete link(s) can be displayed - when total forms > min forms

            */

            showDeleteLinks = function() {

                return minForms.length == 0 ||   // For Django versions pre 1.7

                    (minForms.val() == '' || (totalForms.val() - minForms.val() > 0));

            },

            insertDeleteLink = function(row) {

                var delCssSelector = $.trim(options.deleteCssClass).replace(/\s+/g, '.'),

                    addCssSelector = $.trim(options.addCssClass).replace(/\s+/g, '.');

                //href="javascript:void(0)"

                var delButtonHTML = '<a class="' + options.deleteCssClass + '">' + options.deleteText +'</a>';

                if (options.deleteContainerClass) {

                    // If we have a specific container for the remove button,

                    // place it as the last child of that container:

                    row.find('[class*="' + options.deleteContainerClass + '"]').append(delButtonHTML);

                } else if (row.is('TR')) {

                    // If the forms are laid out in table rows, insert

                    // the remove button into the last table cell:

                    row.children(':last').append(delButtonHTML);

                } else if (row.is('UL') || row.is('OL')) {

                    // If they're laid out as an ordered/unordered list,

                    // insert an <li> after the last list item:

                    row.append('<li>' + delButtonHTML + '</li>');

                } else {

                    // Otherwise, just insert the remove button as the

                    // last child element of the form's container:

                    row.append(delButtonHTML);

                }

                // Check if we're under the minimum number of forms - not to display delete link at rendering

                if (!showDeleteLinks()){

                    row.find('a.' + delCssSelector).hide();

                }

                row.find('a.' + delCssSelector).click(function() {

                    var row = $(this).parents('.' + options.formCssClass),

                        del = row.find('input:hidden[id $= "-DELETE"]'),

                        buttonRow = row.siblings("a." + addCssSelector + ', .' + options.formCssClass + '-add'),

                        forms;

                    if (del.length) {

                        // We're dealing with an inline formset.

                        // Rather than remove this form from the DOM, we'll mark it as deleted

                        // and hide it, then let Django handle the deleting:

                        del.val('on');

                        //row.hide();

                        row.attr('style', 'display: none !important');

                        row.attr('type', 'hidden');

                        forms = $('.' + options.formCssClass).not(':hidden');

                        totalForms.val(forms.length);

                    } else {

                        row.remove();

                        // Update the TOTAL_FORMS count:

                        //forms = $('.' + options.formCssClass).not('.formset-custom-template');

                        forms = $('.' + options.formCssClass).not(':hidden');

                        totalForms.val(forms.length);

                    }

                    for (var i=0, formCount=forms.length; i<formCount; i++) {

                        // Apply `extraClasses` to form rows so they're nicely alternating:

                        applyExtraClasses(forms.eq(i), i);

                        if (!del.length) {

                            // Also update names and IDs for all child controls (if this isn't

                            // a delete-able inline formset) so they remain in sequence:

                            forms.eq(i).find(childElementSelector).each(function() {

                                updateElementIndex($(this), options.prefix, i);

                            });

                        }

                    }

                    // Check if we've reached the minimum number of forms - hide all delete link(s)

                    if (!showDeleteLinks()){

                        $('a.' + delCssSelector).each(function(){$(this).hide();});

                    }

                    // Check if we need to show the add button:

                    if (buttonRow.is(':hidden') && showAddButton()) buttonRow.show();

                    // If a post-delete callback was provided, call it with the deleted form:

                    if (options.removed) options.removed(row);

                    return false;

                });

            };

        $$.each(function(i) {

            var row = $(this),

                del = row.find('input:checkbox[id $= "-DELETE"]');

            if (del.length) {

                // If you specify "can_delete = True" when creating an inline formset,

                // Django adds a checkbox to each form in the formset.

                // Replace the default checkbox with a hidden field:

                if (del.is(':checked')) {

                    // If an inline formset containing deleted forms fails validation, make sure

                    // we keep the forms hidden (thanks for the bug report and suggested fix Mike)

                    del.before('<input type="hidden" name="' + del.attr('name') +'" id="' + del.attr('id') +'" value="on" />');

                    row.hide();

                } else {

                    del.before('<input type="hidden" name="' + del.attr('name') +'" id="' + del.attr('id') +'" />');

                }

                // Hide any labels associated with the DELETE checkbox:

                $('label[for="' + del.attr('id') + '"]').hide();

                del.remove();

            }

            if (hasChildElements(row)) {

                row.addClass(options.formCssClass);

                if (row.is(':visible')) {

                    insertDeleteLink(row);

                    applyExtraClasses(row, i);

                }

            }

        });

        if ($$.length) {

            var hideAddButton = !showAddButton(),

                addButton, template;

            if (options.formTemplate) {

                // If a form template was specified, we'll clone it to generate new form instances:

                template = (options.formTemplate instanceof $) ? options.formTemplate : $(options.formTemplate);

                template.removeAttr('id').addClass(options.formCssClass + ' formset-custom-template');

                template.find(childElementSelector).each(function() {

                    updateElementIndex($(this), options.prefix, '__prefix__');

                });

                insertDeleteLink(template);

            } else {

                // Otherwise, use the last form in the formset; this works much better if you've got

                // extra (>= 1) forms (thnaks to justhamade for pointing this out):

                if (options.hideLastAddForm) $('.' + options.formCssClass + ':last').hide();

                template = $('.' + options.formCssClass + ':last').clone(true).removeAttr('id');

                template.find('input:hidden[id $= "-DELETE"]').remove();

                // Clear all cloned fields, except those the user wants to keep (thanks to brunogola for the suggestion):

                template.find(childElementSelector).not(options.keepFieldValues).each(function() {

                    var elem = $(this);

                    // If this is a checkbox or radiobutton, uncheck it.

                    // This fixes Issue 1, reported by Wilson.Andrew.J:

                    if (elem.is('input:checkbox') || elem.is('input:radio')) {

                        elem.attr('checked', false);

                    } else {

                        elem.val('');

                    }

                });

            }

            // FIXME: Perhaps using $.data would be a better idea?

            options.formTemplate = template;

            //href="javascript:void(0)"

            var addButtonHTML = '<a class="' + options.addCssClass + '">' + options.addText + '</a>';

            if (options.addContainerClass) {

                // If we have a specific container for the "add" button,

                // place it as the last child of that container:

                var addContainer = $('[class*="' + options.addContainerClass + '"');

                addContainer.append(addButtonHTML);

                addButton = addContainer.find('[class="' + options.addCssClass + '"]');

            } else if ($$.is('TR')) {

                // If forms are laid out as table rows, insert the

                // "add" button in a new table row:

                var numCols = $$.eq(0).children().length,   // This is a bit of an assumption :|

                    buttonRow = $('<tr><td colspan="' + numCols + '">' + addButtonHTML + '</tr>').addClass(options.formCssClass + '-add');

                $$.parent().append(buttonRow);

                addButton = buttonRow.find('a');

            } else {

                // Otherwise, insert it immediately after the last form:

                $$.filter(':last').after(addButtonHTML);

                addButton = $$.filter(':last').next();

            }

            if (hideAddButton) addButton.hide();

            addButton.click(function() {

                var formCount = parseInt(totalForms.val()),

                    row = options.formTemplate.clone(true).removeClass('formset-custom-template'),

                    buttonRow = $($(this).parents('tr.' + options.formCssClass + '-add').get(0) || this),

                    delCssSelector = $.trim(options.deleteCssClass).replace(/\s+/g, '.');

                applyExtraClasses(row, formCount);

                row.insertBefore(buttonRow).show();

                row.find(childElementSelector).each(function() {

                    updateElementIndex($(this), options.prefix, formCount);

                });

                totalForms.val(formCount + 1);

                // Check if we're above the minimum allowed number of forms -> show all delete link(s)

                if (showDeleteLinks()){

                    $('a.' + delCssSelector).each(function(){$(this).show();});

                }

                // Check if we've exceeded the maximum allowed number of forms:

                if (!showAddButton()) buttonRow.hide();

                // If a post-add callback was supplied, call it with the added form:

                if (options.added) options.added(row);

                return false;

            });

        }

        return $$;

    };

    /* Setup plugin defaults */

    $.fn.formset.defaults = {

        prefix: 'form',                  // The form prefix for your django formset

        formTemplate: null,              // The jQuery selection cloned to generate new form instances

        addText: 'add another',          // Text for the add link

        deleteText: 'remove',            // Text for the delete link

        addContainerClass: null,         // Container CSS class for the add link

        deleteContainerClass: null,      // Container CSS class for the delete link

        addCssClass: 'add-row',          // CSS class applied to the add link

        deleteCssClass: 'delete-row',    // CSS class applied to the delete link

        formCssClass: 'dynamic-form',    // CSS class applied to each form in a formset

        extraClasses: [],                // Additional CSS classes, which will be applied to each form in turn

        keepFieldValues: '',             // jQuery selector for fields whose values should be kept when the form is cloned

        added: null,                     // Function called each time a new form is added

        removed: null,                   // Function called each time a form is deleted

        hideLastAddForm: false           // When set to true, hide last empty add form (becomes visible when clicking on add button)

    };

})(jQuery);

Template.html

{% extends 'base.html' %}
{% load static %}
{% load crispy_forms_tags %}

{% block extrahead %}
<script src="{% static 'js/formset.js' %}"></script>
{% endblock %}

{% block main %}

<h3 class="text-dark mb-4">Orders</h3>
<div class="card shadow">

	<div class="card-header py-3">
		<div class="row">
			<div class="col-md-9 text-primary m-0 font-weight-bold">Update Order</div>
		</div>
	</div>


	<div class="card-body">
		<div class="row">

			<form method="post" autocomplete="off">
				{% csrf_token %}
				{{form|crispy}}

				{{formset.management_form}}
				{% for form in formset %}

					<div class="orderitem-form d-flex py-1 ps-3 form-inline">
						{% for hidden_field in form.hidden_fields %}
							{% if hidden_field.errors %}
							<ul>
								{% for error in hidden_field.errors %}
								<li>(Hidden field {{ hidden_field.name }}) {{ error }}</li>
								{% endfor %}
							</ul>
							{% endif %}
							{{ hidden_field }}
						{% endfor %}

						{% for field in form.visible_fields %}
						<div class="pe-2 col">
							{{ field.label_tag }}
							
							{% if field.errors %}
								{% for error in field.errors %}
									<li>{{ error }}</li>
								{% endfor %}
							{% endif %}
							
							{{ field }}
							
							{% if field.help_text %}
								{{ field.help_text }}
							{% endif %}

						</div>
						{% endfor %}

					</div>
				{% endfor %}
				
				<div class="add-another-product"></div>

			<div class="col-md-12">
				<button type="submit" value="submit" class="btn btn-primary">
					<i class="fas fa-save"></i> Update Order
				</button>

				<a class="btn btn-secondary" href="{% url 'orders:order_list' %}">
					<i class="fas fa-times"></i> Cancel Editing
				</a>
			</div>

			</form>

		</div>
	</div>
</div>


{{ formset.form.media }}

<script type="text/javascript">
	$('.orderitem-form').formset({
		prefix: '{{ formset.prefix}}',
		addText: '<i class="fas fa-plus"></i> Add Another Product',
		deleteText: '<i class="fas fa-trash"></i> Remove Product',
		addCssClass: 'btn btn-success mb-3', // CSS class applied to the add link
		deleteCssClass: 'btn btn-danger ms-auto align-content-end align-self-center', // CSS class applied to the delete link
		//deleteContainerClass: 'ms-auto align-content-end align-self-center pt-3',
		addContainerClass: 'add-another-product',
	});
</script>
{% endblock %}

Overall, I’m not sure, how to even begin troubleshooting this problem. When I check the POST in the browser, I can see that is sending all the required information for a valid formset and forms are correctly marked with Delete attribute.

Any guidance where to begin would be amazing, or anyone who have successfully used dynamic formsets with Select2 type functionality would be great. Or maybe better alternatives?

1 Like