/**
* @file Jeditable - jQuery in place edit plugin
* @home https://github.com/NicolasCARPi/jquery_jeditable
* @author Mika Tuupola, Dylan Verheul, Nicolas CARPi
* @copyright © 2006 Mika Tuupola, Dylan Verheul, Nicolas CARPi
* @licence MIT (see LICENCE file)
* @name Jquery-jeditable
* @type jQuery
*
* @param {String|Function} target - URL or Function to send edited content to. Can also be 'disable', 'enable', or 'destroy'
* @param {Object} [options] - Additional options
* @param {Object} [options.ajaxoptions] - jQuery Ajax options. See https://api.jquery.com/jQuery.ajax/
* @param {Function} [options.before] - Function to be executed before going into edit mode
* @param {Function} [options.callback] - function(result, settings, submitdata) Function to run after submitting edited content
* @param {String} [options.cancel] - Cancel button value, empty means no button
* @param {String} [options.cancelcssclass] - CSS class to apply to cancel button
* @param {Number} [options.cols] - Number of columns if using textarea
* @param {String} [options.cssclass] - CSS class to apply to input form; use 'inherit' to copy from parent
* @param {String} [options.inputcssclass] - CSS class to apply to input. 'inherit' to copy from parent
* @param {Function} [options.intercept] - Intercept the returned data so you have a chance to process it before returning it in the page
* @param {String|Function} [options.data] - Content loaded in the form
* @param {String} [options.event='click'] - jQuery event such as 'click' or 'dblclick'. See https://api.jquery.com/category/events/
* @param {String} [options.formid] - Give an id to the form that is produced
* @param {String|Number} [options.height='auto'] - Height of the element in pixels or 'auto' or 'none'
* @param {String} [options.id='id'] - POST parameter name of edited div id
* @param {String} [options.indicator] - Indicator html to show when saving
* @param {String} [options.label] - Label for the form
* @param {String} [options.list] - HTML5 attribute for text input. Will suggest from a datalist with id of the list option
* @param {String|Function} [options.loaddata] - Extra parameters to pass when fetching content before editing
* @param {String} [options.loadtext='Loading…'] - Text to display while loading external content
* @param {String} [options.loadtype='GET'] - Request type for loadurl (GET or POST)
* @param {String} [options.loadurl] - URL to fetch input content before editing
* @param {Number} [options.max] - Maximum value for number type
* @param {String} [options.maxlength] - The maximum number of character in the text field
* @param {String} [options.method] - Method to use to send edited content (POST or PUT)
* @param {Number} [options.min] - Mininum value for number type
* @param {Boolean} [options.multiple] - Allow multiple selections in a select input
* @param {String} [options.name='value'] - POST parameter name of edited content
* @param {String|Function} [options.onblur='cancel'] - Use 'cancel', 'submit', 'ignore' or function. If function returns false, the form is cancelled.
* @param {Function} [options.onedit] - function triggered upon edition; will cancel edition if it returns false
* @param {Function} [options.onerror] - function(settings, original, xhr) { ... } called on error
* @param {Function} [options.onreset] - function(settings, original) { ... } called before reset
* @param {Function} [options.onsubmit] - function(settings, original) { ... } called before submit
* @param {String} [options.pattern] - HTML5 attribute for text or URL input
* @param {String} [options.placeholder='Click to edit'] - Placeholder text or html to insert when element is empty
* @param {Number} [options.rows] - number of rows if using textarea
* @param {Boolean} [options.select] - When true text is selected
* @param {Function} [options.showfn]- Function that can animate the element when switching to edit mode
* @param {String} [options.size] - The size of the text field
* @param {String} [options.sortselectoptions] - Sort the options of a select form
* @param {Number} [options.step] - Step size for number type
* @param {String} [options.style] - Style to apply to input form; 'inherit' to copy from parent
* @param {String} [options.submit] - submit button value, empty means no button
* @param {String} [options.submitcssclass] - CSS class to apply to submit button
* @param {Object|Function} [options.submitdata] - Extra parameters to send when submitting edited content. function(revert, settings, submitdata)
* @param {String} [options.tooltip] - Tooltip text that appears on hover (via title attribute)
* @param {String} [options.type='text'] - text, textarea, select, email, number, url (or any 3rd party input type)
* @param {String|Number} [options.width='auto'] - The width of the element in pixels or 'auto' or 'none'
*
* @example
Simple usage example:
* $(".editable").editable("save.php", {
* cancel : 'Cancel',
* submit : 'Save',
* tooltip : "Click to edit...",
* });
*/
(function($) {
'use strict';
// Keyboard accessibility/WAI-ARIA - allow users to navigate to an editable element using TAB/Shift+TAB
$.fn.editableAriaShim = function () {
this.attr({
role: 'button',
tabindex: 0
});
return this; // <-- object chaining.
};
// EDITABLE function
$.fn.editable = function(target, options) {
if ('disable' === target) {
$(this).data('disabled.editable', true);
return;
}
if ('enable' === target) {
$(this).data('disabled.editable', false);
return;
}
if ('destroy' === target) {
$(this)
.off($(this).data('event.editable'))
.removeData('disabled.editable')
.removeData('event.editable');
return;
}
var settings = $.extend({}, $.fn.editable.defaults, {target:target}, options);
/* setup some functions */
var plugin = $.editable.types[settings.type].plugin || function() { };
var submit = $.editable.types[settings.type].submit || function() { };
var buttons = $.editable.types[settings.type].buttons || $.editable.types.defaults.buttons;
var content = $.editable.types[settings.type].content || $.editable.types.defaults.content;
var element = $.editable.types[settings.type].element || $.editable.types.defaults.element;
var reset = $.editable.types[settings.type].reset || $.editable.types.defaults.reset;
var destroy = $.editable.types[settings.type].destroy || $.editable.types.defaults.destroy;
var callback = settings.callback || function() { };
var intercept = settings.intercept || function(s) { return s; };
var onedit = settings.onedit || function() { };
var onsubmit = settings.onsubmit || function() { };
var onreset = settings.onreset || function() { };
var onerror = settings.onerror || reset;
var before = settings.before || false;
// TOOLTIP
if (settings.tooltip) {
$(this).attr('title', settings.tooltip);
}
return this.each(function() {
/* Save this to self because this changes when scope changes. */
var self = this;
/* Save so it can be later used by $.editable('destroy') */
$(this).data('event.editable', settings.event);
/* If element is empty add something clickable (if requested) */
if (!$.trim($(this).html())) {
$(this).html(settings.placeholder);
}
if ('destroy' === target) {
destroy.apply($(this).find('form'), [settings, self]);
return;
}
// EVENT IS FIRED
$(this).on(settings.event, function(e) {
/* Abort if element is disabled. */
if (true === $(this).data('disabled.editable')) {
return;
}
// do nothing if user press Tab again, just go to next element, not into edit mode
if (e.which === 9) {
return;
}
/* Prevent throwing an exeption if edit field is clicked again. */
if (self.editing) {
return;
}
/* Abort if onedit hook returns false. */
if (false === onedit.apply(this, [settings, self, e])) {
return;
}
/* execute the before function if any was specified */
if (settings.before && jQuery.isFunction(settings.before)) {
settings.before(e);
} else if (settings.before && !jQuery.isFunction(settings.before)) {
throw "The 'before' option needs to be provided as a function!";
}
/* Prevent default action and bubbling. */
e.preventDefault();
e.stopPropagation();
/* Remove tooltip. */
if (settings.tooltip) {
$(self).removeAttr('title');
}
/* Remove placeholder text, replace is here because of IE. */
if ($(this).html().toLowerCase().replace(/(;|"|\/)/g, '') ===
settings.placeholder.toLowerCase().replace(/(;|"|\/)/g, '')) {
$(this).html('');
}
self.editing = true;
self.revert = $(self).text();
$(self).html('');
/* Create the form object. */
var form = $('');
/* Apply css or style or both. */
if (settings.cssclass) {
if ('inherit' === settings.cssclass) {
form.attr('class', $(self).attr('class'));
} else {
form.attr('class', settings.cssclass);
}
}
if (settings.style) {
if ('inherit' === settings.style) {
form.attr('style', $(self).attr('style'));
/* IE needs the second line or display wont be inherited. */
form.css('display', $(self).css('display'));
} else {
form.attr('style', settings.style);
}
}
// add a label if it exists
if (settings.label) {
form.append('');
}
// add an ID to the form
if (settings.formid) {
form.attr('id', settings.formid);
}
/* Add main input element to form and store it in input. */
var input = element.apply(form, [settings, self]);
if (settings.inputcssclass) {
if ('inherit' === settings.inputcssclass) {
input.attr('class', $(self).attr('class'));
} else {
input.attr('class', settings.inputcssclass);
}
}
/* Set input content via POST, GET, given data or existing value. */
var input_content;
// timeout function
var t;
var isSubmitting = false;
if (settings.loadurl) {
t = self.setTimeout(function() {
input.disabled = true;
}, 100);
$(self).html(settings.loadtext);
var loaddata = {};
loaddata[settings.id] = self.id;
if ($.isFunction(settings.loaddata)) {
$.extend(loaddata, settings.loaddata.apply(self, [self.revert, settings]));
} else {
$.extend(loaddata, settings.loaddata);
}
$.ajax({
type : settings.loadtype,
url : settings.loadurl,
data : loaddata,
async: false,
cache : false,
success: function(result) {
self.clearTimeout(t);
input_content = result;
input.disabled = false;
}
});
} else if (settings.data) {
input_content = settings.data;
if ($.isFunction(settings.data)) {
input_content = settings.data.apply(self, [self.revert, settings]);
}
} else {
input_content = self.revert;
}
content.apply(form, [input_content, settings, self]);
input.attr('name', settings.name);
/* adjust the width of the element to account for the margin/padding/border */
if (settings.width !== 'none') {
var adj_width = settings.width - (input.outerWidth(true) - settings.width);
input.width(adj_width);
}
/* Add buttons to the form. */
buttons.apply(form, [settings, self]);
/* Add created form to self. */
if (settings.showfn && $.isFunction(settings.showfn)) {
form.hide();
}
// clear the loadtext that we put here before
$(self).html('');
$(self).append(form);
// execute the showfn
if (settings.showfn && $.isFunction(settings.showfn)) {
settings.showfn(form);
}
/* Attach 3rd party plugin if requested. */
plugin.apply(form, [settings, self]);
/* Focus to first visible form element. */
form.find(':input:visible:enabled:first').trigger('focus');
/* Highlight input contents when requested. */
if (settings.select) {
input.select();
}
/* discard changes if pressing esc */
$(this).on('keydown', function(e) {
if (e.which === 27) {
e.preventDefault();
reset.apply(form, [settings, self]);
/* allow shift+enter to submit form (required for textarea) */
} else if (e.which == 13 && e.shiftKey){
e.preventDefault();
form.trigger('submit');
}
});
/* Discard, submit or nothing with changes when clicking outside. */
/* Do nothing is usable when navigating with tab. */
if ('cancel' === settings.onblur) {
input.on('blur', function(e) {
/* Prevent canceling if submit was clicked. */
t = self.setTimeout(function() {
reset.apply(form, [settings, self]);
}, 500);
});
} else if ('submit' === settings.onblur) {
input.on('blur', function(e) {
/* Prevent double submit if submit was clicked. */
t = self.setTimeout(function() {
form.trigger('submit');
}, 200);
});
} else if ($.isFunction(settings.onblur)) {
input.on('blur', function(e) {
// reset the form if the onblur function returns false
if (false === settings.onblur.apply(self, [input.val(), settings, form])) {
reset.apply(form, [settings, self]);
}
});
}
form.on('submit', function(e) {
/* Do no submit. */
e.preventDefault();
e.stopPropagation();
if (isSubmitting) {
// we are already submitting! Stop right here.
return false;
} else {
isSubmitting = true;
}
if (t) {
self.clearTimeout(t);
}
/* Call before submit hook. */
/* If it returns false abort submitting. */
isSubmitting = false !== onsubmit.apply(form, [settings, self]);
if (isSubmitting) {
/* Custom inputs call before submit hook. */
/* If it returns false abort submitting. */
isSubmitting = false !== submit.apply(form, [settings, self]);
if (isSubmitting) {
/* Check if given target is function */
if ($.isFunction(settings.target)) {
/* Callback function to handle the target reponse */
var responseHandler = function(value, complete) {
isSubmitting = false;
if (false !== complete) {
$(self).html(value);
self.editing = false;
callback.apply(self, [self.innerText, settings]);
if (!$.trim($(self).html())) {
$(self).html(settings.placeholder);
}
}
};
/* Call the user target function */
var userTarget = settings.target.apply(self, [input.val(), settings, responseHandler]);
/* Handle the target function return for compatibility */
if (false !== userTarget && undefined !== userTarget) {
responseHandler(userTarget, userTarget);
}
} else {
/* Add edited content and id of edited element to POST. */
var submitdata = {};
submitdata[settings.name] = input.val();
submitdata[settings.id] = self.id;
/* Add extra data to be POST:ed. */
if ($.isFunction(settings.submitdata)) {
$.extend(submitdata, settings.submitdata.apply(self, [self.revert, settings, submitdata]));
} else {
$.extend(submitdata, settings.submitdata);
}
/* Quick and dirty PUT support. */
if ('PUT' === settings.method) {
submitdata._method = 'put';
}
// SHOW INDICATOR
$(self).html(settings.indicator);
/* Defaults for ajaxoptions. */
var ajaxoptions = {
type : 'POST',
complete: function (xhr, status) {
isSubmitting = false;
},
data : submitdata,
dataType: 'html',
url : settings.target,
success : function(result, status) {
// INTERCEPT
result = intercept.apply(self, [result, status]);
if (ajaxoptions.dataType === 'html') {
$(self).html(result);
}
self.editing = false;
callback.apply(self, [result, settings, submitdata]);
if (!$.trim($(self).html())) {
$(self).html(settings.placeholder);
}
},
error : function(xhr, status, error) {
onerror.apply(form, [settings, self, xhr]);
}
};
/* Override with what is given in settings.ajaxoptions. */
$.extend(ajaxoptions, settings.ajaxoptions);
$.ajax(ajaxoptions);
}
}
}
/* Show tooltip again. */
$(self).attr('title', settings.tooltip);
return false;
});
});
// PRIVILEGED METHODS
// RESET
self.reset = function(form) {
/* Prevent calling reset twice when blurring. */
if (self.editing) {
/* Before reset hook, if it returns false abort reseting. */
if (false !== onreset.apply(form, [settings, self])) {
$(self).text(self.revert);
self.editing = false;
if (!$.trim($(self).html())) {
$(self).html(settings.placeholder);
}
/* Show tooltip again. */
if (settings.tooltip) {
$(self).attr('title', settings.tooltip);
}
}
}
};
// DESTROY
self.destroy = function(form) {
$(self)
.off($(self).data('event.editable'))
.removeData('disabled.editable')
.removeData('event.editable');
self.clearTimeouts();
if (self.editing) {
reset.apply(form, [settings, self]);
}
};
// CLEARTIMEOUT
self.clearTimeout = function(t) {
var timeouts = $(self).data('timeouts');
clearTimeout(t);
if(timeouts) {
var i = timeouts.indexOf(t);
if(i > -1) {
timeouts.splice(i, 1);
if(timeouts.length <= 0) {
$(self).removeData('timeouts');
}
} else {
console.warn('jeditable clearTimeout could not find timeout '+t);
}
}
};
// CLEAR ALL TIMEOUTS
self.clearTimeouts = function () {
var timeouts = $(self).data('timeouts');
if(timeouts) {
for(var i = 0, n = timeouts.length; i < n; ++i) {
clearTimeout(timeouts[i]);
}
timeouts.length = 0;
$(self).removeData('timeouts');
}
};
// SETTIMEOUT
self.setTimeout = function(callback, time) {
var timeouts = $(self).data('timeouts');
var t = setTimeout(function() {
callback();
self.clearTimeout(t);
}, time);
if(!timeouts) {
timeouts = [];
$(self).data('timeouts', timeouts);
}
timeouts.push(t);
return t;
};
});
};
var _supportInType = function (type) {
var i = document.createElement('input');
i.setAttribute('type', type);
return i.type !== 'text' ? type : 'text';
};
$.editable = {
types: {
defaults: {
element : function(settings, original) {
var input = $('');
$(this).append(input);
return(input);
},
content : function(string, settings, original) {
$(this).find(':input:first').val(string);
},
reset : function(settings, original) {
original.reset(this);
},
destroy: function(settings, original) {
original.destroy(this);
},
buttons : function(settings, original) {
var form = this;
var submit;
if (settings.submit) {
/* If given html string use that. */
if (settings.submit.match(/>$/)) {
submit = $(settings.submit).on('click', function() {
if (submit.attr('type') !== 'submit') {
form.trigger('submit');
}
});
/* Otherwise use button with given string as text. */
} else {
submit = $('');
submit.html(settings.submit);
if (settings.submitcssclass) {
submit.addClass(settings.submitcssclass);
}
}
$(this).append(submit);
}
if (settings.cancel) {
var cancel;
/* If given html string use that. */
if (settings.cancel.match(/>$/)) {
cancel = $(settings.cancel);
/* otherwise use button with given string as text */
} else {
cancel = $('');
cancel.html(settings.cancel);
if (settings.cancelcssclass) {
cancel.addClass(settings.cancelcssclass);
}
}
$(this).append(cancel);
$(cancel).on('click', function(event) {
var reset;
if ($.isFunction($.editable.types[settings.type].reset)) {
reset = $.editable.types[settings.type].reset;
} else {
reset = $.editable.types.defaults.reset;
}
reset.apply(form, [settings, original]);
return false;
});
}
}
},
text: {
element : function(settings, original) {
var input = $('').attr({
autocomplete: 'off',
list: settings.list,
maxlength: settings.maxlength,
pattern: settings.pattern,
placeholder: settings.placeholder,
tooltip: settings.tooltip,
type: 'text'
});
if (settings.width !== 'none') {
input.css('width', settings.width);
}
if (settings.height !== 'none') {
input.css('height', settings.height);
}
if (settings.size) {
input.attr('size', settings.size);
}
if (settings.maxlength) {
input.attr('maxlength', settings.maxlength);
}
$(this).append(input);
return(input);
}
},
// TEXTAREA
textarea: {
element : function(settings, original) {
var textarea = $('');
if (settings.rows) {
textarea.attr('rows', settings.rows);
} else if (settings.height !== 'none') {
textarea.height(settings.height);
}
if (settings.cols) {
textarea.attr('cols', settings.cols);
} else if (settings.width !== 'none') {
textarea.width(settings.width);
}
if (settings.maxlength) {
textarea.attr('maxlength', settings.maxlength);
}
$(this).append(textarea);
return(textarea);
}
},
// SELECT
select: {
element : function(settings, original) {
var select = $('');
if (settings.multiple) {
select.attr('multiple', 'multiple');
}
$(this).append(select);
return(select);
},
content : function(data, settings, original) {
var json;
// If it is string assume it is json
if (String === data.constructor) {
json = JSON.parse(data);
} else {
// Otherwise assume it is a hash already
json = data;
}
// Create tuples for sorting
var tuples = [];
var key;
if (Array.isArray(json) && json.every(Array.isArray)) {
// Process list of tuples
tuples = json // JSON already contains list of [key, value]
json = {};
tuples.forEach(function(e) {
json[e[0]] = e[1]; // Recreate json object to comply with following code
});
} else {
// Process object
for (key in json) {
tuples.push([key, json[key]]); // Store: [key, value]
}
}
if (settings.sortselectoptions) {
// sort it
tuples.sort(function (a, b) {
a = a[1];
b = b[1];
return a < b ? -1 : (a > b ? 1 : 0);
});
}
// now add the options to our select
var option;
for (var i = 0; i < tuples.length; i++) {
key = tuples[i][0];
var value = tuples[i][1];
if (!json.hasOwnProperty(key)) {
continue;
}
if (key !== 'selected') {
option = $('').val(key).append(value);
// add the selected prop if it's the same as original or if the key is 'selected'
if (json.selected === key || key === $.trim(original.revert)) {
$(option).prop('selected', 'selected');
}
$(this).find('select').append(option);
}
}
// submit on change if no submit button defined
if (!settings.submit) {
var form = this;
$(this).find('select').change(function() {
form.trigger('submit');
});
}
}
},
// NUMBER
number: {
element: function (settings, original) {
var input = $('').attr({
maxlength: settings.maxlength,
placeholder: settings.placeholder,
min : settings.min,
max : settings.max,
step: settings.step,
tooltip: settings.tooltip,
type: _supportInType('number')
});
if (settings.width !== 'none') {
input.css('width', settings.width);
}
$(this).append(input);
return input;
}
},
// EMAIL
email: {
element: function (settings, original) {
var input = $('').attr({
maxlength: settings.maxlength,
placeholder: settings.placeholder,
tooltip: settings.tooltip,
type: _supportInType('email')
});
if (settings.width !== 'none') {
input.css('width', settings.width);
}
$(this).append(input);
return input;
}
},
// URL
url: {
element: function (settings, original) {
var input = $('').attr({
maxlength: settings.maxlength,
pattern: settings.pattern,
placeholder: settings.placeholder,
tooltip: settings.tooltip,
type: _supportInType('url')
});
if (settings.width !== 'none') {
input.css('width', settings.width);
}
$(this).append(input);
return input;
}
}
},
// add new input type
addInputType: function(name, input) {
$.editable.types[name] = input;
}
};
/* Publicly accessible defaults. */
$.fn.editable.defaults = {
name : 'value',
id : 'id',
type : 'text',
width : 'auto',
height : 'auto',
// Keyboard accessibility - use mouse click OR press any key to enable editing
event : 'click.editable keydown.editable',
onblur : 'cancel',
tooltip : 'Click to edit',
loadtype : 'GET',
loadtext : 'Loading...',
placeholder: 'Click to edit',
sortselectoptions: false,
loaddata : {},
submitdata : {},
ajaxoptions: {}
};
})(jQuery);