UNPKG

alpaca

Version:

Alpaca provides the easiest and fastest way to generate interactive forms for the web and mobile devices. It runs simply as HTML5 or more elaborately using Bootstrap, jQuery Mobile or jQuery UI. Alpaca uses Handlebars to process JSON schema and provide

1,519 lines (1,286 loc) 93.6 kB
(function($) { var Alpaca = $.alpaca; Alpaca.Field = Base.extend( /** * @lends Alpaca.Field.prototype */ { /** * @constructs * * @class Abstract class that served as base for all Alpaca field classes that provide actual implementation. * * @param {Object} domEl The dom element to which this field is ultimately rendering. * @param {Any} data Field data * @param {Object} options Field options. * @param {Object} schema Field schema. * @param {String} viewId view id * @param {Alpaca.Connector} connector Field connector. * @param {Function} errorCallback Error callback. */ constructor: function(domEl, data, options, schema, viewId, connector, errorCallback) { var self = this; // mark that we are initializing this.initializing = true; // domEl this.domEl = domEl; // parent this.parent = null; // config this.data = data; this.options = options; this.schema = schema; this.connector = connector; this.errorCallback = function(err) { if (errorCallback) { errorCallback(err); } else { Alpaca.defaultErrorCallback.call(self, err); } }; // check if this field rendering is single-level or not this.singleLevelRendering = false; // set a runtime view this.view = new Alpaca.RuntimeView(viewId, this); // things we can draw off the options var noOptions = false; if (!this.options) { this.options = {}; noOptions = true; } this.id = this.options.id; this.type = this.options.type; // setup defaults if (!this.id) { this.id = Alpaca.generateId(); } var noSchema = false; if (!this.schema) { this.schema = {}; noSchema = true; } if (!this.options.label && this.schema.title !== null) { this.options.label = this.schema.title; } /* if (!this.options.helper && this.schema.description !== null) { this.options.helper = this.schema.description; } */ // legacy support: options.helper -> convert to options.helpers if (!this.options.helpers) { this.options.helpers = []; } if (this.options.helper) { if (!Alpaca.isArray(this.options.helper)) { this.options.helpers.push(this.options.helper); } else { for (var i = 0; i < this.options.helper.length; i++) { this.options.helpers.push(this.options.helper[i]); } } // remove legacy value delete this.options.helper; } // options.helpersPosition defaults to above if (!this.options.helpersPosition) { this.options.helpersPosition = this.options.helperPosition } if (!this.options.helpersPosition) { this.options.helpersPosition = Alpaca.defaultHelpersPosition; } if (Alpaca.isEmpty(this.options.readonly) && !Alpaca.isEmpty(this.schema.readonly)) { this.options.readonly = this.schema.readonly; } // in case they put "default" on options if (typeof(this.schema.default) === "undefined") { if (typeof(this.options.default) !== "undefined") { this.schema.default = this.options.default; delete this.options.default; } } // if data is empty, then we check whether we can fall back to a default value if (Alpaca.isValEmpty(this.data) && !Alpaca.isEmpty(this.schema["default"])) { this.data = this.schema["default"]; this.showingDefaultData = true; } // default path this.path = "/"; // validation status this.validation = {}; // events this._events = {}; // helper function to determine if we're in a display-only mode this.isDisplayOnly = function() { return (self.view.type === "view" || self.view.type == "display"); }; // schema id cleanup if (this.schema && this.schema.id && this.schema.id.indexOf("#") === 0) { this.schema.id = this.schema.id.substring(1); } // has this field been previously validated? this._previouslyValidated = false; this.updateObservable = function() { // update observable if (this.getValue()) { this.observable(this.path).set(this.getValue()); } else { this.observable(this.path).clear(); } }; this.getObservableScope = function() { var top = this; while (!top.isTop()) { top = top.parent; } var observableScope = top.observableScope; if (!observableScope) { observableScope = "global"; } return observableScope; }; this.ensureProperType = function(val) { var self = this; var _ensure = function(v, type) { if (Alpaca.isString(v)) { if (type === "number") { v = parseFloat(v); } else if (type === "integer") { v = parseInt(v); } else if (type === "boolean") { if (v === "" || v.toLowerCase() === "false") { v = false; } else { v = true; } } } else if (Alpaca.isNumber(v)) { if (type === "string") { v = "" + v; } else if (type === "boolean") { if (v === -1 || v === 0) { v = false; } else { v = true; } } } return v; }; if (typeof(val) !== "undefined") { if (Alpaca.isArray(val)) { for (var i = 0; i < val.length; i++) { if (self.schema.items && self.schema.items.type) { val[i] = _ensure(val[i], self.schema.items.type); } } } else if (Alpaca.isString(val) || Alpaca.isNumber(val)) { if (self.schema.type) { val = _ensure(val, self.schema.type); } } } return val; }; this.onConstruct(); }, onConstruct: function() { }, isTop: function() { return !this.parent; }, /** * Get the id of the outer field template. * * For control fields, this is "control". * For container fields, this is "container". * * @returns {String} field template descriptor id */ getTemplateDescriptorId : function () { throw new Error("Template descriptor ID was not specified"); }, /** * Sets up default rendition template from view. */ initTemplateDescriptor: function() { var self = this; var viewTemplateDescriptor = this.view.getTemplateDescriptor(this.getTemplateDescriptorId(), this); var globalTemplateDescriptor = this.view.getGlobalTemplateDescriptor(); var layout = this.view.getLayout(); // we only allow the global or layout template to be applied to the top-most field var trip = false; if (this.isTop()) { if (globalTemplateDescriptor) { this.setTemplateDescriptor(globalTemplateDescriptor); this.singleLevelRendering = true; trip = true; } else if (layout && layout.templateDescriptor) { this.setTemplateDescriptor(layout.templateDescriptor); trip = true; } } if (!trip && viewTemplateDescriptor) { this.setTemplateDescriptor(viewTemplateDescriptor); } // ensure we have a template descriptor var t = this.getTemplateDescriptor(); if (!t) { return Alpaca.throwErrorWithCallback("Unable to find template descriptor for field: " + self.getFieldType()); } }, /** * This method will be called right after the field instance is created. It will initialize * the field to get it ready for rendition. */ setup: function() { /* if (!this.initializing) { this.data = this.getValue(); } */ // ensures that we have a template descriptor picked for this field this.initTemplateDescriptor(); // JSON SCHEMA if (Alpaca.isUndefined(this.schema.required)) { this.schema.required = false; } // VALIDATION if (Alpaca.isUndefined(this.options.validate)) { this.options.validate = true; } // OPTIONS if (Alpaca.isUndefined(this.options.disabled)) { this.options.disabled = false; } // MESSAGES if (Alpaca.isUndefined(this.options.showMessages)) { this.options.showMessages = true; } // support for "hidden" field on schema if (typeof(this.options.hidden) === "undefined") { if (typeof(this.schema.hidden) !== "undefined") { this.options.hidden = this.schema.hidden; } } }, setupField: function(callback) { callback(); }, /** * Registers an event listener. * * @param name * @param fn * @returns {*} */ on: function(name, fn) { Alpaca.logDebug("Adding listener for event: " + name); if (!this._events[name]) { this._events[name] = []; } this._events[name].push(fn); return this; }, /** * Unregisters all listeners for an event. * * @param name */ off: function(name) { if (this._events[name]) { this._events[name].length = 0; } }, /** * Triggers an event and propagates the event. * * By default, the behavior is to propagate up to the parent chain (bubble up). * * If "direction" is set to "down" and the field is a container, then the event is propagated down * to children (trickle down). * * If "direction" is set to "both", then both up and down are triggered. * * @param name * @param event * @param direction (optional) see above */ triggerWithPropagation: function(name, event, direction) { if (typeof(event) === "string") { direction = event; event = null; } if (!direction) { direction = "up"; } if (direction === "up") { // we trigger ourselves first this.trigger.call(this, name, event); // then we trigger parents if (this.parent) { this.parent.triggerWithPropagation.call(this.parent, name, event, direction); } } else if (direction === "down") { // do any children first if (this.children && this.children.length > 0) { for (var i = 0; i < this.children.length; i++) { var child = this.children[i]; child.triggerWithPropagation.call(child, name, event, direction); } } // do ourselves last this.trigger.call(this, name, event); } else if (direction === "both") { // do any children first if (this.children && this.children.length > 0) { for (var i = 0; i < this.children.length; i++) { var child = this.children[i]; child.triggerWithPropagation.call(child, name, event, "down"); } } // then do ourselves this.trigger.call(this, name, event); // then we trigger parents if (this.parent) { this.parent.triggerWithPropagation.call(this.parent, name, event, "up"); } } }, /** * Triggers an event * * @param name * @param event * * Remainder of arguments will be passed to the event handler. * * @returns {null} */ trigger: function(name, event, arg1, arg2, arg3) { // NOTE: this == control var handlers = this._events[name]; if (handlers) { for (var i = 0; i < handlers.length; i++) { var handler = handlers[i]; var ret = null; if (typeof(handler) === "function") { Alpaca.logDebug("Firing event: " + name); try { ret = handler.call(this, event, arg1, arg2, arg3); } catch (e) { Alpaca.logDebug("The event handler caught an exception: " + name); Alpaca.logDebug(e); } } } } }, /** * Binds the data into the field. Called at the very end of construction. */ bindData: function() { if (!Alpaca.isEmpty(this.data)) { this.setValue(this.data); } }, /** * This is the entry point method into the field. It is called by Alpaca for each field being rendered. * * Renders this field into the container and creates a DOM element which is bound into the container. * * @param {Object|String} view View to be used for rendering field (optional). * @param {Function} callback Post-Render callback (optional). */ render: function(view, callback) { var self = this; if (view && (Alpaca.isString(view) || Alpaca.isObject(view))) { this.view.setView(view); } else { if (Alpaca.isEmpty(callback) && Alpaca.isFunction(view)) { callback = view; } } // last try to see if we can populate the label from propertyId if (this.options.label === null && this.propertyId) { this.options.label = this.propertyId; } // make a copy of name field if (this.options.name) { this.name = this.options.name; } // calculate name this.calculateName(); this.setup(); this.setupField(function() { self._render(function() { // trigger the render event self.trigger("render"); callback(); }); }); }, calculateName: function() { if (!this.name || (this.name && this.nameCalculated)) { // has path? if (this.parent && this.parent.name && this.path) { if (this.propertyId) { this.name = this.parent.name + "_" + this.propertyId; this.nameCalculated = true; } else { var lastSegment = this.path.substring(this.path.lastIndexOf('/') + 1); if (lastSegment.indexOf("[") !== -1 && lastSegment.indexOf("]") !== -1) { lastSegment = lastSegment.substring(lastSegment.indexOf("[") + 1, lastSegment.indexOf("]")); } if (lastSegment) { this.name = this.parent.name + "_" + lastSegment; this.nameCalculated = true; } } } else { // generate name from the path if (this.path) { this.name = this.path.replace(/\//g,"").replace(/\[/g,"_").replace(/\]/g,""); this.nameCalculated = true; } } } }, /** * Internal method for processing the render. * * @private * @param {Function} callback Post-render callback. */ _render: function(callback) { var self = this; // check if it needs to be wrapped in a form if (self.options.form && Alpaca.isObject(self.options.form)) { self.options.form.viewType = this.view.type; var form = self.form; if (!form) { form = new Alpaca.Form(self.domEl, this.options.form, self.view.id, self.connector, self.errorCallback); } form.render(function(form) { // NOTE: form is the form instance (not the jquery element) var tempFieldHolder = $("<div></div>"); // load the appropriate template and render it self._processRender(tempFieldHolder, function() { // insert the field before our form fields container form.formFieldsContainer.before(self.field); // remove the formFieldsContainer marker form.formFieldsContainer.remove(); // bind the top field to the form form.topControl = self; if (self.view.type && self.view.type !== 'view') { form.initEvents(); } self.form = form; var me = self; // allow any post-rendering facilities to kick in self.postRender(function() { // finished initializing self.initializing = false; // allow for form to do some late updates self.form.afterInitialize(); // when the field removes, remove the form as well $(self.field).bind('destroyed', function (e) { self.form.destroy(); }); // callback if (callback && Alpaca.isFunction(callback)) { callback(self); } }); }); }); } else { // load the appropriate template and render it this._processRender(self.domEl, function() { // add "field" element to the domEl //$(self.field).appendTo(self.domEl); // allow any post-rendering facilities to kick in self.postRender(function() { // finished initializing self.initializing = false; // callback if (callback && Alpaca.isFunction(callback)) { callback(self); } }); }); } }, /** * Renders the field into the given parent element. * * Once completed, the callback method is called. * * @private * * @param {Object} parentEl Field container. * @param {Function} callback callback. */ _processRender: function(parentEl, callback) { var self = this; // render the field (outer element) self.renderField(parentEl, function() { // CALLBACK: "field" self.fireCallback("field"); // render any field elements self.renderFieldElements(function() { callback(); }); }); }, renderFieldDomElement: function(data) { var templateDescriptor = this.getTemplateDescriptor(); // render the field return Alpaca.tmpl(templateDescriptor, { "id": this.getId(), "options": this.options, "schema": this.schema, "data": data, "view": this.view, "path": this.path, "name": this.name }); }, /** * Renders the "field" outer element. This is usually the control or container. * * @param parentEl * @param onSuccess */ renderField: function(parentEl, onSuccess) { var self = this; // the data we'll render var theData = this.data; // if we're in display-only mode, and theData is an object, convert to string if (this.isDisplayOnly() && typeof(theData) === "object") { theData = JSON.stringify(theData); } var renderedDomElement = self.renderFieldDomElement(theData); // if we got back multiple elements, try to pick at the first DIV if ($(renderedDomElement).length > 0) { var single = null; for (var i = 0; i < $(renderedDomElement).length; i++) { var name = $(renderedDomElement)[i].nodeName; if (name) { name = name.toLowerCase(); if ("div" === name || "span" === name) { single = $($(renderedDomElement)[i]); break; } } } if (!single) { single = $($(renderedDomElement).last()); } if (single) { renderedDomElement = single; } } this.field = renderedDomElement; this.field.appendTo(parentEl); onSuccess(); }, /** * Renders any field elements. * * For controls or containers, this hook is used to inject additional dom elements into the outer field * dom element. Simple field types may choose not to implement this. * * @param callback {Function} callback */ renderFieldElements: function(callback) { callback(); }, /** * This gets called typically once per render. If a DOM element is moved within a container and it's indexing * changes, this will get called against to ensure that DOM properties are kept in sync. */ updateDOMElement: function() { // all fields get their path this.field.attr("data-alpaca-field-path", this.getPath()); // all fields get their name this.field.attr("data-alpaca-field-name", this.getName()); // name should not appear on field this.field.removeAttr("name"); }, /** * This method will be called after the field rendition is complete. It is served as a way to make final * modifications to the dom elements that were produced. */ postRender: function(callback) { var self = this; // all fields get the "alpaca-field" class which marks the outer element this.field.addClass("alpaca-field"); // all fields get marked by type as well this.field.addClass("alpaca-field-" + this.getFieldType()); // all fields get field id data attribute this.field.attr("data-alpaca-field-id", this.getId()); this.updateDOMElement(); // try to avoid adding unnecessary injections for display view. if (this.view.type !== 'view') { // optional if (this.isRequired()) { $(this.field).addClass("alpaca-required"); // CALLBACK: "required" self.fireCallback("required"); } else { $(this.field).addClass("alpaca-optional"); // CALLBACK: "optional" self.fireCallback("optional"); } var doDisableField = function() { // mark "disabled" attribute onto underlying element Alpaca.disabled($('input', self.field), true); Alpaca.disabled($('select', self.field), true); Alpaca.disabled($(':radio', self.field), true); Alpaca.disabled($(':checkbox', self.field), true); // special case for radio buttons (prevent clicks) $(":radio", self.field).off().click(function(e) { e.preventDefault(); e.stopImmediatePropagation(); return false; }); $(".radio label", self.field).off().click(function(e) { e.preventDefault(); e.stopImmediatePropagation(); return false; }); // special case (input field) $("input", self.field).off().click(function(e) { e.preventDefault(); e.stopImmediatePropagation(); return false; }); // fire disable function if (self.disable) { self.disable(); } }; // readonly if (this.options.readonly) { $(this.field).addClass("alpaca-readonly"); $('input', this.field).attr('readonly', 'readonly'); // disable the field doDisableField(); // CALLBACK: "readonly" self.fireCallback("readonly"); } // disabled if (this.options.disabled) { $(this.field).addClass("alpaca-disabled"); // disable the field doDisableField(); // CALLBACK: "disabled" self.fireCallback("disabled"); } // allow single or multiple field classes to be specified via the "fieldClass" // or "fieldClasses" options var applyFieldClass = function(el, thing) { if (thing) { var i = 0; var tokens = null; if (Alpaca.isArray(thing)) { for (i = 0; i < thing.length; i++) { el.addClass(thing[i]); } } else { if (thing.indexOf(",") > -1) { tokens = thing.split(","); for (i = 0; i < tokens.length; i++) { el.addClass(tokens[i]); } } else if (thing.indexOf(" ") > -1) { tokens = thing.split(" "); for (i = 0; i < tokens.length; i++) { el.addClass(tokens[i]); } } else { el.addClass(thing); } } } }; applyFieldClass(this.field, this.options["fieldClass"]); /* // Support for custom styles provided by custom view var customStyles = this.view.getStyles(); if (customStyles) { for (var styleClass in customStyles) { $(styleClass, this.domEl).css(customStyles[styleClass]); } } */ // after render if (this.options.disabled) { this.disable(); // CALLBACK: "disable" self.fireCallback("disable"); } // we bind data if we're in "edit" mode // typically, we don't bind data if we're in "create" or any other mode if (this.view.type && this.view.type === 'edit') { this.bindData(); } else if (this.showingDefaultData) { // if this control is showing default data, then we render the control anyway this.bindData(); } // some logging to be useful if (this.view.type === "create") { Alpaca.logDebug("Skipping data binding for field: " + this.id + " since view mode is 'create'"); } // initialize dom-level events if (this.view.type && this.view.type !== 'view') { this.initEvents(); } } // hidden if (this.options.hidden) { this.field.hide(); } var defaultHideInitValidationError = (this.view.type === 'create') && !this.refreshed; this.hideInitValidationError = Alpaca.isValEmpty(this.options.hideInitValidationError) ? defaultHideInitValidationError : this.options.hideInitValidationError; // for create view, hide all readonly fields if (!this.view.displayReadonly) { $(this.field).find(".alpaca-readonly").hide(); } // field level post render if (this.options.postRender) { this.options.postRender.call(this, function() { callback(); }); } else { callback(); } }, /** * Redraws the field using the currently bound DOM element and view. * * @param callback */ refresh: function(callback) { var self = this; // store back data var _externalData = self.getValue(); this.data = self.getValue(); // remember this stuff var oldDomEl = self.domEl; var oldField = self.field; //var oldControl = self.control; //var oldContainer = self.container; //var oldForm = self.form; // insert marker element before current field to mark where we'll render var markerEl = $("<div></div>"); $(oldField).before(markerEl); // temp domEl self.domEl = $("<div style='display: none'></div>"); // clear this stuff out self.field = undefined; self.control = undefined; self.container = undefined; self.form = undefined; // disable all buttons on our current field // we do this because repeated clicks could cause trouble while the field is in some half-state // during refresh $(oldField).find("button").prop("disabled", true); // mark that we are initializing this.initializing = true; // re-setup the field self.setup(); self.setupField(function() { // render self._render(function() { // move ahead of marker $(markerEl).before(self.field); // reset the domEl self.domEl = oldDomEl; // copy classes from oldField onto field var oldClasses = $(oldField).attr("class"); if (oldClasses) { $.each(oldClasses.split(" "), function(i, v) { if (v && !v.indexOf("alpaca-") === 0) { $(self.field).addClass(v); } }); } // hide the old field $(oldField).hide(); // remove marker $(markerEl).remove(); // mark that we're refreshed self.refreshed = true; /* // this is apparently needed for objects and arrays if (typeof(_externalData) !== "undefined") { if (Alpaca.isObject(_externalData) || Alpaca.isArray(_externalData)) { self.setValue(_externalData, true); } } */ // fire the "ready" event Alpaca.fireReady(self); if (callback) { callback.call(self); } // afterwards... // now clean up old field elements // the trick here is that we want to make sure we don't trigger the bound "destroyed" event handler // for the old dom el. // // the reason is that we have oldForm -> Field (with oldDomEl) // and form -> Field (with domEl) // // cleaning up "oldDomEl" causes "Field" to cleanup which causes "oldForm" to cleanup // which causes "Field" to cleanup which causes "domEl" to clean up (and also "form") // // here we just want to remove the dom elements for "oldDomEl" and "oldForm" without triggering // the special destroyer event // // appears that we can do this with a second argument...? // $(oldField).remove(undefined, { "nodestroy": true }); }); }); }, /** * Applies a view style to a dom element. * * @param id * @param target */ applyStyle: function(id, target) { this.view.applyStyle(id, target); }, /** * Fires a view callback for the current field. * * @param id * @param arg1 * @param arg2 * @param arg3 * @param arg4 * @param arg5 */ fireCallback: function(id, arg1, arg2, arg3, arg4, arg5) { this.view.fireCallback(this, id, arg1, arg2, arg3, arg4, arg5); }, /** * Retrieves the outer "field" rendered DOM element. * * If this field is a control field or a container field, this DOM element will wrap the inner "control" * and "container" elements respectively. In some cases, the wrapping might not exist in which case this * field may be the "control" or "container" field itself. * * @returns {Object} The rendered DOM element. */ getFieldEl: function() { return this.field; }, /** * Returns the id of the field. * * @returns Field id. */ getId: function() { return this.id; }, /** * Returns this field's parent. * * @returns {Alpaca.Field} Field parent. */ getParent: function() { return this.parent; }, /** * Retrieves the path to this element in the graph of JSON data. * * @returns {string} the path to this element */ getPath: function() { return this.path; }, /** * Retrieves the name of this element at the current level of JSON data. * * @returns {*} */ getName: function() { return this.name; }, /** * Finds if this field is top level. * * @returns {Boolean} True if this field is the top level one, false otherwise. */ isTopLevel: function() { return Alpaca.isEmpty(this.parent); }, /** * Walks up the parent chain and returns the top most control. If no parents, then current control is top control. * * @returns {Control} top most control */ top: function() { var top = this; while (top.parent) { top = top.parent; } return top; }, /** * Returns the value of this field. * * @returns {Any} value Field value. */ getValue: function() { var self = this; return self.ensureProperType(this.data); }, /** * Sets the value of the field. * * @param {Any} value Value to be set. */ setValue: function(value) { this.data = value; this.updateObservable(); this.triggerUpdate(); // special case - if we're in a display mode and not first render, then do a refresh here if (this.isDisplayOnly() && !this.initializing) { if (this.top && this.top() && this.top().initializing) { // if we're rendering under a top most control that isn't finished initializing, then don't refresh } else { this.refresh(); } } }, /** * Resets value to default. */ setDefault: function() { }, /** * Returns the field template descriptor. * * @returns {Object} template descriptor */ getTemplateDescriptor: function() { return this.templateDescriptor; }, /** * Sets the field template descriptor. * * @param {Object} template descriptor */ setTemplateDescriptor: function(templateDescriptor) { this.templateDescriptor = templateDescriptor; }, /** * Sets the validation state messages to show for a given field. * * @param {Object|Array} messages either a message object {id, message} or an array of message objects * @param {Boolean} beforeStatus Previous validation status. */ displayMessage: function(messages, beforeStatus) { var self = this; // if object, convert to array if (messages && Alpaca.isObject(messages)) { messages = [messages]; } // if string, convert if (messages && Alpaca.isString(messages)) { messages = [{ "id": "custom", "message": messages }]; } // remove any alpaca messages for this field $(this.getFieldEl()).children(".alpaca-message").remove(); // maxMessage if (messages && messages.length > 0) { if(this.options.maxMessages && Alpaca.isNumber(this.options.maxMessages) && this.options.maxMessages > -1) { messages = messages.slice(0,this.options.maxMessages); } } // CALLBACK: "removeMessages" self.fireCallback("removeMessages"); // add message and generate it if (messages && messages.length > 0) { $.each(messages, function(index, messageObject) { var hidden = false; if (self.hideInitValidationError) { hidden = true; } // add message to the field var messageTemplateDescriptor = self.view.getTemplateDescriptor("message"); if (messageTemplateDescriptor) { var messageElement = Alpaca.tmpl(messageTemplateDescriptor, { "id": messageObject.id, "message": messageObject.message, "view": self.view }); messageElement.addClass("alpaca-message"); if (hidden) { messageElement.addClass("alpaca-message-hidden"); } $(self.getFieldEl()).append(messageElement); } // CALLBACK: "addMessage" self.fireCallback("addMessage", index, messageObject.id, messageObject.message, hidden); }); } }, /** * Forces the validation for a field to be refreshed or redrawn to the screen. * * If told to check children, then all children of the container field will be refreshed as well. * * @param {Boolean} validateChildren whether to refresh validation for children * @param [Function] optional callback when validation completes */ refreshValidationState: function(validateChildren, cb) { // console.log("Call refreshValidationState: " + this.path); var self = this; // run validation context compilation for ourselves and optionally any children var contexts = []; var functions = []; // constructs an async function to validate context for a given field var functionBuilder = function(field, contexts) { return function(callback) { // run on the next tick Alpaca.nextTick(function() { Alpaca.compileValidationContext(field, function(context) { contexts.push(context); callback(); }); }); }; }; // wrap up everything we need to do into async callback methods if (validateChildren) { // depth first crawl across all children var crawl = function(field, contexts) { if (field.isValidationParticipant()) { // if the field has children, go depth first if (field.children && field.children.length > 0) { for (var i = 0; i < field.children.length; i++) { crawl(field.children[i], contexts); } } functions.push(functionBuilder(field, contexts)); } }; crawl(this, contexts); } // add ourselves in last functions.push(functionBuilder(this, contexts)); // now run all of the functions in parallel Alpaca.parallel(functions, function(err) { // contexts now contains all of the validation results // merge all contexts into a single validation context for this field var mergedMap = {}; var mergedContext = []; for (var i = 0; i < contexts.length; i++) { var context = contexts[i]; // NOTE: context is already in order [child, parent, ...] var mIndex = mergedContext.length; // walk forward for (var j = 0; j < context.length; j++) { var entry = context[j]; var existing = mergedMap[entry.id]; if (!existing) { // just add to end var newEntry = {}; newEntry.id = entry.id; newEntry.path = entry.path; newEntry.domEl = entry.domEl; newEntry.field = entry.field; newEntry.validated = entry.validated; newEntry.invalidated = entry.invalidated; newEntry.valid = entry.valid; mergedContext.splice(mIndex, 0, newEntry); // mark in map mergedMap[newEntry.id] = newEntry; } else { if (entry.validated && !existing.invalidated) { existing.validated = true; existing.invalidated = false; existing.valid = entry.valid; } if (entry.invalidated) { existing.invalidated = true;