Skip to content

Instantly share code, notes, and snippets.

@vdboor
Last active November 5, 2019 21:12
Show Gist options
  • Save vdboor/5020164 to your computer and use it in GitHub Desktop.
Save vdboor/5020164 to your computer and use it in GitHub Desktop.
jQuery plugin for dynamic Django admin inlines. This script gives more freedom over the HTML layout (in constrast to `inlines.js` from Django), but does require manual binding.
/**
* jQuery plugin for Django inlines
*
* - When a `.js-django-inlines` is present, it will automatically enable this script for it.
* - When `.js-add-form` is missing, an add button will be inserted manually.
* - Make sure a `.js-remove-form` element is present in the HTML.
*
* To customize the behavior, use different class names and manually call $formset.djangoInline().
* This can also be used to manually connect the 'add' and 'delete' buttons.
*
* Source: https://gist.github.com/vdboor/5020164
*
* (c) 2011-2016 Diederik van der Boor, Apache 2 Licensed.
*/
(function($){
/**
* The internal object that manages the inline.
* This object is constructed in the jQuery plugin binding, and stored as 'data' attribute.
*
* @param group The container DOM element.
* @param options The `data-..` fields given by the jQuery plugin.
* @constructor
*/
function DjangoInline(group, options) {
options = $.extend({}, $.fn.djangoInline.defaults, options);
this.group = group;
this.$group = $(group);
this.options = options;
options.prefix = options.prefix || this.$group.attr('id').replace(/-group$/, '');
if( options.formTemplate ) {
this.$form_template = $(options.formTemplate);
} else {
this.$form_template = this.$group.find(this.options.emptyFormSelector); // the extra item to construct new instances.
}
// Create the add button if requested (null/undefined means auto select)
if(options.showAddButton !== false) {
this.$addButton = this.$group.find('.js-add-form');
if(! this.$addButton.length) {
this.$addButton = this.createAddButton();
}
else {
this.$addButton.filter('a, button').click($.proxy(this._onAddButtonClick, this));
this.$addButton.find('a, button').click($.proxy(this._onAddButtonClick, this));
}
// Hide the add button when there are too many forms (NOTE: partially implemented)
this._toggleAddButtonDisplay();
}
// Auto-bind the delete button.
var myself = this;
this.$group.on('click', '.js-remove-form', function(event) {
event.preventDefault();
myself.removeForm(this);
});
}
DjangoInline.prototype = {
/**
* Create the add button (if needed)
*/
createAddButton: function() {
var $addButton;
var myself = this;
if (this.options.childTypes) {
// Polymorphic inlines - the add button opens a menu.
$addButton = $(this._renderPolymorphicAddButton());
this.$group.after($addButton);
$addButton.children('a').click($.proxy(this._onMenuToggle, this));
$addButton.find('li a').click($.proxy(this._onAddButtonClick, this));
}
else {
// Normal inlines
$addButton = $(this._renderAddButton());
this.$group.after($addButton);
$addButton.find('a').click($.proxy(this._onAddButtonClick, this));
}
return $addButton;
},
_renderAddButton: function() {
return '<div class="' + this.options.addCssClass + '"><a href="#">' + this.options.addText + "</a></div>";
},
_renderPolymorphicAddButton: function() {
var menu = '<div class="add-form-choice-menu" style="display: none;"><ul>';
for (var i = 0; i < this.options.childTypes.length; i++) {
var obj = this.options.childTypes[i];
menu += '<li><a href="#" data-type="' + obj.type + '">' + obj.name + '</a></li>';
}
menu += '</ul></div>';
return '<div class="' + this.options.addCssClass + ' add-form-choice"><a href="#">' + this.options.addText + "</a>" + menu + "</div>";
},
_onMenuToggle: function(event) {
event.preventDefault();
event.stopPropagation();
var $menu = $(event.target).next('.add-form-choice-menu');
if(! $menu.is(':visible')) {
function hideMenu() {
$menu.slideUp();
$(document).unbind('click', hideMenu);
}
$(document).click(hideMenu);
}
$menu.slideToggle();
},
_onAddButtonClick: function(event) {
event.preventDefault();
var type = $(event.target).attr('data-type');
var empty_form_selector = ! type ? null : this.options.emptyFormSelector + "[data-inline-type=" + type + "]";
this.addForm(empty_form_selector);
},
_toggleAddButtonDisplay: function() {
// Hide the add button when there are no more forms available.
var management_form = this._getManagementForm();
var hideAddButton = (management_form.max_forms
&& management_form.max_forms.value
&& parseInt(management_form.total_forms.value) >= parseInt(management_form.max_forms.value));
this.$addButton.toggle(!hideAddButton);
},
/**
* The main action, add a new row.
* Allow to select a different form template (for polymorphic inlines)
*/
addForm: function(emptyFormSelector) {
var $form_template;
if(emptyFormSelector) {
$form_template = this.$group.find(emptyFormSelector);
if($form_template.length === 0) {
throw new Error("Form template '" + emptyFormSelector + "' not found")
}
}
else {
if(! this.$form_template || this.$form_template.length === 0) {
throw new Error("No empty form available. Define the 'form_template' setting or add an '.empty-form' element in the '" + this.options.prefix + "' formset group!");
}
$form_template = this.$form_template;
}
// The Django admin/media/js/inlines.js API is not public, or easy to use.
// Recoded the inline model dynamics.
var management_form = this._getManagementForm();
if(! management_form.total_forms) {
throw new Error("Missing '#" + this._getGroupFieldIdPrefix() + "-TOTAL_FORMS' field. Make sure the management form included!");
}
// When a inline is presented in a complex table,
// the newFormTarget can be very useful to direct the output.
var container;
if(this.options.newFormTarget == null) {
container = $form_template.parent();
}
else if($.isFunction(this.options.newFormTarget)) {
container = this.options.newFormTarget.apply(this.group);
}
else {
container = this.$group.find(this.options.newFormTarget);
}
if(container === null || container.length === 0) {
throw new Error("No container found via custom 'newFormTarget' function!");
}
// Clone the item.
var new_index = parseInt(management_form.total_forms.value);
var item_id = this._getFormId(new_index);
var newhtml = _getOuterHtml($form_template).replace(/__prefix__/g, new_index);
var newitem = $(newhtml).removeClass("empty-form").attr("id", item_id);
// Add it
container.append(newitem);
var formset_item = $("#" + item_id);
if( formset_item.length === 0 ) {
throw new Error("New FormSet item not found: #" + item_id);
}
formset_item.data('djangoInlineIndex', new_index);
if(this.options.onAdd) {
this.options.onAdd.call(this.group, formset_item, new_index, this.options);
}
// Update administration
management_form.total_forms.value = new_index + 1;
return formset_item;
},
getFormAt: function(index) {
return $('#' + this._getFormId(index));
},
_getFormId: function(index) {
// The form container is expected by the numbered as #prefix-NR
return this.options.itemIdTemplate.replace('{prefix}', this.options.prefix).replace('{index}', index);
},
_getGroupFieldIdPrefix: function() {
// typically: #id_modelname
return this.options.autoId.replace('{prefix}', this.options.prefix);
},
/**
* Get the management form data.
*/
_getManagementForm: function() {
var group_id_prefix = this._getGroupFieldIdPrefix();
return {
// management form item
total_forms: $("#" + group_id_prefix + "-TOTAL_FORMS")[0],
max_forms: $("#" + group_id_prefix + "-MAX_NUM_FORMS")[0],
group_id_prefix: group_id_prefix
}
},
_getItemData: function(child_node) {
var formset_item = $(child_node).closest(this.options.itemsSelector);
if( formset_item.length === 0 ) {
return null;
}
// Split the ID, using the id_template pattern.
// note that ^...$ is important, as a '-' char can occur multiple times with generic inlines (inlinetype-id / app-model-ctfield-ctfkfield-id)
var id = formset_item.attr("id");
var cap = (new RegExp('^' + this.options.itemIdTemplate.replace('{prefix}', '(.+?)').replace('{index}', '(\\d+)') + '$')).exec(id);
return {
formset_item: formset_item,
prefix: cap[1],
index: parseInt(cap[2], 0) // or parseInt(formset_item.data('djangoInlineIndex'))
};
},
/**
* Get the meta-data of a single form.
*/
_getItemForm: function(child_node) {
var dominfo = this._getItemData(child_node);
if( dominfo === null ) {
return null;
}
var field_id_prefix = this._getGroupFieldIdPrefix() + "-" + dominfo.index;
return $.extend({}, dominfo, {
// Export settings data
field_id_prefix: field_id_prefix,
field_name_prefix: dominfo.prefix + '-' + dominfo.index,
// Item fields
pk_field: $('#' + field_id_prefix + '-' + this.options.pkFieldName),
delete_checkbox: $("#" + field_id_prefix + "-DELETE")
});
},
/**
* Remove a row
*/
removeForm: function(child_node)
{
// Get dom info
var management_form = this._getManagementForm();
var itemform = this._getItemForm(child_node);
if( itemform === null ) {
throw new Error("No form found for the selector '" + child_node.selector + "'!");
}
var total_count = parseInt(management_form.total_forms.value);
var has_pk_field = itemform.pk_field.length != 0;
if(this.options.onBeforeRemove) {
this.options.onBeforeRemove.call(this.group, itemform.formset_item, this.options);
}
// In case there is a delete checkbox, save it.
if( has_pk_field && itemform.pk_field[0].value ) {
// Item was an existing form, need to update the delete checkbox.
if (itemform.delete_checkbox.length) {
itemform.pk_field.insertAfter(management_form.total_forms);
itemform.delete_checkbox.attr('checked', true).insertAfter(management_form.total_forms).hide();
}
else {
// Construct a delete checkbox on the fly.
itemform.pk_field.insertAfter(management_form.total_forms);
var dummyDelete = '<input type="hidden" id="' + itemform.field_id_prefix + '-DELETE" name="' + itemform.field_name_prefix + '-DELETE" value="on">';
$(dummyDelete).insertAfter(management_form.total_forms);
}
}
else
{
// Newly added item, renumber in reverse order
for( var i = itemform.index + 1; i < total_count; i++ )
{
this._renumberItem(this.getFormAt(i), i - 1);
}
management_form.total_forms.value--;
}
// And remove item
itemform.formset_item.remove();
if(this.options.onRemove) {
this.options.onRemove.call(this.group, itemform.formset_item, this.options);
}
return itemform.formset_item;
},
// Based on django/contrib/admin/media/js/inlines.js
_renumberItem: function($formset_item, new_index)
{
var id_regex = new RegExp("(" + this._getFormId('(\\d+|__prefix__)') + ")");
var replacement = this._getFormId(new_index);
$formset_item.data('djangoInlineIndex', new_index);
// Loop through the nodes.
// Getting them all at once turns out to be more efficient, then looping per level.
var nodes = $formset_item.add( $formset_item.find("*") );
for( var i = 0; i < nodes.length; i++ )
{
var node = nodes[i];
var $node = $(node);
var for_attr = $node.attr('for');
if( for_attr && for_attr.match(id_regex) ) {
$node.attr("for", for_attr.replace(id_regex, replacement));
}
if( node.id && node.id.match(id_regex) ) {
node.id = node.id.replace(id_regex, replacement);
}
if( node.name && node.name.match(id_regex) ) {
node.name = node.name.replace(id_regex, replacement);
}
}
},
// Extra query methods for external callers:
getFormIndex: function(child_node) {
var dominfo = this._getItemData(child_node);
return dominfo ? dominfo.index : null;
},
getForms: function() {
// typically: .inline-related:not(.empty-form)
return this.$group.children(this.options.itemsSelector + ":not(" + this.options.emptyFormSelector + ")");
},
getEmptyForm: function() {
// typically: #modelname-group > .empty-form
return this.$form_template;
},
getFieldIdPrefix: function(item_index) {
if(! $.isNumeric(item_index)) {
var dominfo = this._getItemData(item_index);
if(dominfo === null) {
throw new Error("Unexpected element in getFieldIdPrefix, needs to be item_index, or DOM child node.");
}
item_index = dominfo.index;
}
// typically: #id_modelname-NN
return this._getGroupFieldIdPrefix() + "-" + item_index;
},
getFieldsAt: function(index) {
var $form = this.getFormAt(index);
return this.getFields($form);
},
getFields: function(child_node) {
// Return all fields in a simple lookup object, with the prefix stripped.
var dominfo = this._getItemData(child_node);
if(dominfo === null) {
return null;
}
var fields = {};
var $inputs = dominfo.formset_item.find(':input');
var name_prefix = this.prefix + "-" + dominfo.index;
for(var i = 0; i < $inputs.length; i++) {
var name = $inputs[i].name;
if(name.substring(0, name_prefix.length) == name_prefix) {
var suffix = name.substring(name_prefix.length + 1); // prefix-<name>
fields[suffix] = $inputs[i];
}
}
return fields;
},
removeFormAt: function(index) {
return this.removeForm(this.getFormAt(index));
}
};
function _getOuterHtml($node)
{
if( $node.length )
{
if( $node[0].outerHTML ) {
return $node[0].outerHTML;
} else {
return $("<div>").append($node.clone()).html();
}
}
return null;
}
// jQuery plugin definition
// Separated from the main code, as demonstrated by Twitter bootstrap.
$.fn.djangoInline = function(option) {
var args = Array.prototype.splice.call(arguments, 1);
var call_method = (typeof option == 'string');
var plugin_result = (call_method ? undefined : this);
this.filter('.inline-group').each(function() {
var $this = $(this);
var data = $this.data('djangoInline');
if (! data) {
var options = typeof option == 'object' ? option : {};
$this.data('djangoInline', (data = new DjangoInline(this, options)));
}
if (typeof option == 'string') {
plugin_result = data[option].apply(data, args);
}
});
return plugin_result;
};
$.fn.djangoInline.defaults = {
pkFieldName: 'id', // can be `tablename_ptr` for inherited models.
autoId: 'id_{prefix}', // the auto id format used in Django.
prefix: null, // typically the model name in lower case.
newFormTarget: null, // Define where the row should be added; a CSS selector or function.
itemIdTemplate: '{prefix}-{index}', // Format of the ID attribute.
itemsSelector: '.inline-related', // CSS class that each item has
emptyFormSelector: '.empty-form', // CSS class that
formTemplate: null, // Complete HTML of the new form
childTypes: null, // Extra for django-polymorphic, allow a choice between empty-forms.
showAddButton: true,
addText: "add another", // Text for the add link
addCssClass: "add-row" // CSS class applied to the add link
};
// Also expose inner object
$.fn.djangoInline.Constructor = DjangoInline;
// Auto enable inlines
$.fn.ready(function(){
$('.js-django-inlines').each(function(){
var $this = $(this);
var data = $this.data();
$this.djangoInline(data.options || data);
});
})
})(window.django ? window.django.jQuery : jQuery);
/**
* Initialization code to use formsets in a page.
* When a `.js-formset-group` is present, it will automatically enable `jquery.django-inlines.js` for it.
* Make sure a `.js-formset-add-form` and `.js-formset-remove-form` element is present in the HTML.
*/
(function($){
$.fn.ready(onReady);
function onReady() {
var $formsets = $('.js-formset-group');
if($formsets.length == 0)
return;
$formsets.each(function(){
var $formset = $(this);
var options = $formset.data();
// Auto adjust options when there is an add button in the HTML.
// Add button may exist one level up, so new items move the button down.
var $addButton = $formset.parent().find('.js-formset-add-form');
if($addButton.length) {
options['showAddButton'] = false;
}
// Initialize the formet
$formset.djangoInline(options);
$addButton.click(function(event){
event.preventDefault();
$formset.djangoInline('addForm');
});
// Remove buttons should exist in every row.
$formset.on('click', '.js-formset-remove-form', function(event){
$formset.djangoInline('removeForm', this);
});
});
}
})(jQuery);
{% load i18n crispy_forms_tags frontend_form_tags %}
{{ formset.media }}
<div class="inline-group js-django-inlines" id="{{ formset.prefix }}-group" data-options="{{ formset|as_script_options }}">
{% block add_button %}
{% if formset.empty_forms %}
{# django-polymorphic formset #}
<div class="btn-group" role="group">
{% for model in formset.child_forms %}
<a type="button" data-type="{{ model|as_model_name }}" class="js-add-form btn btn-default">{{ model|as_verbose_name }}</a>
{% endfor %}
</div>
{% else %}
<a class="btn btn-default js-add-form">{% trans "Add" %}</a>
{% endif %}
{% endblock %}
{{ formset.management_form }}
{% for form in formset|include_empty_form %}
<div id="{{ form.prefix }}" data-inline-type="{{ form|as_form_type|lower }}" class="inline-related{% if '__prefix__' in form.prefix %} empty-form{% endif %}">
{{ form|as_non_field_errors }}
{# Add the 'pk' field that is not mentioned in crispy #}
{% for field in form.hidden_fields %}
{{ field }}
{% endfor %}
{% block formset_form %}
{% crispy form %}
{% endblock %}
{% if formset.can_delete %}<span class="glyphicon glyphicon-trash remove-button js-remove-form" title="{% trans "Remove" %}"></span>{% endif %}
</div>
{% endfor %}
</div>
"""
Some useful template tag examples to display forms and formsets easier.
"""
import json
from django.forms import Media
from django.template import Library, TemplateSyntaxError
from django.utils.encoding import force_text
from django.utils.html import format_html, escape
from django.utils.safestring import mark_safe
from django.utils.text import capfirst
from django.utils.translation import ugettext
register = Library()
@register.filter()
def include_empty_form(formset):
"""
Make sure the "empty form" is included when displaying a formset (typically table with input rows)
"""
for form in formset:
yield form
if hasattr(formset, 'empty_forms'):
# BasePolymorphicModelFormSet from django-polymorphic 1.0b1
for form in formset.empty_forms:
yield form
else:
# Standard Django formset
yield formset.empty_form
@register.filter
def as_non_field_errors(form):
"""
Properly format field errors if they exist.
"""
errors = form.non_field_errors()
if errors:
return mark_safe('<span class="help-block"><strong>{0}</strong></span>', '<br>'.join(map(escape, errors)))
else:
return ''
@register.filter
def as_non_form_errors(formset):
"""
Properly format the formset errors.
"""
errors = formset.non_form_errors()
if errors:
return format_html('<div class="has-error"><span class="help-block"><strong>{0}</strong></span></div>', '<br>'.join(map(escape, errors)))
else:
return ''
@register.filter()
def as_field_errors(field):
"""
Properly format field errors if they exist.
"""
if not field:
raise TemplateSyntaxError("Invalid field name passed to as_field_errors filter")
if field.errors:
return format_html('<span class="help-block"><strong>{0}</strong></span>', '<br>'.join(map(escape, field.errors)))
else:
return ''
@register.filter
def as_script_options(formset):
"""
A JavaScript data structure for the JavaScript code
This generates the ``data-options`` attribute for jquery.django-inlines.js
"""
verbose_name = formset.model._meta.verbose_name
options = {
'prefix': formset.prefix,
'pkFieldName': formset.model._meta.pk.name,
'addText': ugettext('Add another %(verbose_name)s') % {
'verbose_name': capfirst(verbose_name),
},
'deleteText': ugettext('Remove'),
}
if hasattr(formset, 'child_forms'): # BasePolymorphicModelFormSet from django-polymorphic 1.0b1
# Allow to add different types
options['childTypes'] = [
{
'name': force_text(model._meta.verbose_name),
'type': model._meta.model_name,
} for model in formset.child_forms.keys()
]
return json.dumps(options)
@register.filter
def as_model_name(model):
"""
Usage: ``{{ model|as_model_name }}``
"""
return model._meta.model_name
@register.filter
def as_form_type(form):
"""
Usage: ``{{ form|as_form_type }}``
"""
return form._meta.model._meta.model_name
@register.filter
def as_verbose_name(obj):
"""
Usage: ``{{ obj|as_verbose_name }}``
"""
return force_text(obj._meta.verbose_name)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment