"use strict";
/**
* @module gallery-quickedit
*/
/**
* <p>The QuickEdit plugin provides a new mode for DataTable where all
* values in the table can be edited simultaneously, controlled by the
* column configuration. Each editable cell contains an input field. If
* the user decides to save the changes, then you can extract the changed
* values by calling <code><i>dt</i>.qe.getChanges()</code>.</p>
*
* <p>For a column to be editable in QuickEdit mode, the column
* configuration must include <code>quickEdit</code>. The contents of
* this object define the column's behavior in QuickEdit mode.</p>
*
* <p>To move up or down within a column while in QuickEdit mode, hold down
* the Ctrl key and press the up or down arrow.</p>
*
* <p>If a column should not be editable, but needs to be formatted
* differently in QuickEdit mode, then you must define qeFormatter in
* the column configuration. This is simply a normal cell formatter
* function that will be used in QuickEdit mode. The static functions
* <code>readonly*Formatter</code> provide examples.</p>
*
* <p>The following configuration can be provided as part of
* quickEdit:</p>
*
* <dl>
*
* <dt>changed</dt><dd>Optional. The function to call with the old and new
* value. Should return true if the values are different.</dd>
*
* <dt>formatter</dt><dd>The cell formatter which will render an
* appropriate form field: <input type="text">, <textarea>,
* or <select>.</dd>
*
* <dt>validation</dt><dd>Validation configuration for every field in
* the column.</dd>
*
* <dt>copyDown</dt><dd>If true, the top cell in the column will have a
* button to copy the value down to the rest of the rows.</dd>
*
* </dl>
*
* <p>The following configuration can be provided as part of
* quickEdit.validation:</p>
*
* <dl>
*
* <dt>css</dt><dd>CSS classes encoding basic validation rules:
* <dl>
* <dt><code>yiv-required</code></dt>
* <dd>Value must not be empty.</dd>
*
* <dt><code>yiv-length:[x,y]</code></dt>
* <dd>String must be at least x characters and at most y characters.
* At least one of x and y must be specified.</dd>
*
* <dt><code>yiv-integer:[x,y]</code></dt>
* <dd>The integer value must be at least x and at most y.
* x and y are both optional.</dd>
*
* <dt><code>yiv-decimal:[x,y]</code></dt>
* <dd>The decimal value must be at least x and at most y. Exponents are
* not allowed. x and y are both optional.</dd>
* </dl>
* </dd>
*
* <dt>fn</dt><dd>A function that will be called with the DataTable as its
* scope and the cell's form element as the argument. Return true if the
* value is valid. Otherwise, call this.qe.displayMessage(...) to display
* an error and return false.</dd>
*
* <dt>msg</dt><dd>A map of types to messages that will be displayed
* when a basic or regex validation rule fails. The valid types are:
* required, min_length, max_length, integer, decimal, and regex.
* There is no default for type regex, so you must specify a message if
* you configure a regex validation.</dd>
*
* <dt>regex</dt><dd>Regular expression that the value must satisfy in
* order to be considered valid.</dd>
*
* </dl>
*
* <p>Custom QuickEdit Formatters</p>
*
* <p>To write a custom cell formatter for QuickEdit mode, you must
* structure the function as follows:</p>
*
* <pre>
* function myQuickEditFormatter(o) {
* var markup =
* '<input type="text" class="{yiv} quickedit-field quickedit-key:{key}" value="{value}"/>' +
* '{cd}' + Y.Plugin.DataTableQuickEdit.error_display_markup;
*
* var qe = o.column.quickEdit;
* return Y.Lang.sub(markup, {
* key: o.column.key,
* value: o.value.toString().replace(/"/g, '"'),
* yiv: qe.validation ? (qe.validation.css || '') : '',
* cd: QuickEdit.copyDownFormatter.call(this, o)
* });
* };
* </pre>
*
* <p>You can use textarea or select instead of input, but you can only
* create a single field.</p>
*
* <p><code>extractMyEditableValue</code> does not have to be a separate
* function. The work should normally be done inline in the formatter
* function, but the name of the sample function makes the point clear.</p>
*
* @main gallery-quickedit
* @class DataTableQuickEdit
* @namespace Plugin
* @extends Plugin.Base
* @constructor
* @param config {Object} Object literal to set component configuration.
*/
function QuickEdit(config)
{
QuickEdit.superclass.constructor.call(this, config);
}
QuickEdit.NAME = "QuickEditPlugin";
QuickEdit.NS = "qe";
QuickEdit.ATTRS =
{
/**
* @attribute changesAlwaysInclude
* @description Record keys to always include in result from getChanges().
* @type Array
*/
changesAlwaysInclude:
{
value: [],
validator: Y.Lang.isArray
},
/**
* @attribute includeAllRowsInChanges
* @description If true, getChanges() returns a record for every row, even if the record is empty. Set to false if you want getChanges() to only return records that contain data.
* @type Boolean
* @default true
*/
includeAllRowsInChanges:
{
value: true,
validator: Y.Lang.isBoolean
},
/**
* @attribute includeRowIndexInChanges
* @description If true, getChanges() includes the row index in each record, using the _row_index key.
* @type Boolean
* @default false
*/
includeRowIndexInChanges:
{
value: false,
validator: Y.Lang.isBoolean
}
};
var quick_edit_re = /quickedit-key:([^\s]+)/,
qe_row_status_prefix = 'quickedit-has',
qe_row_status_pattern = qe_row_status_prefix + '([a-z]+)',
qe_row_status_re = new RegExp(Y.Node.class_re_prefix + qe_row_status_pattern + Y.Node.class_re_suffix),
qe_cell_status_prefix = 'quickedit-has',
qe_cell_status_pattern = qe_cell_status_prefix + '([a-z]+)',
qe_cell_status_re = new RegExp(Y.Node.class_re_prefix + qe_cell_status_pattern + Y.Node.class_re_suffix);
/**
* The CSS class that marks the container for the error message inside a cell.
*
* @property error_text_class
* @static
* @type {String}
*/
QuickEdit.error_text_class = 'quickedit-message-text';
/**
* The markup for the container for the error message inside a cell.
*
* @property error_display_markup
* @static
* @type {String}
*/
QuickEdit.error_display_markup = '<div class="quickedit-message-text"></div>';
/**
* The CSS class that marks the "Copy Down" button inside a cell.
*
* @property copy_down_button_class
* @static
* @type {String}
*/
QuickEdit.copy_down_button_class = 'quickedit-copy-down';
/**
* Called with exactly the same arguments as any other cell
* formatter, this function displays an input field.
*
* @method textFormatter
* @static
* @param o {Object} standard DataTable formatter data
*/
QuickEdit.textFormatter = function(o)
{
var markup =
'<input type="text" class="{yiv} quickedit-field quickedit-key:{key}" value="{value}"/>' +
'{cd}' + QuickEdit.error_display_markup;
var qe = o.column.quickEdit;
return Y.Lang.sub(markup,
{
key: o.column.key,
value: o.value.toString().replace(/"/g, '"'),
yiv: qe.validation ? (qe.validation.css || '') : '',
cd: QuickEdit.copyDownFormatter.call(this, o)
});
};
/**
* Called with exactly the same arguments as any other cell
* formatter, this function displays a textarea field.
*
* @method textareaFormatter
* @static
* @param o {Object} standard DataTable formatter data
*/
QuickEdit.textareaFormatter = function(o)
{
var markup =
'<textarea class="{yiv} quickedit-field quickedit-key:{key}">{value}</textarea>' +
'{cd}' + QuickEdit.error_display_markup;
var qe = o.column.quickEdit;
return Y.Lang.sub(markup,
{
key: o.column.key,
value: o.value,
yiv: qe.validation ? (qe.validation.css || '') : '',
cd: QuickEdit.copyDownFormatter.call(this, o)
});
};
/**
* Called with exactly the same arguments as any other cell
* formatter, this function displays an email address without the
* anchor tag. Use this as the column's qeFormatter if the column
* should not be editable in QuickEdit mode.
*
* @method readonlyEmailFormatter
* @static
* @param o {Object} standard DataTable formatter data
*/
QuickEdit.readonlyEmailFormatter = function(o)
{
return (o.value || ''); // don't need to check for zero
};
/**
* Called with exactly the same arguments as any other cell
* formatter, this function displays a link without the anchor tag.
* Use this as the column's qeFormatter if the column should not be
* editable in QuickEdit mode.
*
* @method readonlyLinkFormatter
* @static
* @param o {Object} standard DataTable formatter data
*/
QuickEdit.readonlyLinkFormatter = function(o)
{
return (o.value || ''); // don't need to check for zero
};
/*
* Copy value from first cell to all other cells in the column.
*
* @method copyDown
* @private
* @param e {Event} triggering event
*/
function copyDown(e)
{
var cell = e.currentTarget.ancestor('.yui3-datatable-cell');
var field = cell.one('.quickedit-field');
if (!field)
{
return;
}
var value = Y.Lang.trim(field.get('value'));
if (!value && value !== 0)
{
return;
}
while (1)
{
cell = this.getCell(cell, 'below');
if (!cell)
{
break;
}
field = cell.one('.quickedit-field');
if (field)
{
field.set('value', value);
}
}
}
/**
* Inserts a "Copy down" button if the cell is in the first row of the
* DataTable. Call this at the end of your QuickEdit formatter.
*
* @method copyDownFormatter
* @static
* @param o {Object} cell formatter object
* @param td {Node} cell
*/
QuickEdit.copyDownFormatter = function(o, td)
{
if (o.column.quickEdit.copyDown && o.rowIndex === 0)
{
return Y.Lang.sub('<button type="button" title="Copy down" class="{c}">↓</button>',
{
c: QuickEdit.copy_down_button_class
});
}
else
{
return '';
}
};
function wrapFormatter(editFmt, origFmt)
{
return function(o)
{
if (!o.record && Y.Lang.isString(origFmt))
{
return origFmt;
}
else
{
return (o.record ? editFmt : origFmt).apply(this, arguments);
}
};
}
/*
* Shift the focus up/down within a column.
*/
function moveFocus(e)
{
var cell = this.getCell(e.target, e.charCode == 38 ? 'above' : 'below');
if (cell)
{
var input = cell.one('.quickedit-field');
if (input)
{
input.focus();
input.select();
e.halt(true);
}
}
}
/*
* Parse the column configuration for easy lookup.
*/
function parseColumns()
{
var forest = this.get('host').get('columns');
var map = {};
function accumulate(list, node)
{
if (Y.Lang.isString(node))
{
var col = { key: node };
list.push(col);
map[node] = col;
}
else if (node.children)
{
list = Y.reduce(node.children, list, accumulate);
}
else
{
list.push(node);
map[ node.key ] = node;
}
return list;
}
this.column_list = Y.reduce(forest, [], accumulate);
this.column_map = map;
}
/*
* Validate the given form fields.
*
* @method validateElements
* @private
* @param e {Array} Array of form fields.
* @return {boolean} true if all validation checks pass
*/
function validateElements(
/* NodeList */ list)
{
var host = this.get('host');
var status = true;
var count = list.size();
for (var i=0; i<count; i++)
{
var e = list.item(i);
if (!Y.DOM.hasClass(e, 'quickedit-field'))
{
continue;
}
var qe = this.column_map[ this._getColumnKey(e) ].quickEdit;
if (!qe)
{
continue;
}
var msg_list = qe.validation ? qe.validation.msg : null;
var info = Y.FormManager.validateFromCSSData(e, msg_list);
if (info.error)
{
this.displayMessage(e, info.error, 'error');
status = false;
continue;
}
if (info.keepGoing)
{
if (qe.validation &&
qe.validation.regex instanceof RegExp &&
!qe.validation.regex.test(e.get('value')))
{
this.displayMessage(e, msg_list ? msg_list.regex : null, 'error');
status = false;
continue;
}
}
if (qe.validation &&
Y.Lang.isFunction(qe.validation.fn) &&
!qe.validation.fn.call(host, e))
{
status = false;
continue;
}
}
return status;
}
Y.extend(QuickEdit, Y.Plugin.Base,
{
initializer: function(config)
{
var host = this.get('host');
this.hasMessages = false;
parseColumns.call(this);
this.get('host').after('columnsChange', parseColumns, this);
var h = this.afterHostEvent('render', function()
{
host.get('boundingBox').delegate('click', copyDown, '.'+QuickEdit.copy_down_button_class, host);
h.detach();
});
},
/**
* Switch to QuickEdit mode. Columns that have quickEdit defined will
* be editable. If the table has paginators, you must hide them.
*
* @method start
*/
start: function()
{
this.fire('clearErrorNotification');
var host = this.get('host');
this.saveSort = [];
this.saveEdit = [];
this.saveFmt = {};
for (var i=0; i<this.column_list.length; i++)
{
var col = this.column_list[i];
var key = col.key;
this.saveSort.push(col.sortable);
col.sortable = false;
// this.saveEdit.push(col.editor);
// col.editor = null;
var qe = col.quickEdit,
qef = col.qeFormatter;
if (/* !col.hidden && */ (qe || qef))
{
var fn = null;
if (qe && Y.Lang.isFunction(qe.formatter))
{
fn = qe.formatter;
}
else if (Y.Lang.isFunction(qef))
{
fn = qef;
}
else
{
fn = QuickEdit.textFormatter;
}
if (fn)
{
this.saveFmt[key] =
{
formatter: col.formatter,
nodeFormatter: col.nodeFormatter,
allowHTML: col.allowHTML
};
col.formatter = wrapFormatter.call(this, fn, col.formatter || col.nodeFormatter);
col.nodeFormatter = null;
col.allowHTML = true;
}
}
}
var container = host.get('contentBox');
container.addClass(host.getClassName('quickedit'));
this.move_event_handle = container.on('key', moveFocus, 'down:38+ctrl,40+ctrl', host);
// trigger re-parsing of columns -- since we saved references to
// the column objects, the original forest has been modified :)
host.set('columns', host.get('columns'));
},
/**
* Stop QuickEdit mode. THIS DISCARDS ALL DATA! If you want to save
* the data, call getChanges() BEFORE calling this function. If the
* table has paginators, you must show them.
*
* @method cancel
*/
cancel: function()
{
this.fire('clearErrorNotification');
for (var i=0; i<this.column_list.length; i++)
{
var col = this.column_list[i];
col.sortable = this.saveSort[i];
// col.editor = this.saveEdit[i];
}
delete this.saveSort;
delete this.saveEdit;
Y.each(this.saveFmt, function(fmt, key)
{
var col = this.column_map[key];
col.formatter = fmt.formatter;
col.nodeFormatter = fmt.nodeFormatter;
col.allowHTML = fmt.allowHTML;
},
this);
delete this.saveFmt;
var host = this.get('host');
var container = host.get('contentBox');
container.removeClass(host.getClassName('quickedit'));
if (this.move_event_handle)
{
this.move_event_handle.detach();
delete this.move_event_handle;
}
// trigger re-parsing of columns -- since we saved references to
// the column objects, the original forest has been modified :)
host.set('columns', host.get('columns'));
},
/**
* Return the changed values. For each row, an object is created with
* only the changed values. The object keys are the column keys. If
* you need values from particular columns to be included always, even
* if the value did not change, include the key `changesAlwaysInclude`
* in the plugin configuration and pass an array of column keys.
* If you need the row indexes, configure `includeRowIndexInChanges`.
*
* If you only want the records with changes, configure
* `includeAllRowsInChanges` to be false. For this to be useful, you
* will need to configure either `changesAlwaysInclude` or
* `includeRowIndexInChanges`.
*
* @method getChanges
* @return {mixed} array of objects if all validations pass, false otherwise
*/
getChanges: function()
{
if (!this.validate())
{
return false;
}
var changes = [],
always_include = this.get('changesAlwaysInclude'),
include_index = this.get('includeRowIndexInChanges'),
include_all = this.get('includeAllRowsInChanges');
var host = this.get('host');
var rows = host._tbodyNode.get('children');
host.get('data').each(function(rec, i)
{
var list = rows.item(i).all('.quickedit-field');
var change = {},
changed = false;
var field_count = list.size();
for (var j=0; j<field_count; j++)
{
var field = list.item(j);
var key = this._getColumnKey(field);
var qe = this.column_map[key].quickEdit;
var prev = rec.get(key);
var val = Y.Lang.trim(field.get('value'));
if (qe.changed ? qe.changed(prev, val) :
val !== (prev ? prev.toString() : ''))
{
change[key] = val;
changed = true;
}
}
if (changed || include_all)
{
for (var j=0; j<always_include.length; j++)
{
var key = always_include[j];
change[key] = rec.get(key);
}
if (include_index)
{
change._row_index = i;
}
changes.push(change);
}
},
this);
return changes;
},
/**
* Validate the QuickEdit data.
*
* @method validate
* @return {boolean} true if all validation checks pass
*/
validate: function()
{
this.clearMessages();
var status = true;
var host = this.get('host');
var e1 = host._tbodyNode.getElementsByTagName('input');
var e2 = host._tbodyNode.getElementsByTagName('textarea');
var e3 = host._tbodyNode.getElementsByTagName('select');
status = validateElements.call(this, e1) && status; // status last to guarantee call
status = validateElements.call(this, e2) && status;
status = validateElements.call(this, e3) && status;
if (!status)
{
this.fire('notifyErrors');
}
return status;
},
/**
* Clear all validation messages in QuickEdit mode.
*
* @method clearMessages
*/
clearMessages: function()
{
this.hasMessages = false;
this.fire('clearErrorNotification');
var host = this.get('host');
host._tbodyNode.getElementsByClassName(qe_row_status_pattern)
.removeClass(qe_row_status_pattern);
host._tbodyNode.getElementsByClassName(qe_cell_status_pattern)
.removeClass(qe_cell_status_pattern);
host._tbodyNode.all('.' + QuickEdit.error_text_class)
.set('innerHTML', '');
},
/**
* Display a message for a QuickEdit field. If an existing message with
* a higher precedence is already visible, it will not be replaced.
*
* @method displayMessage
* @param e {Element} form field
* @param msg {String} message to display
* @param type {String} message type: error, warn, success, info
* @param scroll {boolean} If false, does not scroll, even if this is the first message to display.
*/
displayMessage: function(
/* element */ e,
/* string */ msg,
/* string */ type,
/* boolean */ scroll)
{
if (Y.Lang.isUndefined(scroll))
{
scroll = true;
}
e = Y.one(e);
var row = e.getAncestorByTagName('tr');
if (Y.FormManager.statusTakesPrecedence(this._getElementStatus(row, qe_row_status_re), type))
{
if (!this.hasMessages && scroll)
{
Y.one(row.get('firstChild')).scrollIntoView();
}
row.replaceClass(qe_row_status_pattern, qe_row_status_prefix + type);
this.hasMessages = true;
}
var cell = e.getAncestorByTagName('td');
if (Y.FormManager.statusTakesPrecedence(this._getElementStatus(cell, qe_cell_status_re), type))
{
if (msg)
{
cell.one('.' + QuickEdit.error_text_class)
.set('innerHTML', msg);
}
cell.replaceClass(qe_cell_status_pattern, qe_cell_status_prefix + type);
this.hasMessages = true;
}
},
/**
* Return the status of the field.
*
* @method _getElementStatus
* @protected
* @param e {Node} form field
* @param r {RegExp} regex to match against className
* @return {String}
*/
_getElementStatus: function(
/* Node */ e,
/* regex */ r)
{
var m = e.get('className').match(r);
return ((m && m.length) ? m[1] : false);
},
/**
* Return the column key for the specified field.
*
* @method _getColumnKey
* @protected
* @param e {Node} form field
* @return {String}
*/
_getColumnKey: function(
/* Node */ e)
{
var m = quick_edit_re.exec(e.get('className'));
return m[1];
}
});
Y.namespace("Plugin");
Y.Plugin.DataTableQuickEdit = QuickEdit;