diff --git a/docs/usage.rst b/docs/usage.rst index d87bd4c..47f13de 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -234,73 +234,106 @@ Formset options You can customize this plugin's behavior by passing an options hash. A complete list of available options is shown below:: - ``prefix`` - Use this to specify the prefix for your formset if it's anything - other than the default ("form"). This option must be supplied for - inline formsets. - - ``addText`` - Use this to set the text for the generated add link. The default - text is "add another". - - ``deleteText`` - Use this to set the text for the generated delete links. The - default text is "remove". - - ``addCssClass`` - Use this to change the default CSS class applied to the generated - add link (possibly, to avoid CSS conflicts within your templates). - The default class is "add-row". - - ``deleteCssClass`` - Use this to change the default CSS class applied to the generated - delete links. The default class is "delete-row". - - ``added`` - If you set this to a function, that function will be called each - time a new form is added. The function should take a single argument, - ``row``; it will be passed a jQuery object, wrapping the form that - was just added. - - ``removed`` - Set this to a function, and that function will be called each time - a form is deleted. The function should take a single argument, - ``row``; it will be passed a jQuery object, wrapping the form that - was just removed. +``prefix`` + Use this to specify the prefix for your formset if it's anything + other than the default ("form"). This option must be supplied for + inline formsets. + +``addText`` + Use this to set the text for the generated add link. The default + text is "add another". + +``deleteText`` + Use this to set the text for the generated delete links. The + default text is "remove". + +``addCssClass`` + Use this to change the default CSS class applied to the generated + add link (possibly, to avoid CSS conflicts within your templates). + The default class is "add-row". + +``deleteCssClass`` + Use this to change the default CSS class applied to the generated + delete links. The default class is "delete-row". + +``added`` + If you set this to a function, that function will be called each + time a new form is added. The function should take a single argument, + ``row``; it will be passed a jQuery object, wrapping the form that + was just added. + +``removed`` + Set this to a function, and that function will be called each time + a form is deleted. The function should take a single argument, + ``row``; it will be passed a jQuery object, wrapping the form that + was just removed. .. versionadded:: 1.1 - - ``formCssClass`` - Use this to set the CSS class applied to all forms within the same - formset. Internally, all forms with the same class are assumed to - belong to the same formset. If you have multiple formsets on a single - HTML page, you MUST provide unique class names for each formset. If - you don't provide a value, this defaults to "dynamic-form". - - For more information, see the section on :ref:`Using multiple Formsets - on the same page `, and check out the example - in the demo project. +``formCssClass`` + Use this to set the CSS class applied to all forms within the same + formset. Internally, all forms with the same class are assumed to + belong to the same formset. If you have multiple formsets on a single + HTML page, you MUST provide unique class names for each formset. If + you don't provide a value, this defaults to "dynamic-form". + + For more information, see the section on :ref:`Using multiple Formsets + on the same page `, and check out the example + in the demo project. .. versionadded:: 1.2 +``formTemplate`` + Use this to override the form that gets cloned, each time a new form + instance is added. If specified, this should be a jQuery selector. - ``formTemplate`` - Use this to override the form that gets cloned, each time a new form - instance is added. If specified, this should be a jQuery selector. +``extraClasses`` + Set this to an array of CSS class names (defaults to an empty array), + and the classes will be applied to each form in the formset in turn. + This can easily be used to acheive row-striping effects, which can + make large formsets easier to deal with visually. - ``extraClasses`` - Set this to an array of CSS class names (defaults to an empty array), - and the classes will be applied to each form in the formset in turn. - This can easily be used to acheive row-striping effects, which can - make large formsets easier to deal with visually. - .. versionadded:: 1.3 - - ``keepFieldValues`` - Set this to a jQuery selector, which should resolve to a list of elements - whose values should be preserved when the form is cloned. - Internally, this value is passed directly to the ``$.not(...)`` method. - This means you can also pass in DOM elements, or a function (in newer - versions of jQuery) as your selector. +``keepFieldValues`` + Set this to a jQuery selector, which should resolve to a list of elements + whose values should be preserved when the form is cloned. + Internally, this value is passed directly to the ``$.not(...)`` method. + This means you can also pass in DOM elements, or a function (in newer + versions of jQuery) as your selector. + +``addWrap`` + This value will be passed to jQuery ``.wrap`` to wrap the add button + in elements for additional styling. + +``deleteWrap`` + This value will be passed to jQuery ``.wrap`` to wrap the delete button + in elements for additional styling. + +``addInsert`` + If you set this to a function, that function will take responsibility + for inserting the add button into the page. It should take two + arguments: ``$$``, the original jQuery object you are applying the + formset to; and ``buttonRow``, the button element (or element + containing the button). + It does not need to return anything. + + If you do not set ``formInsert`` (see below), the button needs to be + added as a sibling of ``$$``. + + This can be used to put the add button at the top of the forms, or + above a hidden ``formTemplate`` to avoid styling issues. + +``formInsert`` + If you set this to a function, that function will take responsibility + for inserting new forms into the page and showing them (eg with jQuery + ``.show()``). It should take three arguments: + ``$$``, the original jQuery object you are applying the formset to; + ``buttonRow``, the row or element containing the add button; and + ``formRow``, the new row or element containing the new form. + It does not need to return anything. + + This can be used to add animations to reveal new forms + (eg ``formRow.insertBefore(buttonRow).slideDown();``), to insert new + forms above existing ones, or to use ``addInsert`` (see above) to place + the add button outside the formset. .. note:: The ``addCssClass`` and ``deleteCssClass`` options must be unique. Internally, the plugin uses the class names to target the add and delete diff --git a/src/jquery.formset.js b/src/jquery.formset.js index a47d9c0..70c4352 100644 --- a/src/jquery.formset.js +++ b/src/jquery.formset.js @@ -18,6 +18,7 @@ maxForms = $('#id_' + options.prefix + '-MAX_NUM_FORMS'), childElementSelector = 'input,select,textarea,label,div', $$ = $(this), + addButtonRow, applyExtraClasses = function(row, ndx) { if (options.extraClasses) { @@ -39,8 +40,13 @@ }, showAddButton = function() { + var forms = $$.not('.formset-custom-template'), + isInline = (forms.find('input:hidden[id $= "-DELETE"]').length > 0); return maxForms.length == 0 || // For Django versions pre 1.2 - (maxForms.val() == '' || (maxForms.val() - totalForms.val() > 0)); + maxForms.val() == '' || + (maxForms.val() - totalForms.val() > 0) || + (isInline && maxForms.val() - forms.filter(':visible').length > 0) + ; }, insertDeleteLink = function(row) { @@ -59,10 +65,9 @@ // last child element of the form's container: row.append('' + options.deleteText +''); } - row.find('a.' + delCssSelector).click(function() { + var delButton = 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. @@ -73,6 +78,7 @@ forms = $('.' + options.formCssClass).not(':hidden'); } else { row.remove(); + $$ = $$.not(row); // Update the TOTAL_FORMS count: forms = $('.' + options.formCssClass).not('.formset-custom-template'); totalForms.val(forms.length); @@ -89,11 +95,14 @@ } } // Check if we need to show the add button: - if (buttonRow.is(':hidden') && showAddButton()) buttonRow.show(); + if (addButtonRow.is(':hidden') && showAddButton()) addButtonRow.show(); // If a post-delete callback was provided, call it with the deleted form: if (options.removed) options.removed(row); return false; }); + if (options.deleteWrap) { + delButton.wrap(options.deleteWrap); + } }; $$.each(function(i) { @@ -125,8 +134,7 @@ }); if ($$.length) { - var hideAddButton = !showAddButton(), - addButton, template; + var 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); @@ -158,30 +166,51 @@ 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 = $('' + options.addText + '') - .addClass(options.formCssClass + '-add'); - $$.parent().append(buttonRow); - if (hideAddButton) buttonRow.hide(); - addButton = buttonRow.find('a'); + var numCols = $$.eq(0).children().length; // This is a bit of an assumption :| + addButtonRow = $('' + options.addText + '') + .addClass(options.formCssClass + '-add'); + if (options.addInsert) { + options.addInsert($$, addButtonRow); + } else { + $$.parent().append(addButtonRow); + } + addButton = addButtonRow.find('a'); } else { // Otherwise, insert it immediately after the last form: - $$.filter(':last').after('' + options.addText + ''); - addButton = $$.filter(':last').next(); - if (hideAddButton) addButton.hide(); + addButton = $('' + options.addText + ''); + addButtonRow = addButton; + if (options.addInsert) { + options.addInsert($$, addButton); + } else { + addButton.insertAfter($$.filter(':last')); + } + } + if (options.addWrap) { + // Wrap the addButton + addButton.wrap(options.addWrap); + // Find the wrapper's container by wrapping an empty element + // outside the DOM and finding how many parents it has + addButtonRow = addButton.parents().eq( + $('
').wrap(options.addWrap).parents().length - 1 + ); } + if (!showAddButton()) addButtonRow.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); + row = options.formTemplate.clone(true).removeClass('formset-custom-template'); applyExtraClasses(row, formCount); - row.insertBefore(buttonRow).show(); + if (options.formInsert) { + options.formInsert($$, addButtonRow, row); + } else { + row.insertBefore(addButtonRow).show(); + } + $$ = $$.add(row); row.find(childElementSelector).each(function() { updateElementIndex($(this), options.prefix, formCount); }); totalForms.val(formCount + 1); // Check if we've exceeded the maximum allowed number of forms: - if (!showAddButton()) buttonRow.hide(); + if (!showAddButton()) addButtonRow.hide(); // If a post-add callback was supplied, call it with the added form: if (options.added) options.added(row); return false; @@ -199,6 +228,10 @@ deleteText: 'remove', // Text for the delete link addCssClass: 'add-row', // CSS class applied to the add link deleteCssClass: 'delete-row', // CSS class applied to the delete link + addWrap: null, // Argument for jQuery .wrap for add link + deleteWrap: null, // Argument for jQuery .wrap for delete link + addInsert: null, // Function called to insert the add link + formInsert: null, // Function called to insert a new form 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 diff --git a/tests/basic.js b/tests/basic.js index b4b0c52..ad70e9a 100755 --- a/tests/basic.js +++ b/tests/basic.js @@ -8,6 +8,10 @@ assert.equal($.fn.formset.defaults.deleteText, 'remove', 'deleteText: "remove"'); assert.equal($.fn.formset.defaults.addCssClass, 'add-row', 'addCssClass: "add-row"'); assert.equal($.fn.formset.defaults.deleteCssClass, 'delete-row', 'deleteCssClass: "delete-row"'); + assert.equal($.fn.formset.defaults.addWrap, null, 'addWrap: null'); + assert.equal($.fn.formset.defaults.deleteWrap, null, 'deleteWrap: null'); + assert.equal($.fn.formset.defaults.addInsert, null, 'addInsert: null'); + assert.equal($.fn.formset.defaults.formInsert, null, 'formInsert: null'); assert.equal($.fn.formset.defaults.formCssClass, 'dynamic-form', 'formCssClass: "dynamic-form"'); assert.deepEqual($.fn.formset.defaults.extraClasses, [], 'extraClasses: []'); assert.equal($.fn.formset.defaults.keepFieldValues, '', 'keepFieldValues: '); @@ -178,4 +182,72 @@ assert.equal($('#id_form-TOTAL_FORMS').val(), '0', 'Updated "Total Forms" count.'); assert.equal($('#stacked-form div').size(), 0, 'Removed form.'); }); + + + module('Basic Formset Tests', { + setup: function () { + $('#stacked-form div').formset({ + addCssClass: 'btn-add', + deleteCssClass: 'btn-delete', + // Use spans to avoid clashing with #stacked-form divs + addWrap: '', + deleteWrap: '' + }); + } + }); + + test('Test Form Addition With addWrap', function (assert) { + var $btn = $('#stacked-form .btn-add'); + assert.equal($('#id_form-TOTAL_FORMS').val(), '1', 'Default form is present.'); + assert.ok($btn.hasClass('btn-add'), 'Add button has class "btn-add" applied to it.'); + assert.ok($btn.parent().is('span'), 'Add button is wrapped in "span".'); + assert.ok($btn.parent().hasClass('add-wrap'), 'Add button is wrapped in "span.add-wrap".'); + $btn.trigger('click'); + assert.equal($('#id_form-TOTAL_FORMS').val(), '2', 'Updated "Total Forms" count.'); + assert.equal($('#stacked-form div').size(), 2, 'Added new form.'); + }); + + test('Test Form Removal With deleteWrap', function (assert) { + var $btn = $('#stacked-form .btn-delete'); + assert.equal($('#id_form-TOTAL_FORMS').val(), '1', 'Default form is present.'); + assert.ok($btn.hasClass('btn-delete'), 'Remove button has class "btn-delete" applied to it.'); + assert.ok($btn.parent().is('span'), 'Remove button is wrapped in "span".'); + assert.ok($btn.parent().hasClass('delete-wrap'), 'Remove button is wrapped in "span.delete-wrap".'); + $btn.trigger('click'); + assert.equal($('#id_form-TOTAL_FORMS').val(), '0', 'Updated "Total Forms" count.'); + assert.equal($('#stacked-form div').size(), 0, 'Removed form.'); + }); + + + module('Basic Formset Tests', { + setup: function () { + $('#stacked-form div').formset({ + addCssClass: 'btn-add', + // Test by adding to the top instead of bottom + addInsert: function ($$, addButton) { + $$.parent().prepend(addButton); + }, + formInsert: function ($$, buttonRow, formRow) { + formRow.insertAfter(buttonRow).show(); + } + }); + } + }); + + test('Test Form Addition With custom addInsert and formInsert', function (assert) { + var idRegex = /id_form-(\d+)-(\w+)/, + $btn = $('#stacked-form .btn-add'); + assert.equal($('#id_form-TOTAL_FORMS').val(), '1', 'Default form is present.'); + assert.equal($('#stacked-form div').size(), 1, 'Default form is div.'); + assert.ok($btn.hasClass('btn-add'), 'Add button has class "btn-add" applied to it.'); + assert.equal($('#stacked-form :first')[0], $btn[0], 'Add button is at top.'); + + assert.equal($('#stacked-form div:first label').attr('for').match(idRegex)[1], 0, 'Default form is first form.'); + $btn.trigger('click'); + assert.equal($('#id_form-TOTAL_FORMS').val(), '2', 'Updated "Total Forms" count.'); + assert.equal($('#stacked-form div').size(), 2, 'Added new form.'); + assert.equal($('#stacked-form div:first label').attr('for').match(idRegex)[1], 1, 'Added form is first form.'); + assert.equal($('#stacked-form :first')[0], $btn[0], 'Add button is still at top.'); + }); + }(jQuery));