API Docs for: 1.0.0
Show:

File: src/gallery-bulkedit/js/BulkEditor.js

/**********************************************************************
 * A widget for editing many records at once.
 *
 * @module gallery-bulkedit
 * @main gallery-bulkedit
 */

/**
 * <p>BulkEditor provides the basic structure for editing all the records
 * in a BulkEditDataSource.  The fields for editing a record are rendered
 * into a "row".  This could be a div, a tbody, or something else.</p>
 * 
 * <p>All event handlers must be placed on the container, not individual
 * DOM elements.</p>
 * 
 * <p>Errors must be returned from the server in the order in which records
 * are displayed.  Because of this, when data is sent to the server:</p>
 * <ul>
 * <li>If the server knows the ordering, you can send the diffs.  (Diffs are an unordered map, keyed on the record id.)</li>
 * <li>If the server doesn't know the ordering, you must send all the data.</li>
 * </ul>
 *
 * @class BulkEditor
 * @extends Widget
 * @constructor
 * @param config {Object}
 */
function BulkEditor()
{
	BulkEditor.superclass.constructor.apply(this, arguments);
}

BulkEditor.NAME = "bulkedit";

BulkEditor.ATTRS =
{
	/**
	 * @attribute ds
	 * @type {DataSource.BulkEdit}
	 * @required
	 * @writeonce
	 */
	ds:
	{
		validator: function(value)
		{
			return (value instanceof BulkEditDataSource);
		},
		writeOnce: true
	},

	/**
	 * Configuration for each field: type (input|select|textarea), label,
	 * validation (css, regex, msg, fn; see
	 * gallery-formmgr-css-validation).  Derived classes can require
	 * additional keys.
	 *
	 * @attribute fields
	 * @type {Object}
	 * @required
	 * @writeonce
	 */
	fields:
	{
		validator: Y.Lang.isObject,
		writeOnce: true
	},

	/**
	 * Paginator for switching between pages of records.  BulkEditor
	 * expects it to be configured to display ValidationPageLinks, so the
	 * user can see which pages have errors that need to be fixed.
	 *
	 * @attribute paginator
	 * @type {Paginator}
	 * @writeonce
	 */
	paginator:
	{
		validator: function(value)
		{
			return (value instanceof Y.Paginator);
		},
		writeOnce: true
	},

	/**
	 * Extra key/value pairs to pass in the DataSource request.
	 * 
	 * @attribute requestExtra
	 * @type {Object}
	 * @writeonce
	 */
	requestExtra:
	{
		value:     {},
		validator: Y.Lang.isObject,
		writeOnce: true
	},

	/**
	 * CSS class used to temporarily highlight a record.
	 *
	 * @attribute pingClass
	 * @type {String}
	 * @default "yui3-bulkedit-ping"
	 */
	pingClass:
	{
		value:     Y.ClassNameManager.getClassName(BulkEditor.NAME, 'ping'),
		validator: Y.Lang.isString
	},

	/**
	 * Duration in seconds that pingClass is applied to a record.
	 *
	 * @attribute pingTimeout
	 * @type {Number}
	 * @default 2
	 */
	pingTimeout:
	{
		value:     2,
		validator: Y.Lang.isNumber
	}
};

/**
 * The number of checkboxes in each column of a checkboxMultiselect field.
 * 
 * @property checkbox_multiselect_column_height
 * @type {Number}
 * @static
 */
BulkEditor.checkbox_multiselect_column_height = 6;

/**
 * @event notifyErrors
 * @description Fired when widget-level validation messages need to be displayed.
 * @param msgs {Array} the messages to display
 */
/**
 * @event clearErrorNotification
 * @description Fired when widget-level validation messages should be cleared.
 */
/**
 * @event pageRendered
 * @description Fired after the editor has rendered a page.
 */
/**
 * @event recordVisible
 * @description Fired after showRecord*() succeeds, including switching pages.
 */

var default_page_size = 1e9,

	id_prefix = 'bulk-editor',
	id_separator = '__',
	id_regex = new RegExp('^' + id_prefix + id_separator + '(.+?)(?:' + id_separator + '(.+?))?$'),

	status_prefix  = 'bulkedit-has',
	status_pattern = status_prefix + '([a-z]+)',
	status_re      = new RegExp(Y.Node.class_re_prefix + status_pattern + Y.Node.class_re_suffix),

	record_status_prefix  = 'bulkedit-hasrecord',
	record_status_pattern = record_status_prefix + '([a-z]+)',
	record_status_re      = new RegExp(Y.Node.class_re_prefix + record_status_pattern + Y.Node.class_re_suffix),

	message_container_class = Y.ClassNameManager.getClassName(BulkEditor.NAME, 'message-text'),

	perl_flags_regex = /^\(\?([a-z]+)\)/;

BulkEditor.record_container_class     = Y.ClassNameManager.getClassName(BulkEditor.NAME, 'bd');
BulkEditor.record_msg_container_class = Y.ClassNameManager.getClassName(BulkEditor.NAME, 'record-message-container');

BulkEditor.field_container_class        = Y.ClassNameManager.getClassName(BulkEditor.NAME, 'field-container');
BulkEditor.field_container_class_prefix = BulkEditor.field_container_class + '-';
BulkEditor.field_class_prefix           = Y.ClassNameManager.getClassName(BulkEditor.NAME, 'field') + '-';

function switchPage(state)
{
	this.saveChanges();

	var pg = this.get('paginator');
	pg.setTotalRecords(state.totalRecords, true);
	pg.setStartIndex(state.recordOffset, true);
	pg.setRowsPerPage(state.rowsPerPage, true);
	pg.setPage(state.page, true);
	this._updatePageStatus();
	this.reload();
}

Y.extend(BulkEditor, Y.Widget,
{
	initializer: function(config)
	{
		if (config.paginator)
		{
			this._pg_event_handle =
				config.paginator.on('changeRequest', switchPage, this);
		}

		this._hopper = [];
	},

	destructor: function()
	{
		if (this._pg_event_handle)
		{
			this._pg_event_handle.detach();
		}

		this._set('ds', null);
		this._set('paginator', null);
	},

	renderUI: function()
	{
		this.clearServerErrors();
		this.reload();
	},

	bindUI: function()
	{
		this._attachEvents(this.get('contentBox'));
	},

	/**
	 * Attaches events to the container.
	 *
	 * @method _attachEvents
	 * @param container {Node} node to which events should be attached
	 * @protected
	 */
	_attachEvents: function(
		/* node */	container)
	{
		Y.delegate('bulkeditor|click', handleCheckboxMultiselectClickOnCheckbox, container, '.checkbox-multiselect input[type=checkbox]', this);
	},

	/**
	 * @method _destroyOnRender
	 * @param o {Object} object to destroy when BulkEditor is re-rendered
	 * @protected
	 */
	_destroyOnRender: function(o)
	{
		this._hopper.push(o);
	},

	/**
	 * Reloads the current page of records.  This will erase any changes
	 * unsaved changes!
	 * 
	 * @method reload
	 */
	reload: function()
	{
		if (!this.busy)
		{
			this.plug(Y.Plugin.BusyOverlay);
		}
		this.busy.show();

		var pg = this.get('paginator');
		var request =
		{
			startIndex:  pg ? pg.getStartIndex() : 0,
			resultCount: pg ? pg.getRowsPerPage() : default_page_size
		};
		Y.mix(request, this.get('requestExtra'));

		var ds = this.get('ds');
		ds.sendRequest(
		{
			request: request,
			callback:
			{
				success: Y.bind(function(e)
				{
					this.busy.hide();

					var count = ds.getRecordCount();
					if (count > 0 && pg && pg.getStartIndex() >= count)
					{
						pg.setPage(pg.getPreviousPage());
						return;
					}

					this._render(e.response);
					this._updatePaginator(e.response);
					this.scroll_to_index = -1;
				},
				this),

				failure: Y.bind(function()
				{
					Y.log('error loading data in BulkEditor', 'error');

					this.busy.hide();
					this.scroll_to_index = -1;
				},
				this)
			}
		});
	},

	/**
	 * Save the modified values from the current page of records.
	 * 
	 * @method saveChanges
	 */
	saveChanges: function()
	{
		var ds      = this.get('ds');
		var records = ds.getCurrentRecords();
		var id_key  = ds.get('uniqueIdKey');
		Y.Object.each(this.get('fields'), function(field, key)
		{
			Y.Array.each(records, function(r)
			{
				var node = this.getFieldElement(r, key),
					tag  = node.get('tagName'),
					value;

				// Limited number of HTML form tags.  Behaviors should not be
				// changed.  Thus, not using a lookup table.

				if (tag == 'INPUT' && node.get('type').toLowerCase() == 'checkbox')
				{
					value = node.get('checked') ? field.values.on : field.values.off;
				}
				else if (tag == 'SELECT' && node.get('multiple'))
				{
					value = Y.reduce(node.getDOMNode().options, [], function(v, o)
					{
						if (o.selected)
						{
							v.push(o.value);
						}
						return v;
					});
				}
				else
				{
					value = node.get('value');
				}

				ds.updateValue(r[ id_key ], key, value);
			},
			this);
		},
		this);
	},

	/**
	 * Retrieve *all* the data.  Do not call this if you use server-side
	 * pagination.
	 * 
	 * @method getAllValues
	 * @param callback {Object} callback object which will be invoked by DataSource
	 */
	getAllValues: function(callback)
	{
		var request =
		{
			startIndex:  0,
			resultCount: this.get('ds').getRecordCount(),
			outOfBand:   true
		};
		Y.mix(request, this.get('requestExtra'));

		this.get('ds').sendRequest(
		{
			request:  request,
			callback: callback
		});
	},

	/**
	 * @method getChanges
	 * @return {Object} map of all changed values, keyed by record id
	 */
	getChanges: function()
	{
		return this.get('ds').getChanges();
	},

	/**
	 * <p>Insert a new record.</p>
	 * 
	 * <p>You must `reload` the widget after calling this function!</p>
	 * 
	 * @method insertRecord
	 * @param index {Number} insertion index
	 * @param record {Object|String} record to insert or id of record to clone
	 * @return {String} the new record's id
	 */
	insertRecord: function(
		/* int */		index,
		/* object */	record)
	{
		var record_id = this.get('ds').insertRecord(index, record);
		if (index <= this.server_errors.records.length)
		{
			this.server_errors.records.splice(index,0, { id: record_id });
			// leave entry in record_map undefined
			this._updatePageStatus();
		}
		return record_id;
	},

	/**
	 * <p>Remove a record.  There is no simple way to un-remove a record,
	 * so if you need that functionality, you may want to use highlighting
	 * to indicate removed records instead.</p>
	 * 
	 * <p>You must `reload` the widget after calling this function!</p>
	 * 
	 * @method removeRecord
	 * @param index {Number}
	 * @return {Boolean} true if the record was successfully removed
	 */
	removeRecord: function(
		/* int */ index)
	{
		if (this.get('ds').removeRecord(index))
		{
			if (index < this.server_errors.records.length)
			{
				var rec = this.server_errors.records[index];
				this.server_errors.records.splice(index,1);
				delete this.server_errors.record_map[ rec[ this.get('ds').get('uniqueIdKey') ] ];
				this._updatePageStatus();
			}
			return true;
		}
		else
		{
			return false;
		}
	},

	/**
	 * <p>Restore a removed record.</p>
	 * 
	 * <p>You must `reload` the widget after calling this function!</p>
	 * 
	 * @method restoreRecord
	 * @param record_id {String}
	 * @return {int} the restored record's index or -1 if record id is not found
	 */
	restoreRecord: function(
		/* string */ record_id)
	{
		var i = this.get('ds').restoreRecord(record_id);
		if (i >= 0)
		{
			this.clearServerErrors();
		}

		return i;
	},

	/**
	 * <p>Show a record.  Do not call this if you use server-side
	 * pagination.</p>
	 * 
	 * <p>You must `reload` the widget after calling this function!</p>
	 * 
	 * @method showRecord
	 * @param record_id {String}
	 * @return {int} the newly visible record's index or -1 if record id is not found
	 */
	showRecord: function(
		/* string */ record_id)
	{
		var i = this.get('ds').showRecord(record_id);
		if (i >= 0)
		{
			this.clearServerErrors();
		}

		return i;
	},

	/**
	 * <p>Hide a record.  Do not call this if you use server-side
	 * pagination.</p>
	 * 
	 * <p>You must `reload` the widget after calling this function!</p>
	 * 
	 * @method hideRecord
	 * @param index {Number}
	 * @return {boolean} true if record is now hidden
	 */
	hideRecord: function(
		/* int */ index)
	{
		if (this.get('ds').hideRecord(index))
		{
			if (index < this.server_errors.records.length)
			{
				var rec = this.server_errors.records[index];
				this.server_errors.records.splice(index,1);
				delete this.server_errors.record_map[ rec[ this.get('ds').get('uniqueIdKey') ] ];
				this._updatePageStatus();	// paranoia, since records with changes cannot be hidden
			}
			return true;
		}
		else
		{
			return false;
		}
	},

	/**
	 * @method getFieldConfig
	 * @param key {String} field key
	 * @return {Object} field configuration
	 */
	getFieldConfig: function(
		/* string */	key)
	{
		return this.get('fields')[key] || {};
	},

	/**
	 * @method getRecordContainerId
	 * @param record {String|Object} record id or record object
	 * @return {String} id of DOM element containing the record's input elements
	 */
	getRecordContainerId: function(
		/* string/object */ record)
	{
		if (Y.Lang.isString(record))
		{
			return id_prefix + id_separator + record;
		}
		else
		{
			return id_prefix + id_separator + record[ this.get('ds').get('uniqueIdKey') ];
		}
	},

	/**
	 * @method getFieldId
	 * @param record {String|Object} record id or record object
	 * @param key {String} field key
	 * @return {String} id of DOM element containing the field's input element
	 */
	getFieldId: function(
		/* string/object */	record,
		/* string */		key)
	{
		return this.getRecordContainerId(record) + id_separator + key;
	},

	/**
	 * @method getRecordAndFieldKey
	 * @param key {String|Node} field key or field input element
	 * @return {Object} object containing record and field_key
	 */
	getRecordAndFieldKey: function(
		/* string/element */	field)
	{
		var m = id_regex.exec(Y.Lang.isString(field) ? field : field.get('id'));
		if (m && m.length > 0)
		{
			return { record: this.get('ds').getCurrentRecordMap()[ m[1] ], field_key: m[2] };
		}
	},

	/**
	 * @method getRecordId
	 * @param obj {Object|Node} record object, record container, or any node inside record container
	 * @return {String} record id
	 */
	getRecordId: function(
		/* object/element */	obj)
	{
		if (Y.Lang.isObject(obj) && !obj._node)
		{
			return obj[ this.get('ds').get('uniqueIdKey') ];
		}

		var node = obj.getAncestorByClassName(BulkEditor.record_container_class, true);
		if (node)
		{
			var m  = id_regex.exec(node.get('id'));
			if (m && m.length > 0)
			{
				return m[1];
			}
		}
	},

	/**
	 * @method getRecordContainer
	 * @param record {String|Object|Node} record id, record object, record container, or any node inside record container
	 * @return {Node} node containing rendered record
	 */
	getRecordContainer: function(
		/* string/object/element */ record)
	{
		if (Y.Lang.isString(record))
		{
			var id = id_prefix + id_separator + record;
		}
		else if (record && record._node)
		{
			return record.getAncestorByClassName(BulkEditor.record_container_class, true);
		}
		else	// record object
		{
			var id = this.getRecordContainerId(record);
		}

		return Y.one('#'+id);
	},

	/**
	 * @method getFieldContainer
	 * @param record {String|Object|Node} record id, record object, record container, or any node inside record container
	 * @param key {String} field key
	 * @return {Node} node containing rendered field
	 */
	getFieldContainer: function(
		/* string/object/element */	record,
		/* string */				key)
	{
		var field = this.getFieldElement(record, key);
		return field.getAncestorByClassName(BulkEditor.field_container_class, true);
	},

	/**
	 * @method getFieldElement
	 * @param record {String|Object|Node} record id, record object, record container, or any node inside record container
	 * @param key {String} field key
	 * @return {Node} field's input element
	 */
	getFieldElement: function(
		/* string/object/element */	record,
		/* string */				key)
	{
		if (record && record._node)
		{
			record = this.getRecordId(record);
		}
		return Y.one('#'+this.getFieldId(record, key));
	},

	/**
	 * Paginate and/or scroll to make the specified record visible.  Record
	 * is pinged to help the user find it.
	 * 
	 * @method showRecordIndex
	 * @param index {Number} record index
	 */
	showRecordIndex: function(
		/* int */ index)
	{
		if (index < 0 || this.get('ds').getRecordCount() <= index)
		{
			return;
		}

		var pg    = this.get('paginator');
		var start = pg ? pg.getStartIndex() : 0;
		var count = pg ? pg.getRowsPerPage() : default_page_size;
		if (start <= index && index < start+count)
		{
			var rec  = this.get('ds').getCurrentRecords()[ index - start ],
				node = this.getRecordContainer(rec);
			node.scrollIntoView();
			this.pingRecord(node);
			this.fire('recordVisible', { index: index, id: this.getRecordId(rec) });
		}
		else if (pg)
		{
			this.scroll_to_index = index;
			pg.setPage(1 + Math.floor(index / count));
		}
	},

	/**
	 * Paginate and/or scroll to make the specified record visible.  Record
	 * is pinged to help the user find it.
	 * 
	 * @method showRecordId
	 * @param id {Number} record id
	 */
	showRecordId: function(
		/* string */ id)
	{
		var index = this.get('ds').recordIdToIndex(id);
		if (index >= 0)
		{
			this.showRecordIndex(index);
		}
	},

	/**
	 * Apply a class to the DOM element containing the record for a short
	 * while.  Your CSS can use this class to highlight the record in some
	 * way.
	 * 
	 * @method pingRecord
	 * @param record {String|Object|Node} record id, record object, record container, or any node inside record container
	 */
	pingRecord: function(
		/* string/object/element */	record)
	{
		var ping = this.get('pingClass');
		if (ping)
		{
			var node = this.getRecordContainer(record);
			node.addClass(ping);
			Y.later(this.get('pingTimeout')*1000, null, function()
			{
				if (node.inDoc())
				{
					node.removeClass(ping);
				}
			});
		}
	},

	/**
	 * Render the current page of records.
	 *
	 * @method _render
	 * @protected
	 * @param response {Object} response from data source
	 */
	_render: function(response)
	{
		Y.log('_render', 'debug');

		Y.Chipper.destroy(this._hopper);
		this._hopper = [];

		var container = this.get('contentBox');
		this._renderContainer(container);
		container.set('scrollTop', 0);
		container.set('scrollLeft', 0);

		Y.Array.each(response.results, function(record)
		{
			var node = this._renderRecordContainer(container, record);
			this._renderRecord(node, record);
		},
		this);

		this.fire('pageRendered');

		if (this.auto_validate)
		{
			this.validate();
		}

		if (this.scroll_to_index >= 0)
		{
			this.showRecordIndex(this.scroll_to_index);
			this.scroll_to_index = -1;
		}
	},

	/**
	 * Derived class should override to create a structure for the records.
	 *
	 * @method _renderContainer
	 * @protected
	 * @param container {Node}
	 */
	_renderContainer: function(
		/* element */	container)
	{
		container.set('innerHTML', '');
	},

	/**
	 * Derived class must override to create a container for the record.
	 * 
	 * @method _renderRecordContainer
	 * @protected
	 * @param container {Node}
	 * @param record {Object} record data
	 */
	_renderRecordContainer: function(
		/* element */	container,
		/* object */	record)
	{
		return null;
	},

	/**
	 * Derived class can override if it needs to do more than just call
	 * _renderField() for each field.
	 * 
	 * @method _renderRecord
	 * @protected
	 * @param container {Node} record container
	 * @param record {Object} record data
	 */
	_renderRecord: function(
		/* element */	container,
		/* object */	record)
	{
		Y.Object.each(this.get('fields'), function(field, key)
		{
			this._renderField(
			{
				container: container,
				key:       key,
				value:     record[key],
				field:     field,
				record:    record
			});
		},
		this);
	},

	/**
	 * If _renderRecord is not overridden, derived class must override this
	 * function to render the field.
	 * 
	 * @method _renderField
	 * @protected
	 * @param o {Object}
	 *	container {Node} record container,
	 *	key {String} field key,
	 *	value {Mixed} field value,
	 *	field {Object} field configuration,
	 *	record {Object} record data
	 */
	_renderField: function(
		/* object */ o)
	{
	},

	/**
	 * Update the paginator to match the data source meta information.
	 * 
	 * @method _updatePaginator
	 * @protected
	 * @param response {Object} response from DataSource
	 */
	_updatePaginator: function(response)
	{
		var pg = this.get('paginator');
		if (pg)
		{
			pg.setTotalRecords(this.get('ds').getRecordCount(), true);
		}
	},

	/**
	 * Clear errors received from the server.  This clears all displayed
	 * messages.
	 * 
	 * @method clearServerErrors
	 */
	clearServerErrors: function()
	{
		if (this.server_errors && this.server_errors.page &&
			this.server_errors.page.length)
		{
			this.fire('clearErrorNotification');
		}

		this.server_errors =
		{
			page:       [],
			records:    [],
			record_map: {}
		};

		var pg = this.get('paginator');
		if (pg)
		{
			pg.set('pageStatus', []);
		}
		this.first_error_page = -1;

		this._clearValidationMessages();
	},

	/**
	 * Set page level, record level, and field level errors received from
	 * the server.  A message can be either a string (assumed to be an
	 * error) or an object providing msg and type, where type can be
	 * 'error', 'warn', 'info', or 'success'.
	 * 
	 * @method setServerErrors
	 * @param page_errors {Array} list of page-level error messages
	 * @param record_field_errors {Array} list of objects *in record display order*,
	 *		each of which defines id (String), recordError (message),
	 *		and fieldErrors (map of field keys to error messages)
	 */
	setServerErrors: function(
		/* array */	page_errors,
		/* array */	record_field_errors)
	{
		if (this.server_errors.page.length &&
			(!page_errors || !page_errors.length))
		{
			this.fire('clearErrorNotification');
		}

		this.server_errors =
		{
			page:       page_errors || [],
			records:    record_field_errors || [],
			record_map: Y.Array.toObject(record_field_errors || [], 'id')
		};

		this._updatePageStatus();

		var pg = this.get('paginator');
		if (!pg || pg.getCurrentPage() === this.first_error_page)
		{
			this.validate();
		}
		else
		{
			this.auto_validate = true;
			pg.setPage(this.first_error_page);
		}
	},

	/**
	 * Update paginator to show which pages have errors.
	 *
	 * @method _updatePageStatus
	 * @protected
	 */
	_updatePageStatus: function()
	{
		var pg = this.get('paginator');
		if (!pg)
		{
			return;
		}

		var page_size = pg ? pg.getRowsPerPage() : default_page_size;
		var status    = this.page_status.slice(0);

		this.first_error_page = -1;

		var r = this.server_errors.records, s;
		for (var i=0; i<r.length; i++)
		{
			s = '';
			if (Y.Lang.isObject(r[i].recordError))
			{
				s = r[i].recordError.type;
			}
			else if (Y.Lang.isString(r[i].recordError))
			{
				s = 'error';
			}

			if (s != 'error' && Y.Lang.isObject(r[i].fieldErrors))
			{
				Y.some(r[i].fieldErrors, function(e, k)
				{
					if (Y.Lang.isObject(e) &&
						Y.FormManager.statusTakesPrecedence(s, e.type))
					{
						s = e.type;
					}
					else if (Y.Lang.isString(e))
					{
						s = 'error';
					}

					return (s == 'error');
				});
			}

			if (s)
			{
				var j     = Math.floor(i / page_size);
				status[j] = s;
				if (this.first_error_page == -1)
				{
					this.first_error_page = j+1;
				}
			}
		}

		pg.set('pageStatus', status);
	},

	/**
	 * Validate the visible values (if using server-side pagination) or all
	 * the values (if using client-side pagination or no pagination).
	 * 
	 * @method validate
	 * @return {Boolean} true if all checked values are acceptable
	 */
	validate: function()
	{
		this.saveChanges();

		this._clearValidationMessages();
		this.auto_validate = true;

		var status = this._validateVisibleFields();
		var pg     = this.get('paginator');
		if (!status && pg)
		{
			this.page_status[ pg.getCurrentPage()-1 ] = 'error';
		}

		status = this._validateAllPages() && status;	// status last to guarantee call

		if (!status || this.server_errors.page.length ||
			this.server_errors.records.length)
		{
			var err = this.server_errors.page.slice(0);
			if (err.length === 0)
			{
				err.push(Y.FormManager.Strings.validation_error);
			}
			this.fire('notifyErrors', { msgs: err });

			this.get('contentBox').getElementsByClassName(BulkEditor.record_container_class).some(function(node)
			{
				if (node.hasClass(status_pattern))
				{
					node.scrollIntoView();
					return true;
				}
			});
		}

		this._updatePageStatus();
		return status;
	},

	/**
	 * Validate the visible values.
	 * 
	 * @method _validateVisibleFields
	 * @protected
	 * @param container {Node} if null, uses contentBox
	 * @return {Boolean} true if all checked values are acceptable
	 */
	_validateVisibleFields: function(
		/* object */ container)
	{
		var status = true;

		if (!container)
		{
			container = this.get('contentBox');
		}

		// fields

		var e1 = container.getElementsByTagName('input');
		var e2 = container.getElementsByTagName('textarea');
		var e3 = container.getElementsByTagName('select');

		Y.FormManager.cleanValues(e1);
		Y.FormManager.cleanValues(e2);

		status = this._validateElements(e1) && status;	// status last to guarantee call
		status = this._validateElements(e2) && status;
		status = this._validateElements(e3) && status;

		// records -- after fields, since field class regex would wipe out record class

		container.getElementsByClassName(BulkEditor.record_container_class).each(function(node)
		{
			var id  = this.getRecordId(node);
			var err = this.server_errors.record_map[id];
			if (err && err.recordError)
			{
				err = err.recordError;
				if (Y.Lang.isString(err))
				{
					var msg  = err;
					var type = 'error';
				}
				else
				{
					var msg  = err.msg;
					var type = err.type;
				}
				this.displayRecordMessage(id, msg, type, false);
				status = status && !(type == 'error' || type == 'warn');
			}
		},
		this);

		return status;
	},

	/**
	 * Validate the given elements.
	 * 
	 * @method _validateElements
	 * @protected
	 * @param nodes {NodeList}
	 * @return {Boolean} true if all checked values are acceptable
	 */
	_validateElements: function(
		/* array */ nodes)
	{
		var status = true;
		nodes.each(function(node)
		{
			var field_info = this.getRecordAndFieldKey(node);
			if (!field_info)
			{
				return;
			}

			var field    = this.getFieldConfig(field_info.field_key);
			var msg_list = field.validation && field.validation.msg;

			var info = Y.FormManager.validateFromCSSData(node, msg_list);
			if (info.error)
			{
				this.displayFieldMessage(node, info.error, 'error', false);
				status = false;
				return;
			}

			if (info.keepGoing)
			{
				if (field.validation && Y.Lang.isString(field.validation.regex))
				{
					var flags = '';
					var m     = perl_flags_regex.exec(field.validation.regex);
					if (m && m.length == 2)
					{
						flags                  = m[1];
						field.validation.regex = field.validation.regex.replace(perl_flags_regex, '');
					}
					field.validation.regex = new RegExp(field.validation.regex, flags);
				}

				if (field.validation &&
					field.validation.regex instanceof RegExp &&
					!field.validation.regex.test(node.get('value')))
				{
					this.displayFieldMessage(node, msg_list && msg_list.regex, 'error', false);
					status = false;
					return;
				}
			}

			if (field.validation &&
				Y.Lang.isFunction(field.validation.fn) &&
				!field.validation.fn.call(this, node))
			{
				status = false;
				return;
			}

			var err = this.server_errors.record_map[ this.getRecordId(field_info.record) ];
			if (err && err.fieldErrors)
			{
				var f = err.fieldErrors[ field_info.field_key ];
				if (f)
				{
					if (Y.Lang.isString(f))
					{
						var msg  = f;
						var type = 'error';
					}
					else
					{
						var msg  = f.msg;
						var type = f.type;
					}
					this.displayFieldMessage(node, msg, type, false);
					status = status && !(type == 'error' || type == 'warn');
					return;
				}
			}
		},
		this);

		return status;
	},

	/**
	 * If the data is stored locally and we paginate, validate all of it
	 * and mark the pages that have invalid values.
	 * 
	 * @method _validateAllPages
	 * @protected
	 * @return {Boolean} true if all checked values are acceptable
	 */
	_validateAllPages: function()
	{
		var ds = this.get('ds');
		var pg = this.get('paginator');
		if (!pg || !ds._dataIsLocal())
		{
			return true;
		}

		if (!this.validation_node)
		{
			this.validation_node = Y.Node.create('<input></input>');
		}

		if (!this.validation_keys)
		{
			this.validation_keys = [];
			Y.Object.each(this.get('fields'), function(value, key)
			{
				if (value.validation)
				{
					this.validation_keys.push(key);
				}
			},
			this);
		}

		var count     = ds.getRecordCount(),
			page_size = pg.getRowsPerPage(),
			status    = true;
		for (var i=0; i<count; i++)
		{
			var page_status = true;
			Y.Array.each(this.validation_keys, function(key)
			{
				var field = this.get('fields')[key];
				var value = ds.getValue(i, key);

				this.validation_node.set('value', Y.Lang.isUndefined(value) ? '' : value);
				this.validation_node.set('className', field.validation.css || '');

				var info = Y.FormManager.validateFromCSSData(this.validation_node);
				if (info.error)
				{
					page_status = false;
					return;
				}

				if (info.keepGoing)
				{
					if (field.validation.regex instanceof RegExp &&
						!field.validation.regex.test(value))
					{
						page_status = false;
						return;
					}
				}
			},
			this);

			if (!page_status)
			{
				var j = Math.floor(i / page_size);
				i     = (j+1)*page_size - 1;	// skip to next page

				this.page_status[j] = 'error';
				status              = false;
			}
		}

		return status;
	},

	/**
	 * Clear all displayed messages.
	 * 
	 * @method _clearValidationMessages
	 */
	_clearValidationMessages: function()
	{
		this.has_validation_messages = false;
		this.auto_validate           = false;
		this.page_status             = [];

		this.fire('clearErrorNotification');

		var container = this.get('contentBox');

		container.getElementsByClassName(status_pattern).removeClass(status_pattern);
		container.getElementsByClassName(record_status_pattern).removeClass(record_status_pattern);
		container.getElementsByClassName(message_container_class).set('innerHTML', '');
	},

	/**
	 * Display a message for the specified field.
	 * 
	 * @method displayFieldMessage
	 * @param e {Node} field input element
	 * @param msg {String} message to display
	 * @param type {String} message type:  error, warn, info, success
	 * @param scroll {Boolean} whether or not to scroll to the field
	 */
	displayFieldMessage: function(
		/* element */	e,
		/* string */	msg,
		/* string */	type,
		/* boolean */	scroll)
	{
		if (Y.Lang.isUndefined(scroll))
		{
			scroll = !this.has_validation_messages;
		}

		var bd1     = this.getRecordContainer(e);
		var changed = this._updateRecordStatus(bd1, type, status_pattern, status_re, status_prefix);

		var bd2 = e.getAncestorByClassName(BulkEditor.field_container_class);
		if (Y.FormManager.statusTakesPrecedence(this._getElementStatus(bd2, status_re), type))
		{
			if (msg)
			{
				var m = bd2.getElementsByClassName(message_container_class);
				if (m && m.size() > 0)
				{
					m.item(0).set('innerHTML', msg);
				}
			}

			bd2.replaceClass(status_pattern, status_prefix + type);
			this.has_validation_messages = true;
		}

		if (changed && scroll)
		{
			bd1.scrollIntoView();
		}
	},

	/**
	 * Display a message for the specified record.
	 * 
	 * @method displayRecordMessage
	 * @param id {String} record id
	 * @param msg {String} message to display
	 * @param type {String} message type:  error, warn, info, success
	 * @param scroll {Boolean} whether or not to scroll to the field
	 */
	displayRecordMessage: function(
		/* string */	id,
		/* string */	msg,
		/* string */	type,
		/* boolean */	scroll)
	{
		if (Y.Lang.isUndefined(scroll))
		{
			scroll = !this.has_validation_messages;
		}

		var bd1     = this.getRecordContainer(id);
		var changed = this._updateRecordStatus(bd1, type, status_pattern, status_re, status_prefix);
		if (this._updateRecordStatus(bd1, type, record_status_pattern, record_status_re, record_status_prefix) &&
			msg)	// msg last to guarantee call
		{
			var bd2 = bd1.getElementsByClassName(BulkEditor.record_msg_container_class).item(0);
			if (bd2)
			{
				var m = bd2.getElementsByClassName(message_container_class);
				if (m && m.size() > 0)
				{
					m.item(0).set('innerHTML', msg);
				}
			}
		}

		if (changed && scroll)
		{
			bd1.scrollIntoView();
		}
	},

	/**
	 * @method _getElementStatus
	 * @protected
	 * @param n {Node}
	 * @param r {RegExp}
	 * @return {Mixed} status or false
	 */
	_getElementStatus: function(
		/* Node */	n,
		/* regex */	r)
	{
		var m = r.exec(n.get('className'));
		return (m && m.length > 1 ? m[1] : false);
	},

	/**
	 * Update the status of the node, if the new status has higher precedence.
	 *
	 * @method _updateRecordStatus
	 * @param bd {Node}
	 * @param type {String} new status
	 * @param p {String} pattern for extracting status
	 * @param r {RegExpr} regex for extracting status
	 * @param prefix {String} status prefix
	 * @return {Boolean} true if status was modified
	 */
	_updateRecordStatus: function(
		/* element */	bd,
		/* string */	type,
		/* string */	p,
		/* regex */		r,
		/* string */	prefix)
	{
		if (Y.FormManager.statusTakesPrecedence(this._getElementStatus(bd, r), type))
		{
			bd.replaceClass(p, prefix + type);
			this.has_validation_messages = true;
			return true;
		}

		return false;
	}
});

//
// Markup
//

BulkEditor.cleanHTML = function(s)
{
	return (Y.Lang.isValue(s) ? Y.Escape.html(s) : '');
};

/**
 * @property Y.BulkEditor.error_msg_markup
 * @type {String}
 * @static
 */
BulkEditor.error_msg_markup = Y.Lang.sub('<div class="{c}"></div>',
{
	c: message_container_class
});

/**
 * @method labelMarkup
 * @static
 * @param o {Object}
 *	key {String} field key,
 *	value {Mixed} field value,
 *	field {Object} field configuration,
 *	record {Object} record data
 * @return {String} markup for the label of the specified field
 */
BulkEditor.labelMarkup = function(o)
{
	var label = '<label for="{id}">{label}</label>';

	return Y.Lang.sub(label,
	{
		id:    this.getFieldId(o.record, o.key),
		label: o.field.label
	});
};

/**
 * Map of field type (input,select,textarea) to function that generates the
 * required markup.  You can add additional entries.  Each function takes a
 * single argument: an object defining
 *	key {String} field key,
 *	value {Mixed} field value,
 *	field {Object} field configuration,
 *	record {Object} record data
 *
 * @property Y.BulkEditor.markup
 * @type {Object}
 * @static
 */
BulkEditor.markup =
{
	input: function(o)
	{
		var input =
			'<div class="{cont}{key}">' +
				'{label}{msg1}' +
				'<input type="text" id="{id}" value="{value}" class="{field}{key} {yiv}" />' +
				'{msg2}' +
			'</div>';

		var label = o.field && o.field.label ? BulkEditor.labelMarkup.call(this, o) : '';

		return Y.Lang.sub(input,
		{
			cont:  BulkEditor.field_container_class + ' ' + BulkEditor.field_container_class_prefix,
			field: BulkEditor.field_class_prefix,
			key:   o.key,
			id:    this.getFieldId(o.record, o.key),
			label: label,
			value: BulkEditor.cleanHTML(o.value),
			yiv:   (o.field && o.field.validation && o.field.validation.css) || '',
			msg1:  label ? BulkEditor.error_msg_markup : '',
			msg2:  label ? '' : BulkEditor.error_msg_markup
		});
	},

	select: function(o)
	{
		var select =
			'<div class="{cont}{key}">' +
				'{label}{msg1}' +
				'<select id="{id}" class="{field}{key}">{options}</select>' +
				'{msg2}' +
			'</div>';

		var option = '<option value="{value}" {selected}>{text}</option>';

		var options = Y.Array.reduce(o.field.values, '', function(s, v)
		{
			return s + Y.Lang.sub(option,
			{
				value:    v.value,
				text:     BulkEditor.cleanHTML(v.text),
				selected: o.value && o.value.toString() === v.value ? 'selected="selected"' : ''
			});
		});

		var label = o.field && o.field.label ? BulkEditor.labelMarkup.call(this, o) : '';

		return Y.Lang.sub(select,
		{
			cont:  	 BulkEditor.field_container_class + ' ' + BulkEditor.field_container_class_prefix,
			field:   BulkEditor.field_class_prefix,
			key:     o.key,
			id:      this.getFieldId(o.record, o.key),
			label:   label,
			options: options,
			yiv:     (o.field && o.field.validation && o.field.validation.css) || '',
			msg1:    label ? BulkEditor.error_msg_markup : '',
			msg2:    label ? '' : BulkEditor.error_msg_markup
		});
	},

	checkbox: function(o)
	{
		var checkbox =
			'<div class="{cont}{key}">' +
				'<input type="checkbox" id="{id}" {value} class="{field}{key}" /> ' +
				'{label}' +
				'{msg}' +
			'</div>';

		var label = o.field && o.field.label ? BulkEditor.labelMarkup.call(this, o) : '';

		return Y.Lang.sub(checkbox,
		{
			cont:  BulkEditor.field_container_class + ' ' + BulkEditor.field_container_class_prefix,
			field: BulkEditor.field_class_prefix,
			key:   o.key,
			id:    this.getFieldId(o.record, o.key),
			label: label,
			value: o.value == o.field.values.on ? 'checked="checked"' : '',
			msg:   BulkEditor.error_msg_markup
		});
	},

	checkboxMultiselect: function(o)
	{
		return multiselectMarkup.call(this, 'checkboxes', o);
	},

	autocompleteInputMultiselect: function(o)
	{
		return multiselectMarkup.call(this, 'autocompleteMultivalueInput', o);
	},

	textarea: function(o)
	{
		var textarea =
			'<div class="{cont}{key}">' +
				'{label}{msg1}' +
				'<textarea id="{id}" class="satg-textarea-field {prefix}{key} {yiv}">{value}</textarea>' +
				'{msg2}' +
			'</div>';

		var label = o.field && o.field.label ? BulkEditor.labelMarkup.call(this, o) : '';

		return Y.Lang.sub(textarea,
		{
			cont:   BulkEditor.field_container_class + ' ' + BulkEditor.field_container_class_prefix,
			prefix: BulkEditor.field_class_prefix,
			key:    o.key,
			id:     this.getFieldId(o.record, o.key),
			label:  label,
			value:  BulkEditor.cleanHTML(o.value),
			yiv:    (o.field && o.field.validation && o.field.validation.css) || '',
			msg1:   label ? BulkEditor.error_msg_markup : '',
			msg2:   label ? '' : BulkEditor.error_msg_markup
		});
	}
};

/**
 * @method fieldMarkup
 * @static
 * @param key {String} field key
 * @param record {Object}
 * @return {String} markup for the specified field
 */
BulkEditor.fieldMarkup = function(key, record)
{
	var field = this.getFieldConfig(key);
	return BulkEditor.markup[ field.type || 'input' ].call(this,
	{
		key:    key,
		value:  record[key],
		field:  field,
		record: record
	});
};

function multiselectMarkup(type, o)
{
	var select =
		'<div class="{cont}{key}">' +
			'{label}{msg}' +
			'<div id="{id}-multiselect" class="checkbox-multiselect">{input}</div>' +
			'<select id="{id}" class="{field}{key}" multiple="multiple" style="display:none;">{options}</select>' +
		'</div>';

	var id        = this.getFieldId(o.record, o.key),
		has_value = Y.Lang.isArray(o.value);

	if (type == 'autocompleteMultivalueInput')
	{
		var input_markup = Y.Lang.sub('<input type="text" id="{id}-multivalue-input" />',
		{
			id: id
		});

		Y.later(0, this, function()
		{
			var node = Y.one('#' + id + '-multivalue-input');
			if (!node)
			{
				return;
			}

			node.plug(Y.Plugin.AutoComplete,
			{
				resultFilters:     'phraseMatch',
				resultHighlighter: 'phraseMatch',
				source: Y.reduce(o.field.values, [], function(list, v)
				{
					list.push(v.text);
					return list;
				})
			});

			node.plug(Y.Plugin.MultivalueInput,
			{
				values: Y.map(o.value, function(v)
				{
					var i = Y.Array.findIndexOf(o.field.values, function(v1)
					{
						return (v === v1.value.toString());
					});

					return (i >= 0 ? o.field.values[i].text : '');
				})
			});

			node.mvi.on('valuesChange', function()
			{
				var value_list = node.mvi.get('values'),
					select     = node.ancestor('.checkbox-multiselect').next('select');

				Y.each(select.getDOMNode().options, function(o)
				{
					o.selected = (Y.Array.indexOf(value_list, o.text) >= 0);
				});
			});

			this._destroyOnRender(node);
		});
	}
	else if (type == 'checkboxes')
	{
		var checkbox =
			'<p class="checkbox-multiselect-checkbox">' +
				'<input type="checkbox" id="{id}-{value}" value="{value}" {checked} />&nbsp;' +
				'<label for="{id}-{value}">{label}</label>' +
			'</p>';

		var column_start = '<td class="checkbox-multiselect-column">',
			column_end   = '</td>';

		var input_markup = Y.Array.reduce(o.field.values, '', function(s, v, i)
		{
			var m = Y.Lang.sub(checkbox,
			{
				id:      id,
				value:   v.value,
				checked: has_value && Y.Array.indexOf(o.value, v.value.toString()) >= 0 ? 'checked="checked"' : '',
				label:   BulkEditor.cleanHTML(v.text)
			});

			if (i > 0 && i % BulkEditor.checkbox_multiselect_column_height === 0)
			{
				m = column_end + column_start + m;
			}

			return s + m;
		});

		input_markup =
			'<table class="checkbox-multiselect-container"><tr>' +
			column_start + input_markup + column_end +
			'</tr></table>';
	}

	var option = '<option value="{value}" {selected}>{text}</option>';

	var options = Y.Array.reduce(o.field.values, '', function(s, v)
	{
		return s + Y.Lang.sub(option,
		{
			value:    v.value,
			text:     BulkEditor.cleanHTML(v.text),
			selected: has_value && Y.Array.indexOf(o.value, v.value.toString()) >= 0 ? 'selected="selected"' : ''
		});
	});

	var label = o.field && o.field.label ? BulkEditor.labelMarkup.call(this, o) : '';

	return Y.Lang.sub(select,
	{
		cont:  	 BulkEditor.field_container_class + ' ' + BulkEditor.field_container_class_prefix,
		field:   BulkEditor.field_class_prefix,
		key:     o.key,
		id:      id,
		label:   label,
		input:   input_markup,
		options: options,
		yiv:     (o.field && o.field.validation && o.field.validation.css) || '',
		msg:     BulkEditor.error_msg_markup
	});
}

function handleCheckboxMultiselectClickOnCheckbox(e)
{
	var cb     = e.currentTarget,
		value  = cb.get('value'),
		select = cb.ancestor('.checkbox-multiselect').next('select');

	Y.some(select.getDOMNode().options, function(o)
	{
		if (o.value == value)
		{
			o.selected = cb.get('checked');
			return true;
		}
	});
}

Y.BulkEditor = BulkEditor;