/** * CloneYa!: Plugin to clone form elements in a nested manner * @author Saurabh Shukla * http://hookrefineandtinker.com * License GNU/GPL & MIT */ (function ($) { "use strict"; var name = "cloneya", defaults = { cloneThis: '.toclone', cloneButton: '.clone', deleteButton: '.delete', clonePosition: 'after', minimum: 1, // renaming limit maximum: 999, //setting it to a high number, by default //limit: 999, valueClone: false, dataClone: false, deepClone: false, serializeID: true, ignore: 'label.error', preserveChildCount: false }; /** * Create the class CloneYa * * @class CloneYa * @classdesc Adds cloning functionality to element * * @param {String | Object} element - the clone wrapper * * @param {Object} options - options to initialise with * * @param {String} options.cloneThis - Selector for the clone element * @param {String} options.cloneButton - Selector for the clone button * @param {String} options.deleteButton - Selector for the delete button * * @param {String} options.clonePosition - Where should the clone be added 'before' or 'after' * * @param {Number} options.limit - The maximum number of clones * * @param {Boolean} options.valueClone - Clone the input values as well? * @param {Boolean} options.dataClone - Clone the data attributes? * @param {Boolean} options.deepClone - Clone other data added to the jQuery object * * @param {Boolean} options.serializeID - Whether to serialize the IDs, automatically * @param {String} options.ignore - Selectors for clonables' elements that should not be cloned * @param {Boolean} options.defaultRender - Start with this number of clones, by default * @param {Boolean} options.preserveChildCount - whether to preserve the initial number of clone's child clones, works with nesting as well. * * @returns {_L13.CloneYa} */ function CloneYa(element, options) { /** * regex for recalculating the ids * * @type RegExp */ this.regex = /^(.*)(\d)+$/i; this.elem = element; this.$elem = $(element); this.elemClass = name + '-wrap'; /** * creating a jQuery object, just in case * * @type @call;$ */ //var elem = $(element); /** * Support deprecated parameters */ if (typeof options !== 'undefined') { if (typeof options.limit !== 'undefined' && options.limit > 0) { options.maximum = options.limit; } } /** * merge the passed options object with defaults * * @type @exp;$@call;extend */ this.config = $.extend({}, defaults, options); /** * * @type @exp;elem@call;closestChild */ this.clones = this.$elem.closestChild(this.config.cloneThis); this.init(); } CloneYa.prototype = { init: function () { var $this = this; // add our classes $this.$elem.addClass($this.elemClass); $this.clones.addClass(name); // save the sibling count into data attr $this.clones.data('initialCount', $this.clones.length); //Now, what if the clone button and delete button are not contained in //the clonable? // add a click handler for the clone buttons $this.$elem.on('click.' + name, $this.config.cloneThis + '>' + $this.config.cloneButton, function (event) { event.preventDefault(); event.stopPropagation(); var toClone = $(this).closest($this.config.cloneThis); // this is just a wrapper for the custom clone event $this.$elem.triggerAll('clone_clone clone.' + name, [toClone]); }); // the custom clone event $this.$elem.on('clone.' + name, function (event, toClone) { $this._cloneAndAppend(toClone); }); // click handler for delete button $this.$elem.on('click.' + name, $this.config.cloneThis + '>' + $this.config.deleteButton, function (event) { event.preventDefault(); event.stopPropagation(); var toDelete = $(this).closest($this.config.cloneThis); // just a wrapper for delclone event $this.$elem.triggerAll('clone_delete delete.' + name, [toDelete]); }); // the delete clone event $this.$elem.on('delete.' + name, function (event, toDelete) { // get the count of all the sibling clones /** * * @type @exp;$todelete@call;closest@call;closestChild@pro;length */ var cloneCount = toDelete.closest('.' + $this.elemClass).closestChild($this.config.cloneThis).length; if (cloneCount > $this.config.minimum) { // trigger hook $this.$elem.triggerAll('clone_before_delete before_delete.' + name, [toDelete, cloneCount]); $this.$elem.triggerHandler('remove.' + name, [toDelete]); $this.$elem.triggerAll('clone_after_delete after_delete.' + name); } else { $this.$elem.triggerHandler('minimum.' + name, $this.config.minimum, [toDelete]); // First clone form can't be deleted, but the values should be removed from first form // is this expected behaviour? especially since we use minimum? toDelete.find('input, textarea, select').each(function () { $this._clearForm($(this)); }); } }); $this.$elem.on('remove.' + name, function (event, toDelete) { $(toDelete).remove(); }); }, _clean: function () { var $this = this; $this.$elem.removeClass(name + '-wrap'); $this.clones.removeClass(name); $this.$elem.off('click.' + name, $this.config.cloneThis + '>' + $this.config.cloneButton); $this.$elem.off('click.' + name, $this.config.cloneThis + '>' + $this.config.deleteButton); $this.$elem.off('clone_clone clone_delete clone_before_delete clone.' + name + ' delete.' + name + ' before_delete.' + name); }, destroy: function () { this._clean(); this.$elem.removeData(name); }, getOption: function () { return this.config; }, setOption: function (lateOptions) { $.extend(this.config, lateOptions || {}); this._clean(); this.init(); }, _cloneAndAppend: function (toClone) { // get the count of all the sibling clones /** * * @type @exp;$toclone@call;closest@call;closestChild@pro;length */ var cloneCount = toClone.closest('.' + this.elemClass).closestChild(this.config.cloneThis).length; // check if we've reached the maximum limit if (cloneCount < this.config.maximum) { // trigger a custom event for hooking in this.$elem.triggerAll('clone_before_clone before_clone.' + name, [toClone]); var newClone = this._cloneItem(toClone); // trigger custom event on the original element this.$elem.triggerAll('clone_after_clone after_clone.' + name, [toClone, newClone]); // add to our clones object this.clones.add(newClone); // trigger custom event on the new clone this.$elem.triggerAll('clone_before_append before_append.' + name, [toClone, newClone]); // get the position where the clone has to be added // and add the newclone if (this.config.clonePosition !== 'after') { toClone.before(newClone); } else { toClone.after(newClone); } if (this.config.ignore) { newClone.find(this.config.ignore).remove(); } // reformat the id attributes this._redoIDs(); // trigger custom event for hooking this.$elem.triggerAll('clone_after_append after_append.' + name, [toClone, newClone]); } else { // trigger a custom event for hooking this.$elem.triggerAll('clone_limit maximum.' + name, this.config.maximum, [toClone]); } }, _cloneItem: function (toClone) { var $this = this; // clone it /** * * @type @exp;$toclone@call;clone */ var newClone = toClone.clone($this.config.dataClone, $this.config.deepClone); // we want to preserve the initial child count if ($this.config.preserveChildCount !== false) { // the child count only needs preservation if they are clonable. var originalChildren = toClone.find('.' + name + '-wrap'); // for each wrapper newClone.find('.' + name + '-wrap').each(function (index) { /** * * @type @call;jquery-cloneya_L8.$@call;closestChild */ var inNewClone = $(this).closestChild('.' + name); var inOriginal = $(originalChildren[index]).closestChild('.' + name); /** * * @type @exp;inOriginal@call;data */ var originalCount = inOriginal.data('initialCount'); /** * * @type @exp;inNewClone@call;slice */ var $extra = inNewClone.slice(originalCount, inNewClone.length); $extra.remove(); inNewClone.data('initial-count', originalCount); }); } // get the form input newClone.find('input, textarea, select').each(function () { // check if the values need to be copied, if not empty them $this._clearForm($(this)); // removed the portion taking care of the index // each case is specific and I'd rather leave it to the developer // custom event hook for index handling $this.$elem.triggerAll('clone_form_input form_input.' + name, [$(this), toClone, newClone]); }); return newClone; }, /* * Clear Form will used to clear the values of the form */ /** * * @param {type} $el * @returns {undefined} */ _clearForm: function ($el) { if (!this.config.valueClone && !$el.hasClass('noEmpty')) { if ($el.is(':checkbox') || $el.is(':radio')) { $el.prop('checked', false); } else { $el.val(''); } } }, /** * Redo the id attribute, serially */ /** * * @returns {undefined} */ _redoIDs: function () { var $this = this; // check if this even needs to be done if ($this.config.serializeID !== true) { return; } // get the id of the first clone (hoping to increment the ids) /** * * @type @exp;elem@call;find@call;first@call;attr */ var mainid = $this.$elem.find($this.config.cloneThis).first().attr('id'); $this.$elem.find($this.config.cloneThis).each(function (i) { var j; // assign the index to a string var for appending to the ids // 0 index will have no number at the end if (i !== 0) { j = i; } else { j = ''; } // first modify the clone id if ($(this).attr('id')) { $(this).attr('id', mainid + j); } var id, nId; // take all the elements inside the clone $(this).find('*').each(function () { id = $(this).attr('id'); if (id) { // match the id with the regex to get the string part // separate from the number part var match = id.match($this.regex); // if there was a number if (match && match.length === 3) { // just take the string part // add the new number to it nId = id.replace(/\d+$/, "") + j; $(this).attr('id', nId); } else { // else there was no number, // this was earlier the first element // just add the number to its id nId = id + j; $(this).attr('id', nId); } } //update label $(this).closest($this.config.cloneThis).find("label[for='" + id + "']").attr('for', nId); if ($this.config.serializeIndex) { var name = $(this).attr('name'); // This will increment the numeric array index for cloned field names if (name) { var matches = name.match(/\[([^}]+)\]/); if (matches && matches.length >= 1) { var st = name; name = [].map.call(st, function (s, n) { return (!isNaN(+s) && st[n - 1] === '[' && st[n + 1] === ']') ? i : s; }).join(''); $(this).attr('name', name); } } } }); }); } }; // add the cloneya to the global object /** * * @param {type} options * @returns {jquery-cloneya_L8.$.fn@call;each} */ $.fn[name] = function (options) { var args = arguments; if (options === undefined || typeof options === 'object') { // Creates a new plugin instance, for each selected element, and // stores a reference withint the element's data return this.each(function () { if (!$.data(this, name)) { $.data(this, name, new CloneYa(this, options)); } }); } else if (typeof options === 'string' && options[0] !== '_' && options !== 'init') { // Call a public pluguin method (not starting with an underscore) for each // selected element. if (Array.prototype.slice.call(args, 1).length === 0 && $.inArray(options, $.fn[name].getters) !== -1) { // If the user does not pass any arguments and the method allows to // work as a getter then break the chainability so we can return a value // instead the element reference. var instance = $.data(this[0], name); return instance[options].apply(instance, Array.prototype.slice.call(args, 1)); } else { // Invoke the speficied method on each selected element return this.each(function () { var instance = $.data(this, name); if (instance instanceof CloneYa && typeof instance[options] === 'function') { instance[options].apply(instance, Array.prototype.slice.call(args, 1)); } }); } } }; $.fn[name].getters = ['getOption']; /* * jquery.closestchild 0.1.1 * * Author: Andrey Mikhaylov aka lolmaus * Email: lolmaus@gmail.com * */ /** * * @param {type} selector * @returns {$} */ $.fn.closestChild = function (selector) { var $children, $results; $children = this.children(); if ($children.length === 0) { return $(); } $results = $children.filter(selector); if ($results.length > 0) { return $results; } else { return $children.closestChild(selector); } }; /* * TriggerAll, modified from stackoverflow * http://stackoverflow.com/questions/11850625/jquery-trigger-multiple-events */ $.fn.extend({ triggerAll: function (events, params) { var el = this, i, evts = events.split(' '); for (i = 0; i < evts.length; i += 1) { el.triggerHandler(evts[i], params); } return el; } }); })(jQuery);