07 July 2011

Auto-expanding django formset with jQuery

As it took me quite a while to get it how I like it, here's the relevant bits for making a django formset (custom markup in a table), that automatically adds rows (formset forms) client-side / in the browser keeping up as you fill in the form.

Do with the code as you wish, no licence needed.

In the view (.html file server side) I have:

@login_required
def invoiceEdit(request, invoice_id):
...
InlineInvoiceItemsFormSet = inlineformset_factory(Invoice, InvoiceItem, form=DeleteIfEmptyModelForm, formset=DeleteIfEmptyInlineFormSet, can_delete=True, extra=10)
...
itemFormSet = InlineInvoiceItemsFormSet()
...
return render_to_response('foo/edit.html', {'invoiceForm': invoiceForm, 'itemFormSet': itemFormSet, 'invoice': invoice}, context_instance=RequestContext(request))


In the template I have:

<script type="text/javascript">
$(function() {
setupInvoiceFormset();
});

var initialRows;

function setupInvoiceFormset() {
initialRows = parseInt($('#id_invoiceitem_set-INITIAL_FORMS').val());
// remove all but last two empty rows
resizeInvoiceFormset();
// add handlers to all inputs to automate row adding
$('.invoiceItemRow :input').blur(resizeInvoiceFormset);
}

const targetExtra = 2; // number of extra rows desired

function resizeInvoiceFormset() {
// count the blank rows at the end
var rows = $('.invoiceItemRow').filter(':not(#templateItemRow)');
var totalRows = rows.length
var blankRows = countBlankRows(rows);
var targetRowCount = totalRows - blankRows + targetExtra;
targetRowCount = Math.max(targetRowCount,initialRows); // don't trim off real rows otherwise delete breaks
if (totalRows > targetRowCount) {
// if there too many blank rows remove the extra rows
rows.slice(targetRowCount).remove(); // negative to strip from ends
} else if (totalRows < targetRowCount) {
// add new blank rows to bring the total up to the desired number
for (var newRowIndex = totalRows; newRowIndex < targetRowCount; newRowIndex++) {
addRow(newRowIndex);
}
} else {
return;
}
// update the hidden form with the new form count
$('#id_invoiceitem_set-TOTAL_FORMS').val(targetRowCount);
}

function countBlankRows(rows) {
// count the empty rows from the bottom up, stopping at the first non-blank row
var blankRows = 0;
for (var i = rows.length -1; i>=0; i--) {
if (isEmptyRow(rows[i])) {
blankRows++;
} else {
break;
}
}
return blankRows;
}

function isEmptyRow(row) {
// loop through all the inputs in the row, return true if they are all blank
// whitespace is ignored
var inputs = $(row).find(':input').filter(':not(:hidden)');
for (var j = 0; j < inputs.length; j++) {
if ($.trim(inputs[j].value).length) {
return false;
}
}
return true;
}

function addRow(newRowIndex) {
var newRow = $('#templateItemRow').clone(true);
newRow.addClass('invoiceItemRow');
newRow.removeAttr('id'); //prevent duplicated template row id
newRow.show();
// replace placeholder with row index
newRow.find(':input').each(function() {
$(this).attr("name", $(this).attr("name").replace('__prefix__', newRowIndex));
$(this).attr("id", $(this).attr("id").replace('__prefix__', newRowIndex));
});
$('.invoiceItemRow:last').after(newRow);
}
</script>
...
{{ itemFormSet.management_form }}
<tr id="templateItemRow" class="invoiceItemRow" style="display: none;">
<td><strong>Item:</strong></td>
<td>
{{ itemFormSet.empty_form.id }}
{{ itemFormSet.empty_form.description }}
{{ itemFormSet.empty_form.description.errors }}</td>
<td class="price">£{{ itemFormSet.empty_form.price }} {{ itemFormSet.empty_form.price.errors }}</td></tr>
{% for item in itemFormSet.forms %}
<tr class="invoiceItemRow">
<td><strong>Item:</strong></td>
<td>
{{ item.id }}
{{ item.description }}
{{ item.description.errors }}</td>
<td class="price">£{{ item.price }} {{ item.price.errors }}</td></tr>
{% endfor %}
...


The result is a form that intuitively shrinks/grows as the content is added/removed.

The javascript is of course actually in a separate .js file.

References:


Footnote. You may have noticed the delete-if-empty customisation which I like for usability. References for this at