Dynamically adding a form to a Django formset
Asked Answered
S

10

290

I want to dynamically add new forms to a Django formset, so that when the user clicks an "add" button it runs JavaScript that adds a new form (which is part of the formset) to the page.

Sixtieth answered 1/2, 2009 at 22:26 Comment(4)
I'm just guessing at your use case here, is it something like the "Attach Another File" feature in gmail, where the user is presented with a file upload field and new fields are added to the DOM on the fly as the user clicks to "Attach Another File" plus button?Countryfied
This is something I was going to work on soon, so I'll also be interested in any answers.Tracheo
I think this is a much better solution. #2354210 Does things clone doesn't: - Add form when no initial forms exists - Handles javascript in the form better, for example django-ckeditor - Keep initial dataHackle
Here's an example for django 3.x, using pure JavaScript to add or remove new forms.Reseda
I
239

This is how I do it, using jQuery:

My template:

<h3>My Services</h3>
{{ serviceFormset.management_form }}
{% for form in serviceFormset.forms %}
    <div class='table'>
    <table class='no_error'>
        {{ form.as_table }}
    </table>
    </div>
{% endfor %}
<input type="button" value="Add More" id="add_more">
<script>
    $('#add_more').click(function() {
        cloneMore('div.table:last', 'service');
    });
</script>

In a javascript file:

function cloneMore(selector, type) {
    var newElement = $(selector).clone(true);
    var total = $('#id_' + type + '-TOTAL_FORMS').val();
    newElement.find(':input').each(function() {
        var name = $(this).attr('name').replace('-' + (total-1) + '-','-' + total + '-');
        var id = 'id_' + name;
        $(this).attr({'name': name, 'id': id}).val('').removeAttr('checked');
    });
    newElement.find('label').each(function() {
        var newFor = $(this).attr('for').replace('-' + (total-1) + '-','-' + total + '-');
        $(this).attr('for', newFor);
    });
    total++;
    $('#id_' + type + '-TOTAL_FORMS').val(total);
    $(selector).after(newElement);
}

What it does:

cloneMore accepts selector as the first argument, and the type of formset as the 2nd one. What the selector should do is pass it what it should duplicate. In this case, I pass it div.table:last so that jQuery looks for the last table with a class of table. The :last part of it is important because the selector is also used to determine what the new form will be inserted after. More than likely you'd want it at the end of the rest of the forms. The type argument is so that we can update the management_form field, notably TOTAL_FORMS, as well as the actual form fields. If you have a formset full of, say, Client models, the management fields will have IDs of id_clients-TOTAL_FORMS and id_clients-INITIAL_FORMS, while the form fields will be in a format of id_clients-N-fieldname with N being the form number, starting with 0. So with the type argument the cloneMore function looks at how many forms there currently are, and goes through every input and label inside the new form replacing all the field names/ids from something like id_clients-(N)-name to id_clients-(N+1)-name and so on. After it is finished, it updates the TOTAL_FORMS field to reflect the new form and adds it to the end of the set.

This function is particularly helpful to me because the way it is setup it allows me to use it throughout the app when I want to provide more forms in a formset, and doesn't make me need to have a hidden "template" form to duplicate as long as I pass it the formset name and the format in which the forms are laid out.

Impoverish answered 21/3, 2009 at 20:58 Comment(8)
In IE, a clone from a cloned element is represented as <undefined> when selecting in JS, why?Erythroblastosis
I found that in Django 1.1 you'll need to assign a value to the prefix member of the Formset Object. This should the same value as the type argument for the cloneMore function.Correspond
I modified this to take the selector without :last and used var total = $(selector).length; to get my total because a refresh of the page would remove my formsets but leave the TOTAL increase leading to the wrong number being saved. I then added :last to the selector as needed. Thank for this.Beore
I have found that this using $(this).attr({'name': name, 'id': id}).val('').removeAttr('checked'); To clear the input will mess up checkboxes. Setting val('') gives the checkboxes an empty value attribute. And since checkboxes don't use the value attribute this will never be updated - no matter how many times you click it. But it seems that value has higher priority than the "checked" attributed of checkboxes. Which will mean that you will always post non checked checkboxes.Ryals
please paolo can you check my problem #62253367Goa
i need to replace this line var total = $('#id_' + type + '-TOTAL_FORMS').val(); with var total = $('#id_' + type + '_set-TOTAL_FORMS').val();. ie added _setTakeshi
file field didn't cleared on newly added itemTakeshi
Is this correct solution? newly created formset ids are not correct.Feuar
J
137

Simplified version of Paolo's answer using empty_form as a template.

<h3>My Services</h3>
{{ serviceFormset.management_form }}
<div id="form_set">
    {% for form in serviceFormset.forms %}
        <table class='no_error'>
            {{ form.as_table }}
        </table>
    {% endfor %}
</div>
<input type="button" value="Add More" id="add_more">
<div id="empty_form" style="display:none">
    <table class='no_error'>
        {{ serviceFormset.empty_form.as_table }}
    </table>
</div>
<script>
    $('#add_more').click(function() {
        var form_idx = $('#id_form-TOTAL_FORMS').val();
        $('#form_set').append($('#empty_form').html().replace(/__prefix__/g, form_idx));
        $('#id_form-TOTAL_FORMS').val(parseInt(form_idx) + 1);
    });
</script>
Jujitsu answered 11/11, 2011 at 17:20 Comment(5)
how can i deal this in the view? when i use CompetitorFormSet = modelformset_factory(ProjectCompetitor, formset=CompetitorFormSets) ctx['competitor_form_set'] = CompetitorFormSet(request.POST) i Only get one form, in in clean method. can you please explain how to deal this in views?Aldric
Brilliant – thank you. Makes excellent use of the available Django helpers (like empty_form), which I appreciate.Eudoca
@Eudoca - I have adapted the solution and the new rows of empty forms are getting generated. However the select boxes are generating a list of FK (available) choices, instead of drop downs which are otherwise being generated for the original set of forms. Has any issue of this nature been reported?Antifouling
@Jujitsu could you update answer for later versions i.e 3.x ? it's simple and clear but it's not working for meTaliataliaferro
@PoulaAdel What isn't working? I just tried this on Django 3.0.5 and it still works for me. Surprising after 8 years, but I guess Django and jQuery have good backward compatibility with older code.Jujitsu
A
11

Simulate and imitate:

  • Create a formset which corresponds to the situation before clicking the "add" button.
  • Load the page, view the source and take a note of all <input> fields.
  • Modify the formset to correspond to the situation after clicking the "add" button (change the number of extra fields).
  • Load the page, view the source and take a note of how the <input> fields changed.
  • Create some JavaScript which modifies the DOM in a suitable way to move it from the before state to the after state.
  • Attach that JavaScript to the "add" button.

While I do know formsets use special hidden <input> fields and know approximately what the script must do, I don't recall the details off the top of my head. What I described above is what I would do in your situation.

Architecture answered 9/2, 2009 at 20:49 Comment(0)
N
7

For the coders out there who are hunting resources to understand the above solutions a little better:

Django Dynamic Formsets

After reading the above link, the Django documentation and previous solutions should make a lot more sense.

Django Formset Documentation

As a quick summary of what I was getting confused by: The Management Form contains an overview of the forms within. You must keep that information accurate in order for Django to be aware of the forms you add. (Community, please give me suggestions if some of my wording is off here. Im new to Django.)

Nucleoplasm answered 3/3, 2018 at 14:53 Comment(0)
O
4

One option would be to create a formset with every possible form, but initially set the unrequired forms to hidden - ie, display: none;. When it's necessary to display a form, set it's css display to block or whatever is appropriate.

Without know more details of what your "Ajax" is doing, it's hard to give a more detailed response.

Outmost answered 2/2, 2009 at 0:49 Comment(0)
T
4

Another cloneMore version, which allows for selective sanitization of fields. Use it when you need to prevent several fields from being erased.

$('table tr.add-row a').click(function() {
    toSanitize = new Array('id', 'product', 'price', 'type', 'valid_from', 'valid_until');
    cloneMore('div.formtable table tr.form-row:last', 'form', toSanitize);
});

function cloneMore(selector, type, sanitize) {
    var newElement = $(selector).clone(true);
    var total = $('#id_' + type + '-TOTAL_FORMS').val();
    newElement.find(':input').each(function() {
        var namePure = $(this).attr('name').replace(type + '-' + (total-1) + '-', '');
        var name = $(this).attr('name').replace('-' + (total-1) + '-','-' + total + '-');
        var id = 'id_' + name;
        $(this).attr({'name': name, 'id': id}).removeAttr('checked');

        if ($.inArray(namePure, sanitize) != -1) {
            $(this).val('');
        }

    });
    newElement.find('label').each(function() {
        var newFor = $(this).attr('for').replace('-' + (total-1) + '-','-' + total + '-');
        $(this).attr('for', newFor);
    });
    total++;
    $('#id_' + type + '-TOTAL_FORMS').val(total);
    $(selector).after(newElement);
}
Tillion answered 4/11, 2010 at 14:0 Comment(1)
can you help me #62286267 , i've tried alot but didnt get an answer ! i much appreciate youGoa
W
3

Because all answers above use jQuery and make some things a bit complex I wrote following script:

function $(selector, element) {
    if (!element) {
        element = document
    }
    return element.querySelector(selector)
}

function $$(selector, element) {
    if (!element) {
        element = document
    }
    return element.querySelectorAll(selector)
}

function hasReachedMaxNum(type, form) {
    var total = parseInt(form.elements[type + "-TOTAL_FORMS"].value);
    var max = parseInt(form.elements[type + "-MAX_NUM_FORMS"].value);
    return total >= max
}

function cloneMore(element, type, form) {
    var totalElement = form.elements[type + "-TOTAL_FORMS"];
    total = parseInt(totalElement.value);
    newElement = element.cloneNode(true);
    for (var input of $$("input", newElement)) {
        input.name = input.name.replace("-" + (total - 1) + "-", "-" + total + "-");
        input.value = null
    }
    total++;
    element.parentNode.insertBefore(newElement, element.nextSibling);
    totalElement.value = total;
    return newElement
}
var addChoiceButton = $("#add-choice");
addChoiceButton.onclick = function() {
    var choices = $("#choices");
    var createForm = $("#create");
    cloneMore(choices.lastElementChild, "choice_set", createForm);
    if (hasReachedMaxNum("choice_set", createForm)) {
        this.disabled = true
    }
};

First you should set auto_id to false and so disable the duplication of id and name. Because the input names have to be unique in there form, all identification is done with them and not with id's. You also have to replace the form, type and the container of the formset. (In the example above choices)

Wreck answered 3/4, 2017 at 16:40 Comment(0)
T
2

There is a small issue with the cloneMore function. Since it's also cleaning the value of the django auto-generated hidden fields, it causes django to complain if you try to save a formset with more than one empty form.

Here is a fix:

function cloneMore(selector, type) {
    var newElement = $(selector).clone(true);
    var total = $('#id_' + type + '-TOTAL_FORMS').val();
    newElement.find(':input').each(function() {
        var name = $(this).attr('name').replace('-' + (total-1) + '-','-' + total + '-');
        var id = 'id_' + name;

        if ($(this).attr('type') != 'hidden') {
            $(this).val('');
        }
        $(this).attr({'name': name, 'id': id}).removeAttr('checked');
    });
    newElement.find('label').each(function() {
        var newFor = $(this).attr('for').replace('-' + (total-1) + '-','-' + total + '-');
        $(this).attr('for', newFor);
    });
    total++;
    $('#id_' + type + '-TOTAL_FORMS').val(total);
    $(selector).after(newElement);
}
Tolson answered 7/6, 2010 at 21:22 Comment(1)
Sorry @art_cs, I haven't worked with Django in several years. Please study the answers to this question carefully, and use the debugger in your browser's developer tools, I'm sure it's fairly easy to solve. I'd also look around for a pre-packaged solution.Architecture
K
1

Yea I'd also recommend just rendering them out in the html if you have a finite number of entries. (If you don't you'll have to user another method).

You can hide them like this:

{% for form in spokenLanguageFormset %}
    <fieldset class="languages-{{forloop.counter0 }} {% if spokenLanguageFormset.initial_forms|length < forloop.counter and forloop.counter != 1 %}hidden-form{% endif %}">

Then the js is really simple:

addItem: function(e){
    e.preventDefault();
    var maxForms = parseInt($(this).closest("fieldset").find("[name*='MAX_NUM_FORMS']").val(), 10);
    var initialForms = parseInt($(this).closest("fieldset").find("[name*='INITIAL_FORMS']").val(), 10);
    // check if we can add
    if (initialForms < maxForms) {
        $(this).closest("fieldset").find("fieldset:hidden").first().show();
        if ($(this).closest("fieldset").find("fieldset:visible").length == maxForms ){
            // here I'm just hiding my 'add' link
            $(this).closest(".control-group").hide();
        };
    };
}
Kyongkyoto answered 9/8, 2012 at 1:5 Comment(0)
M
0

I can recommend django-dynamic-formsets for everyone who is just looking for a solution that works out of the box.

It replaced all the code I derived from the solutions proposed and gives some additional functionality, such as deleting forms or stylizing related buttons.

Minervamines answered 22/8, 2022 at 6:32 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.