/*! Django Superformset - v1.0.4 - 2014-06-13 * https://github.com/jgerigmeyer/jquery-django-superformset * Based on jQuery Formset 1.1r14, by Stanislaus Madueke * Original Portions Copyright (c) 2009 Stanislaus Madueke * Modifications Copyright (c) 2014 Jonny Gerig Meyer; Licensed BSDv3 */ (function ($) { 'use strict'; var methods = { init: function (options) { var vars = {}; var opts = vars.opts = $.extend({}, $.fn.superformset.defaults, options); var wrapper = vars.wrapper = $(this); var rows = vars.rows = wrapper.find(opts.rowSel); var container = vars.container = rows.closest(opts.containerSel); vars.totalForms = wrapper .find('input[id$="' + opts.prefix + '-TOTAL_FORMS"]'); vars.maxForms = wrapper .find('input[id$="' + opts.prefix + '-MAX_NUM_FORMS"]'); // Clone the form template to generate new form instances var tpl = vars.tpl = container.find(opts.formTemplate).clone(true); container.find(opts.formTemplate).find('[required], .required') .removeAttr('required').removeData('required-by').addClass('required'); tpl.removeAttr('id').find('input, select, textarea').filter('[required]') .addClass('required').removeAttr('required'); // Add delete-trigger and insert-above-trigger (if applicable) to template methods.addDeleteTrigger(tpl, opts.canDelete, vars); methods.addInsertAboveTrigger(tpl, vars); // Iterate over existing rows... rows.each(function () { var thisRow = $(this); // Add delete-trigger and insert-above-trigger to existing rows methods.addDeleteTrigger( thisRow, (opts.canDelete && !opts.deleteOnlyNew), vars ); methods.addInsertAboveTrigger(thisRow, vars); // Attaches handlers watching for changes to inputs, // ...to add/remove ``required`` attr methods.watchForChangesToOptionalIfEmptyRow(thisRow, vars); }); // Unless using auto-added rows, add and/or activate trigger to add rows if (!opts.autoAdd) { methods.activateAddTrigger(vars); } // Add extra empty row, if applicable if (opts.alwaysShowExtra && opts.autoAdd) { methods.autoAddRow(vars); wrapper.closest('form').submit(function () { $(this).find(opts.rowSel).filter('.extra-row') .find('input, select, textarea').each(function () { $(this).removeAttr('name'); } ); }); } return wrapper; }, activateAddTrigger: function (vars) { var opts = vars.opts; var addButton; if (vars.wrapper.find(opts.addTriggerSel).length) { addButton = vars.addButton = vars.wrapper.find(opts.addTriggerSel); } else { addButton = vars.addButton = $(opts.addTrigger).appendTo(vars.wrapper); } // Hide the add-trigger if we've reach the maxForms limit if (!methods.showAddButton(vars)) { addButton.hide(); } addButton.click(function (e) { var trigger = $(this); var formCount = parseInt(vars.totalForms.val(), 10); var newRow = vars.tpl.clone(true).addClass('new-row'); var lastRow = vars.wrapper.find(opts.rowSel).last(); newRow.find('input, select, textarea').filter('.required') .attr('required', 'required'); if (opts.addAnimationSpeed) { if (lastRow.length) { newRow.hide().insertAfter(lastRow); } else { newRow.hide().insertBefore(trigger); } newRow.animate( {'height': 'toggle', 'opacity': 'toggle'}, opts.addAnimationSpeed ); } else { if (lastRow.length) { newRow.insertAfter(lastRow).show(); } else { newRow.insertBefore(trigger).show(); } } newRow.find('input, select, textarea, label').each(function () { methods.updateElementIndex($(this), opts.prefix, formCount); }); // Attaches handlers watching for changes to inputs, // ...to add/remove ``required`` attr methods.watchForChangesToOptionalIfEmptyRow(newRow, vars); vars.totalForms.val(formCount + 1); // Check if we've exceeded the maximum allowed number of forms: if (!methods.showAddButton(vars)) { trigger.hide(); } // If a post-add callback was supplied, call it with the added form: if (opts.addedCallback) { opts.addedCallback(newRow); } e.preventDefault(); }); }, // Attaches handlers watching for changes to inputs, // ...to add/remove ``required`` attr watchForChangesToOptionalIfEmptyRow: function (row, vars) { var opts = vars.opts; if (opts.optionalIfEmpty && row.is(opts.optionalIfEmptySel)) { var inputs = row.find('input, select, textarea'); inputs.filter('[required], .required').removeAttr('required') .data('required-by', opts.prefix).addClass('required'); row.data('original-vals', inputs.serialize()); inputs.not(opts.deleteTriggerSel).change(function () { methods.updateRequiredFields(row, vars); }); } }, // Replace ``-__prefix__`` with correct index in for, id, name attrs updateElementIndex: function (elem, prefix, ndx) { var idRegex = new RegExp('(' + prefix + '-(\\d+|__prefix__))'); var 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)); } }, // Check whether we can add more rows showAddButton: function (vars) { return ( vars.maxForms.val() === '' || (vars.maxForms.val() - vars.totalForms.val() > 0) ); }, // Add delete trigger to end of a row, or activate existing delete-trigger addDeleteTrigger: function (row, canDelete, vars) { var opts = vars.opts; if (canDelete) { // Add a delete-trigger to remove the row from the DOM $(opts.deleteTrigger).appendTo(row).click(function (e) { var thisRow = $(this).closest(opts.rowSel); var rows, i; var updateSequence = function (rows, i) { rows.eq(i).find('input, select, textarea, label').each(function () { methods.updateElementIndex($(this), opts.prefix, i); }); }; var removeRow = function () { thisRow.remove(); // Update the TOTAL_FORMS count: rows = vars.wrapper.find(opts.rowSel); vars.totalForms.val(rows.not('.extra-row').length); // Update names and IDs for all child controls, // ...so they remain in sequence. for (i = 0; i < rows.length; i = i + 1) { updateSequence(rows, i); } // If a post-delete callback was provided, call it with deleted form if (opts.removedCallback) { opts.removedCallback(thisRow); } }; if (opts.removeAnimationSpeed) { $.when( thisRow.animate( {'height': 'toggle', 'opacity': 'toggle'}, opts.removeAnimationSpeed ) ).done(removeRow); } else { removeRow(); } e.preventDefault(); }); } else { // If we're dealing with an inline formset, // ...just remove :required attrs when marking a row deleted row.find(opts.deleteTriggerSel).change(function () { var trigger = $(this); var thisRow = trigger.closest(opts.rowSel); if (trigger.prop('checked')) { thisRow.addClass(opts.deletedRowClass); thisRow.find('[required]').removeAttr('required') .addClass('deleted-required'); // If a post-delete callback was provided, call it with deleted form if (opts.removedCallback) { opts.removedCallback(thisRow); } } else { thisRow.removeClass(opts.deletedRowClass); thisRow.find('.deleted-required').attr('required', 'required') .removeClass('deleted-required'); } }); } }, // Add insert-above trigger before a row, if ``insertAboveTrigger: true`` addInsertAboveTrigger: function (row, vars) { var opts = vars.opts; if (opts.insertAbove) { $(opts.insertAboveTrigger).prependTo(row).click(function (e) { var thisRow = $(this).closest(opts.rowSel); var formCount = parseInt(vars.totalForms.val(), 10); var newRow = vars.tpl.clone(true).addClass('new-row'); var rows, i; var updateSequence = function (rows, i) { rows.eq(i).find('input, select, textarea, label').each(function () { methods.updateElementIndex($(this), opts.prefix, i); }); }; newRow.find('input, select, textarea').filter('.required') .attr('required', 'required'); if (opts.addAnimationSpeed) { newRow.hide().insertBefore(thisRow).animate( {'height': 'toggle', 'opacity': 'toggle'}, opts.addAnimationSpeed ); } else { newRow.insertBefore(thisRow).show(); } // Update the TOTAL_FORMS count: rows = vars.wrapper.find(opts.rowSel); vars.totalForms.val(formCount + 1); // Update names and IDs for child controls so they remain in sequence. for (i = 0; i < rows.length; i = i + 1) { updateSequence(rows, i); } // Attaches handlers watching for changes to inputs, // ...to add/remove ``required`` attr methods.watchForChangesToOptionalIfEmptyRow(newRow, vars); // Check if we've exceeded the maximum allowed number of rows: if (!methods.showAddButton(vars)) { $(this).hide(); } // If a post-add callback was supplied, call it with the added form: if (opts.addedCallback) { opts.addedCallback(newRow); } $(this).blur(); e.preventDefault(); }); } }, // Add a row automatically autoAddRow: function (vars) { var opts = vars.opts; var formCount = parseInt(vars.totalForms.val(), 10); var newRow = vars.tpl.clone(true); var rows = vars.wrapper.find(opts.rowSel); if (opts.addAnimationSpeed) { newRow.hide().css('opacity', 0).insertAfter(rows.last()) .addClass('extra-row').animate( {'height': 'toggle', 'opacity': '0.5'}, opts.addAnimationSpeed ); } else { newRow.css('opacity', 0.5).insertAfter(rows.last()) .addClass('extra-row'); } // When the extra-row receives focus... newRow.find('input, select, textarea, label').one('focus', function () { var el = $(this); var thisRow = el.closest(opts.rowSel); // fade it in thisRow.removeClass('extra-row').css('opacity', 1); // add "required" to appropriate inputs if not an "optionalIfEmpty" row if ( el.hasClass('required') && !(opts.optionalIfEmpty && newRow.is(opts.optionalIfEmptySel)) ) { el.attr('required', 'required'); } // update the totalForms count vars.totalForms.val( vars.wrapper.find(opts.rowSel).not('.extra-row').length ); // fade in the delete-trigger if (opts.deleteOnlyActive) { thisRow.find(opts.deleteTriggerSel).fadeIn(); } // and auto-add another extra-row if ( methods.showAddButton(vars) && thisRow.is(vars.wrapper.find(opts.rowSel).last()) ) { methods.autoAddRow(vars); } }).each(function () { var el = $(this); methods.updateElementIndex(el, opts.prefix, formCount); el.filter('[required]').removeAttr('required').addClass('required'); }); // Attaches handlers watching for changes to inputs, // ...to add/remove ``required`` attr methods.watchForChangesToOptionalIfEmptyRow(newRow, vars); // Hide the delete-trigger initially, if ``deleteOnlyActive: true`` if (opts.deleteOnlyActive) { newRow.find(opts.deleteTriggerSel).hide(); } // If a post-add callback was supplied, call it with the added form if (opts.addedCallback) { opts.addedCallback(newRow); } }, // Check if inputs have changed from original state, // ...and update ``required`` attr accordingly updateRequiredFields: function (row, vars) { var opts = vars.opts; var inputs = row.find('input, select, textarea'); var relevantInputs = inputs.filter(function () { return $(this).data('required-by') === opts.prefix; }); var state = inputs.serialize(); var originalState = row.data('original-vals'); if (state === originalState) { relevantInputs.removeAttr('required'); } else { relevantInputs.filter('.required').not('.deleted-required') .attr('required', 'required'); } }, // Expose internal methods to allow stubbing in tests exposeMethods: function () { return methods; } }; $.fn.superformset = function (method) { if (methods[method]) { return methods[method].apply( this, Array.prototype.slice.call(arguments, 1) ); } else if (typeof method === 'object' || !method) { return methods.init.apply(this, arguments); } else { $.error('Method ' + method + ' does not exist on jQuery.superformset'); } }; /* Setup plugin defaults */ $.fn.superformset.defaults = { prefix: 'form', // The form prefix for your django formset containerSel: 'form', // Container selector // ...(must contain rows and formTemplate) rowSel: '.dynamic-form', // Selector to match each row in a formset formTemplate: '.empty-form .dynamic-form', // Selector for empty form template to be // ...cloned to generate new form instances // ...Must be outside element on which formset // ...is called, but within containerSel deleteTrigger: 'remove', // The HTML "remove" link added to the end of // ...each form-row (if ``canDelete: true``) deleteTriggerSel: '.remove-row', // Selector for HTML "remove" links // ...Used to target existing delete-trigger, // ...or to target ``deleteTrigger`` addTrigger: 'add', // The HTML "add" link added to the end of all // ...forms if no ``addTriggerSel`` addTriggerSel: null, // Selector for trigger to add a new row // ...Used to target existing trigger // ...if provided, ``addTrigger`` is ignored addedCallback: null, // Fn called each time a new form row is added removedCallback: null, // Fn called each time a form row is deleted deletedRowClass: 'deleted', // Add to deleted row if ``canDelete: false`` addAnimationSpeed: 'normal', // Speed (ms) to animate adding rows // ...If false, new rows appear w/o animation removeAnimationSpeed: 'fast', // Speed (ms) to animate removing rows // ...If false, new rows disappear w/o anim. autoAdd: false, // If true, the "add" link will be removed, // ...and a row will be automatically added // ...when text is entered in the final // ...textarea of the last row alwaysShowExtra: false, // If true, an extra (empty) row will always // ...be displayed (req. ``autoAdd: true``) deleteOnlyActive: false, // If true, extra empty rows cannot be removed // ...until they acquire focus // ...(requires ``alwaysShowExtra: true``) canDelete: false, // If false, rows cannot be removed from DOM. // ...``deleteTriggerSel`` will remove // ...``required`` attr from fields within a // ..."deleted" row. // ...deleted rows should be hidden via CSS deleteOnlyNew: false, // If true, only newly-added rows can be // ...deleted (requires ``canDelete: true``) insertAbove: false, // If true, ``insertAboveTrigger`` will be // ...added to the end of each form-row insertAboveTrigger: 'insert', // The HTML "insert" link add to the end of // ...each row (req. ``insertAbove: true``) optionalIfEmpty: true, // If true, required fields in a row will be // ...optional until changed from initial vals optionalIfEmptySel: '[data-empty-permitted="true"]' // Selector for rows to apply optionalIfEmpty // ...logic (req. ``optionalIfEmpty: true``) }; }(jQuery));