From ef3d20ece75f22c67206bb3afd7e7df720ea7d2f Mon Sep 17 00:00:00 2001 From: Erik Wickstrom Date: Tue, 2 Jul 2013 17:15:15 -0700 Subject: [PATCH] Added backbone-forms version 0.12.0. --- .../adapters/backbone.bootstrap-modal.js | 279 ++ .../adapters/backbone.bootstrap-modal.min.js | 1 + .../backbone-forms/0.12.0/backbone-forms.js | 2366 +++++++++++++++++ .../0.12.0/backbone-forms.min.js | 1 + .../backbone-forms/0.12.0/editors/list.js | 650 +++++ .../backbone-forms/0.12.0/editors/list.min.js | 1 + .../adapters/backbone.bootstrap-modal.js | 279 ++ .../adapters/backbone.bootstrap-modal.min.js | 1 + .../0.12.0/templates/backbone-forms.js | 2366 +++++++++++++++++ .../0.12.0/templates/backbone-forms.min.js | 1 + .../0.12.0/templates/bootstrap.css | 43 + .../0.12.0/templates/bootstrap.js | 65 + .../0.12.0/templates/editors/list.js | 650 +++++ .../0.12.0/templates/editors/list.min.js | 1 + .../backbone-forms/0.12.0/templates/old.css | 140 + .../backbone-forms/0.12.0/templates/old.js | 90 + ajax/libs/backbone-forms/package.json | 21 + 17 files changed, 6955 insertions(+) create mode 100644 ajax/libs/backbone-forms/0.12.0/adapters/backbone.bootstrap-modal.js create mode 100644 ajax/libs/backbone-forms/0.12.0/adapters/backbone.bootstrap-modal.min.js create mode 100644 ajax/libs/backbone-forms/0.12.0/backbone-forms.js create mode 100644 ajax/libs/backbone-forms/0.12.0/backbone-forms.min.js create mode 100644 ajax/libs/backbone-forms/0.12.0/editors/list.js create mode 100644 ajax/libs/backbone-forms/0.12.0/editors/list.min.js create mode 100644 ajax/libs/backbone-forms/0.12.0/templates/adapters/backbone.bootstrap-modal.js create mode 100644 ajax/libs/backbone-forms/0.12.0/templates/adapters/backbone.bootstrap-modal.min.js create mode 100644 ajax/libs/backbone-forms/0.12.0/templates/backbone-forms.js create mode 100644 ajax/libs/backbone-forms/0.12.0/templates/backbone-forms.min.js create mode 100644 ajax/libs/backbone-forms/0.12.0/templates/bootstrap.css create mode 100644 ajax/libs/backbone-forms/0.12.0/templates/bootstrap.js create mode 100644 ajax/libs/backbone-forms/0.12.0/templates/editors/list.js create mode 100644 ajax/libs/backbone-forms/0.12.0/templates/editors/list.min.js create mode 100644 ajax/libs/backbone-forms/0.12.0/templates/old.css create mode 100644 ajax/libs/backbone-forms/0.12.0/templates/old.js create mode 100644 ajax/libs/backbone-forms/package.json diff --git a/ajax/libs/backbone-forms/0.12.0/adapters/backbone.bootstrap-modal.js b/ajax/libs/backbone-forms/0.12.0/adapters/backbone.bootstrap-modal.js new file mode 100644 index 000000000..3677c6072 --- /dev/null +++ b/ajax/libs/backbone-forms/0.12.0/adapters/backbone.bootstrap-modal.js @@ -0,0 +1,279 @@ +/** + * Bootstrap Modal wrapper for use with Backbone. + * + * Takes care of instantiation, manages multiple modals, + * adds several options and removes the element from the DOM when closed + * + * @author Charles Davison + * + * Events: + * shown: Fired when the modal has finished animating in + * hidden: Fired when the modal has finished animating out + * cancel: The user dismissed the modal + * ok: The user clicked OK + */ +(function($, _, Backbone) { + + //Set custom template settings + var _interpolateBackup = _.templateSettings; + _.templateSettings = { + interpolate: /\{\{(.+?)\}\}/g, + evaluate: /<%([\s\S]+?)%>/g + } + + var template = _.template('\ + <% if (title) { %>\ + \ + <% } %>\ + \ + \ + '); + + //Reset to users' template settings + _.templateSettings = _interpolateBackup; + + + var Modal = Backbone.View.extend({ + + className: 'modal', + + events: { + 'click .close': function(event) { + event.preventDefault(); + + this.trigger('cancel'); + + if (this.options.content && this.options.content.trigger) { + this.options.content.trigger('cancel', this); + } + }, + 'click .cancel': function(event) { + event.preventDefault(); + + this.trigger('cancel'); + + if (this.options.content && this.options.content.trigger) { + this.options.content.trigger('cancel', this); + } + }, + 'click .ok': function(event) { + event.preventDefault(); + + this.trigger('ok'); + + if (this.options.content && this.options.content.trigger) { + this.options.content.trigger('ok', this); + } + + if (this.options.okCloses) { + this.close(); + } + } + }, + + /** + * Creates an instance of a Bootstrap Modal + * + * @see http://twitter.github.com/bootstrap/javascript.html#modals + * + * @param {Object} options + * @param {String|View} [options.content] Modal content. Default: none + * @param {String} [options.title] Title. Default: none + * @param {String} [options.okText] Text for the OK button. Default: 'OK' + * @param {String} [options.cancelText] Text for the cancel button. Default: 'Cancel'. If passed a falsey value, the button will be removed + * @param {Boolean} [options.allowCancel Whether the modal can be closed, other than by pressing OK. Default: true + * @param {Boolean} [options.escape] Whether the 'esc' key can dismiss the modal. Default: true, but false if options.cancellable is true + * @param {Boolean} [options.animate] Whether to animate in/out. Default: false + * @param {Function} [options.template] Compiled underscore template to override the default one + */ + initialize: function(options) { + this.options = _.extend({ + title: null, + okText: 'OK', + focusOk: true, + okCloses: true, + cancelText: 'Cancel', + allowCancel: true, + escape: true, + animate: false, + template: template + }, options); + }, + + /** + * Creates the DOM element + * + * @api private + */ + render: function() { + var $el = this.$el, + options = this.options, + content = options.content; + + //Create the modal container + $el.html(options.template(options)); + + var $content = this.$content = $el.find('.modal-body') + + //Insert the main content if it's a view + if (content.$el) { + content.render(); + $el.find('.modal-body').html(content.$el); + } + + if (options.animate) $el.addClass('fade'); + + this.isRendered = true; + + return this; + }, + + /** + * Renders and shows the modal + * + * @param {Function} [cb] Optional callback that runs only when OK is pressed. + */ + open: function(cb) { + if (!this.isRendered) this.render(); + + var self = this, + $el = this.$el; + + //Create it + $el.modal(_.extend({ + keyboard: this.options.allowCancel, + backdrop: this.options.allowCancel ? true : 'static' + }, this.options.modalOptions)); + + //Focus OK button + $el.one('shown', function() { + if (self.options.focusOk) { + $el.find('.btn.ok').focus(); + } + + if (self.options.content && self.options.content.trigger) { + self.options.content.trigger('shown', self); + } + + self.trigger('shown'); + }); + + //Adjust the modal and backdrop z-index; for dealing with multiple modals + var numModals = Modal.count, + $backdrop = $('.modal-backdrop:eq('+numModals+')'), + backdropIndex = parseInt($backdrop.css('z-index'),10), + elIndex = parseInt($backdrop.css('z-index'), 10); + + $backdrop.css('z-index', backdropIndex + numModals); + this.$el.css('z-index', elIndex + numModals); + + if (this.options.allowCancel) { + $backdrop.one('click', function() { + if (self.options.content && self.options.content.trigger) { + self.options.content.trigger('cancel', self); + } + + self.trigger('cancel'); + }); + + $(document).one('keyup.dismiss.modal', function (e) { + e.which == 27 && self.trigger('cancel'); + + if (self.options.content && self.options.content.trigger) { + e.which == 27 && self.options.content.trigger('shown', self); + } + }); + } + + this.on('cancel', function() { + self.close(); + }); + + Modal.count++; + + //Run callback on OK if provided + if (cb) { + self.on('ok', cb); + } + + return this; + }, + + /** + * Closes the modal + */ + close: function() { + var self = this, + $el = this.$el; + + //Check if the modal should stay open + if (this._preventClose) { + this._preventClose = false; + return; + } + + $el.one('hidden', function onHidden(e) { + // Ignore events propagated from interior objects, like bootstrap tooltips + if(e.target !== e.currentTarget){ + return $el.one('hidden', onHidden); + } + self.remove(); + + if (self.options.content && self.options.content.trigger) { + self.options.content.trigger('hidden', self); + } + + self.trigger('hidden'); + }); + + $el.modal('hide'); + + Modal.count--; + }, + + /** + * Stop the modal from closing. + * Can be called from within a 'close' or 'ok' event listener. + */ + preventClose: function() { + this._preventClose = true; + } + }, { + //STATICS + + //The number of modals on display + count: 0 + }); + + + //EXPORTS + //CommonJS + if (typeof require == 'function' && typeof module !== 'undefined' && exports) { + module.exports = Modal; + } + + //AMD / RequireJS + if (typeof define === 'function' && define.amd) { + return define(function() { + Backbone.BootstrapModal = Modal; + }) + } + + //Regular; add to Backbone.Bootstrap.Modal + else { + Backbone.BootstrapModal = Modal; + } + +})(jQuery, _, Backbone); diff --git a/ajax/libs/backbone-forms/0.12.0/adapters/backbone.bootstrap-modal.min.js b/ajax/libs/backbone-forms/0.12.0/adapters/backbone.bootstrap-modal.min.js new file mode 100644 index 000000000..0a66d5664 --- /dev/null +++ b/ajax/libs/backbone-forms/0.12.0/adapters/backbone.bootstrap-modal.min.js @@ -0,0 +1 @@ +(function(e,t,n){var r=t.templateSettings;t.templateSettings={interpolate:/\{\{(.+?)\}\}/g,evaluate:/<%([\s\S]+?)%>/g};var i=t.template(' <% if (title) { %> <% } %> ');t.templateSettings=r;var s=n.View.extend({className:"modal",events:{"click .close":function(e){e.preventDefault(),this.trigger("cancel"),this.options.content&&this.options.content.trigger&&this.options.content.trigger("cancel",this)},"click .cancel":function(e){e.preventDefault(),this.trigger("cancel"),this.options.content&&this.options.content.trigger&&this.options.content.trigger("cancel",this)},"click .ok":function(e){e.preventDefault(),this.trigger("ok"),this.options.content&&this.options.content.trigger&&this.options.content.trigger("ok",this),this.options.okCloses&&this.close()}},initialize:function(e){this.options=t.extend({title:null,okText:"OK",focusOk:!0,okCloses:!0,cancelText:"Cancel",allowCancel:!0,escape:!0,animate:!1,template:i},e)},render:function(){var e=this.$el,t=this.options,n=t.content;e.html(t.template(t));var r=this.$content=e.find(".modal-body");return n.$el&&(n.render(),e.find(".modal-body").html(n.$el)),t.animate&&e.addClass("fade"),this.isRendered=!0,this},open:function(n){this.isRendered||this.render();var r=this,i=this.$el;i.modal(t.extend({keyboard:this.options.allowCancel,backdrop:this.options.allowCancel?!0:"static"},this.options.modalOptions)),i.one("shown",function(){r.options.focusOk&&i.find(".btn.ok").focus(),r.options.content&&r.options.content.trigger&&r.options.content.trigger("shown",r),r.trigger("shown")});var o=s.count,u=e(".modal-backdrop:eq("+o+")"),a=parseInt(u.css("z-index"),10),f=parseInt(u.css("z-index"),10);return u.css("z-index",a+o),this.$el.css("z-index",f+o),this.options.allowCancel&&(u.one("click",function(){r.options.content&&r.options.content.trigger&&r.options.content.trigger("cancel",r),r.trigger("cancel")}),e(document).one("keyup.dismiss.modal",function(e){e.which==27&&r.trigger("cancel"),r.options.content&&r.options.content.trigger&&e.which==27&&r.options.content.trigger("shown",r)})),this.on("cancel",function(){r.close()}),s.count++,n&&r.on("ok",n),this},close:function(){var e=this,t=this.$el;if(this._preventClose){this._preventClose=!1;return}t.one("hidden",function n(r){if(r.target!==r.currentTarget)return t.one("hidden",n);e.remove(),e.options.content&&e.options.content.trigger&&e.options.content.trigger("hidden",e),e.trigger("hidden")}),t.modal("hide"),s.count--},preventClose:function(){this._preventClose=!0}},{count:0});typeof require=="function"&&typeof module!="undefined"&&exports&&(module.exports=s);if(typeof define=="function"&&define.amd)return define(function(){n.BootstrapModal=s});n.BootstrapModal=s})(jQuery,_,Backbone) \ No newline at end of file diff --git a/ajax/libs/backbone-forms/0.12.0/backbone-forms.js b/ajax/libs/backbone-forms/0.12.0/backbone-forms.js new file mode 100644 index 000000000..6c1cda2f2 --- /dev/null +++ b/ajax/libs/backbone-forms/0.12.0/backbone-forms.js @@ -0,0 +1,2366 @@ +/** + * Backbone Forms v0.12.0 + * + * Copyright (c) 2013 Charles Davison, Pow Media Ltd + * + * License and more information at: + * http://github.com/powmedia/backbone-forms + */ +;(function(root) { + + //DEPENDENCIES + //CommonJS + if (typeof exports !== 'undefined' && typeof require !== 'undefined') { + var $ = root.jQuery || root.Zepto || root.ender || require('jquery'), + _ = root._ || require('underscore'), + Backbone = root.Backbone || require('backbone'); + } + + //Browser + else { + var $ = root.jQuery, + _ = root._, + Backbone = root.Backbone; + } + + + //SOURCE + +//================================================================================================== +//FORM +//================================================================================================== + +var Form = Backbone.View.extend({ + + /** + * Constructor + * + * @param {Object} [options.schema] + * @param {Backbone.Model} [options.model] + * @param {Object} [options.data] + * @param {String[]|Object[]} [options.fieldsets] + * @param {String[]} [options.fields] + * @param {String} [options.idPrefix] + * @param {Form.Field} [options.Field] + * @param {Form.Fieldset} [options.Fieldset] + * @param {Function} [options.template] + */ + initialize: function(options) { + var self = this; + + options = options || {}; + + //Find the schema to use + var schema = this.schema = (function() { + //Prefer schema from options + if (options.schema) return _.result(options, 'schema'); + + //Then schema on model + var model = options.model; + if (model && model.schema) { + return (_.isFunction(model.schema)) ? model.schema() : model.schema; + } + + //Then built-in schema + if (self.schema) { + return (_.isFunction(self.schema)) ? self.schema() : self.schema; + } + + //Fallback to empty schema + return {}; + })(); + + //Store important data + _.extend(this, _.pick(options, 'model', 'data', 'idPrefix')); + + //Override defaults + var constructor = this.constructor; + this.template = options.template || constructor.template; + this.Fieldset = options.Fieldset || constructor.Fieldset; + this.Field = options.Field || constructor.Field; + this.NestedField = options.NestedField || constructor.NestedField; + + //Check which fields will be included (defaults to all) + var selectedFields = this.selectedFields = options.fields || _.keys(schema); + + //Create fields + var fields = this.fields = {}; + + _.each(selectedFields, function(key) { + var fieldSchema = schema[key]; + fields[key] = this.createField(key, fieldSchema); + }, this); + + //Create fieldsets + var fieldsetSchema = options.fieldsets || [selectedFields], + fieldsets = this.fieldsets = []; + + _.each(fieldsetSchema, function(itemSchema) { + this.fieldsets.push(this.createFieldset(itemSchema)); + }, this); + }, + + /** + * Creates a Fieldset instance + * + * @param {String[]|Object[]} schema Fieldset schema + * + * @return {Form.Fieldset} + */ + createFieldset: function(schema) { + var options = { + schema: schema, + fields: this.fields + }; + + return new this.Fieldset(options); + }, + + /** + * Creates a Field instance + * + * @param {String} key + * @param {Object} schema Field schema + * + * @return {Form.Field} + */ + createField: function(key, schema) { + var options = { + form: this, + key: key, + schema: schema, + idPrefix: this.idPrefix + }; + + if (this.model) { + options.model = this.model; + } else if (this.data) { + options.value = this.data[key]; + } else { + options.value = null; + } + + var field = new this.Field(options); + + this.listenTo(field.editor, 'all', this.handleEditorEvent); + + return field; + }, + + /** + * Callback for when an editor event is fired. + * Re-triggers events on the form as key:event and triggers additional form-level events + * + * @param {String} event + * @param {Editor} editor + */ + handleEditorEvent: function(event, editor) { + //Re-trigger editor events on the form + var formEvent = editor.key+':'+event; + + this.trigger.call(this, formEvent, this, editor); + + //Trigger additional events + switch (event) { + case 'change': + this.trigger('change', this); + break; + + case 'focus': + if (!this.hasFocus) this.trigger('focus', this); + break; + + case 'blur': + if (this.hasFocus) { + //TODO: Is the timeout etc needed? + var self = this; + setTimeout(function() { + var focusedField = _.find(self.fields, function(field) { + return field.editor.hasFocus; + }); + + if (!focusedField) self.trigger('blur', self); + }, 0); + } + break; + } + }, + + render: function() { + var self = this, + fields = this.fields; + + //Render form + var $form = $($.trim(this.template(_.result(this, 'templateData')))); + + //Render standalone editors + $form.find('[data-editors]').add($form).each(function(i, el) { + var $container = $(el), + selection = $container.attr('data-editors'); + + if (_.isUndefined(selection)) return; + + //Work out which fields to include + var keys = (selection == '*') + ? self.selectedFields || _.keys(fields) + : selection.split(','); + + //Add them + _.each(keys, function(key) { + var field = fields[key]; + + $container.append(field.editor.render().el); + }); + }); + + //Render standalone fields + $form.find('[data-fields]').add($form).each(function(i, el) { + var $container = $(el), + selection = $container.attr('data-fields'); + + if (_.isUndefined(selection)) return; + + //Work out which fields to include + var keys = (selection == '*') + ? self.selectedFields || _.keys(fields) + : selection.split(','); + + //Add them + _.each(keys, function(key) { + var field = fields[key]; + + $container.append(field.render().el); + }); + }); + + //Render fieldsets + $form.find('[data-fieldsets]').add($form).each(function(i, el) { + var $container = $(el), + selection = $container.attr('data-fieldsets'); + + if (_.isUndefined(selection)) return; + + _.each(self.fieldsets, function(fieldset) { + $container.append(fieldset.render().el); + }); + }); + + //Set the main element + this.setElement($form); + + return this; + }, + + /** + * Validate the data + * + * @return {Object} Validation errors + */ + validate: function() { + var self = this, + fields = this.fields, + model = this.model, + errors = {}; + + //Collect errors from schema validation + _.each(fields, function(field) { + var error = field.validate(); + if (error) { + errors[field.key] = error; + } + }); + + //Get errors from default Backbone model validator + if (model && model.validate) { + var modelErrors = model.validate(this.getValue()); + + if (modelErrors) { + var isDictionary = _.isObject(modelErrors) && !_.isArray(modelErrors); + + //If errors are not in object form then just store on the error object + if (!isDictionary) { + errors._others = errors._others || []; + errors._others.push(modelErrors); + } + + //Merge programmatic errors (requires model.validate() to return an object e.g. { fieldKey: 'error' }) + if (isDictionary) { + _.each(modelErrors, function(val, key) { + //Set error on field if there isn't one already + if (fields[key] && !errors[key]) { + fields[key].setError(val); + errors[key] = val; + } + + else { + //Otherwise add to '_others' key + errors._others = errors._others || []; + var tmpErr = {}; + tmpErr[key] = val; + errors._others.push(tmpErr); + } + }); + } + } + } + + return _.isEmpty(errors) ? null : errors; + }, + + /** + * Update the model with all latest values. + * + * @param {Object} [options] Options to pass to Model#set (e.g. { silent: true }) + * + * @return {Object} Validation errors + */ + commit: function(options) { + //Validate + var errors = this.validate(); + if (errors) return errors; + + //Commit + var modelError; + + var setOptions = _.extend({ + error: function(model, e) { + modelError = e; + } + }, options); + + this.model.set(this.getValue(), setOptions); + + if (modelError) return modelError; + }, + + /** + * Get all the field values as an object. + * Use this method when passing data instead of objects + * + * @param {String} [key] Specific field value to get + */ + getValue: function(key) { + //Return only given key if specified + if (key) return this.fields[key].getValue(); + + //Otherwise return entire form + var values = {}; + _.each(this.fields, function(field) { + values[field.key] = field.getValue(); + }); + + return values; + }, + + /** + * Update field values, referenced by key + * + * @param {Object|String} key New values to set, or property to set + * @param val Value to set + */ + setValue: function(prop, val) { + var data = {}; + if (typeof prop === 'string') { + data[prop] = val; + } else { + data = prop; + } + + var key; + for (key in this.schema) { + if (data[key] !== undefined) { + this.fields[key].setValue(data[key]); + } + } + }, + + /** + * Returns the editor for a given field key + * + * @param {String} key + * + * @return {Editor} + */ + getEditor: function(key) { + var field = this.fields[key]; + if (!field) throw 'Field not found: '+key; + + return field.editor; + }, + + /** + * Gives the first editor in the form focus + */ + focus: function() { + if (this.hasFocus) return; + + //Get the first field + var fieldset = this.fieldsets[0], + field = fieldset.getFieldAt(0); + + if (!field) return; + + //Set focus + field.editor.focus(); + }, + + /** + * Removes focus from the currently focused editor + */ + blur: function() { + if (!this.hasFocus) return; + + var focusedField = _.find(this.fields, function(field) { + return field.editor.hasFocus; + }); + + if (focusedField) focusedField.editor.blur(); + }, + + /** + * Manages the hasFocus property + * + * @param {String} event + */ + trigger: function(event) { + if (event === 'focus') { + this.hasFocus = true; + } + else if (event === 'blur') { + this.hasFocus = false; + } + + return Backbone.View.prototype.trigger.apply(this, arguments); + }, + + /** + * Override default remove function in order to remove embedded views + * + * TODO: If editors are included directly with data-editors="x", they need to be removed + * May be best to use XView to manage adding/removing views + */ + remove: function() { + _.each(this.fieldsets, function(fieldset) { + fieldset.remove(); + }); + + _.each(this.fields, function(field) { + field.remove(); + }); + + Backbone.View.prototype.remove.call(this); + } + +}, { + + //STATICS + template: _.template('\ +
\ + ', null, this.templateSettings), + + templateSettings: { + evaluate: /<%([\s\S]+?)%>/g, + interpolate: /<%=([\s\S]+?)%>/g, + escape: /<%-([\s\S]+?)%>/g + }, + + editors: {} + +}); + + +//================================================================================================== +//VALIDATORS +//================================================================================================== + +Form.validators = (function() { + + var validators = {}; + + validators.errMessages = { + required: 'Required', + regexp: 'Invalid', + email: 'Invalid email address', + url: 'Invalid URL', + match: _.template('Must match field "<%= field %>"', null, Form.templateSettings) + }; + + validators.required = function(options) { + options = _.extend({ + type: 'required', + message: this.errMessages.required + }, options); + + return function required(value) { + options.value = value; + + var err = { + type: options.type, + message: _.isFunction(options.message) ? options.message(options) : options.message + }; + + if (value === null || value === undefined || value === false || value === '') return err; + }; + }; + + validators.regexp = function(options) { + if (!options.regexp) throw new Error('Missing required "regexp" option for "regexp" validator'); + + options = _.extend({ + type: 'regexp', + message: this.errMessages.regexp + }, options); + + return function regexp(value) { + options.value = value; + + var err = { + type: options.type, + message: _.isFunction(options.message) ? options.message(options) : options.message + }; + + //Don't check empty values (add a 'required' validator for this) + if (value === null || value === undefined || value === '') return; + + if (!options.regexp.test(value)) return err; + }; + }; + + validators.email = function(options) { + options = _.extend({ + type: 'email', + message: this.errMessages.email, + regexp: /^[\w\-]{1,}([\w\-\+.]{1,1}[\w\-]{1,}){0,}[@][\w\-]{1,}([.]([\w\-]{1,})){1,3}$/ + }, options); + + return validators.regexp(options); + }; + + validators.url = function(options) { + options = _.extend({ + type: 'url', + message: this.errMessages.url, + regexp: /^(http|https):\/\/(([A-Z0-9][A-Z0-9_\-]*)(\.[A-Z0-9][A-Z0-9_\-]*)+)(:(\d+))?\/?/i + }, options); + + return validators.regexp(options); + }; + + validators.match = function(options) { + if (!options.field) throw new Error('Missing required "field" options for "match" validator'); + + options = _.extend({ + type: 'match', + message: this.errMessages.match + }, options); + + return function match(value, attrs) { + options.value = value; + + var err = { + type: options.type, + message: _.isFunction(options.message) ? options.message(options) : options.message + }; + + //Don't check empty values (add a 'required' validator for this) + if (value === null || value === undefined || value === '') return; + + if (value !== attrs[options.field]) return err; + }; + }; + + + return validators; + +})(); + + +//================================================================================================== +//FIELDSET +//================================================================================================== + +Form.Fieldset = Backbone.View.extend({ + + /** + * Constructor + * + * Valid fieldset schemas: + * ['field1', 'field2'] + * { legend: 'Some Fieldset', fields: ['field1', 'field2'] } + * + * @param {String[]|Object[]} options.schema Fieldset schema + * @param {Object} options.fields Form fields + */ + initialize: function(options) { + options = options || {}; + + //Create the full fieldset schema, merging defaults etc. + var schema = this.schema = this.createSchema(options.schema); + + //Store the fields for this fieldset + this.fields = _.pick(options.fields, schema.fields); + + //Override defaults + this.template = options.template || this.constructor.template; + }, + + /** + * Creates the full fieldset schema, normalising, merging defaults etc. + * + * @param {String[]|Object[]} schema + * + * @return {Object} + */ + createSchema: function(schema) { + //Normalise to object + if (_.isArray(schema)) { + schema = { fields: schema }; + } + + //Add null legend to prevent template error + schema.legend = schema.legend || null; + + return schema; + }, + + /** + * Returns the field for a given index + * + * @param {Number} index + * + * @return {Field} + */ + getFieldAt: function(index) { + var key = this.schema.fields[index]; + + return this.fields[key]; + }, + + /** + * Returns data to pass to template + * + * @return {Object} + */ + templateData: function() { + return this.schema; + }, + + /** + * Renders the fieldset and fields + * + * @return {Fieldset} this + */ + render: function() { + var schema = this.schema, + fields = this.fields; + + //Render fieldset + var $fieldset = $($.trim(this.template(_.result(this, 'templateData')))); + + //Render fields + $fieldset.find('[data-fields]').add($fieldset).each(function(i, el) { + var $container = $(el), + selection = $container.attr('data-fields'); + + if (_.isUndefined(selection)) return; + + _.each(fields, function(field) { + $container.append(field.render().el); + }); + }); + + this.setElement($fieldset); + + return this; + }, + + /** + * Remove embedded views then self + */ + remove: function() { + _.each(this.fields, function(field) { + field.remove(); + }); + + Backbone.View.prototype.remove.call(this); + } + +}, { + //STATICS + + template: _.template('\ +
\ + <% if (legend) { %>\ + <%= legend %>\ + <% } %>\ +
\ + ', null, Form.templateSettings) + +}); + + +//================================================================================================== +//FIELD +//================================================================================================== + +Form.Field = Backbone.View.extend({ + + /** + * Constructor + * + * @param {Object} options.key + * @param {Object} options.form + * @param {Object} [options.schema] + * @param {Backbone.Model} [options.model] + * @param {Object} [options.value] + * @param {String} [options.idPrefix] + * @param {Function} [options.template] + * @param {Function} [options.errorClassName] + */ + initialize: function(options) { + options = options || {}; + + //Store important data + _.extend(this, _.pick(options, 'form', 'key', 'model', 'value', 'idPrefix')); + + //Override defaults + this.template = options.template || this.constructor.template; + this.errorClassName = options.errorClassName || this.constructor.errorClassName; + + //Create the full field schema, merging defaults etc. + this.schema = this.createSchema(options.schema); + + //Create editor + this.editor = this.createEditor(); + }, + + /** + * Creates the full field schema, merging defaults etc. + * + * @param {Object|String} schema + * + * @return {Object} + */ + createSchema: function(schema) { + if (_.isString(schema)) schema = { type: schema }; + + //Set defaults + schema = _.extend({ + type: 'Text', + title: this.createTitle() + }, schema); + + //Get the real constructor function i.e. if type is a string such as 'Text' + schema.type = (_.isString(schema.type)) ? Form.editors[schema.type] : schema.type; + + return schema; + }, + + /** + * Creates the editor specified in the schema; either an editor string name or + * a constructor function + * + * @return {View} + */ + createEditor: function() { + var options = _.extend( + _.pick(this, 'schema', 'form', 'key', 'model', 'value'), + { id: this.createEditorId() } + ); + + var constructorFn = this.schema.type; + + return new constructorFn(options); + }, + + /** + * Creates the ID that will be assigned to the editor + * + * @return {String} + */ + createEditorId: function() { + var prefix = this.idPrefix, + id = this.key; + + //Replace periods with underscores (e.g. for when using paths) + id = id.replace(/\./g, '_'); + + //If a specific ID prefix is set, use it + if (_.isString(prefix) || _.isNumber(prefix)) return prefix + id; + if (_.isNull(prefix)) return id; + + //Otherwise, if there is a model use it's CID to avoid conflicts when multiple forms are on the page + if (this.model) return this.model.cid + '_' + id; + + return id; + }, + + /** + * Create the default field title (label text) from the key name. + * (Converts 'camelCase' to 'Camel Case') + * + * @return {String} + */ + createTitle: function() { + var str = this.key; + + //Add spaces + str = str.replace(/([A-Z])/g, ' $1'); + + //Uppercase first character + str = str.replace(/^./, function(str) { return str.toUpperCase(); }); + + return str; + }, + + /** + * Returns the data to be passed to the template + * + * @return {Object} + */ + templateData: function() { + var schema = this.schema; + + return { + help: schema.help || '', + title: schema.title, + fieldAttrs: schema.fieldAttrs, + editorAttrs: schema.editorAttrs, + key: this.key, + editorId: this.editor.id + }; + }, + + /** + * Render the field and editor + * + * @return {Field} self + */ + render: function() { + var schema = this.schema, + editor = this.editor; + + //Render field + var $field = $($.trim(this.template(_.result(this, 'templateData')))); + + if (schema.fieldClass) $field.addClass(schema.fieldClass); + if (schema.fieldAttrs) $field.attr(schema.fieldAttrs); + + //Render editor + $field.find('[data-editor]').add($field).each(function(i, el) { + var $container = $(el), + selection = $container.attr('data-editor'); + + if (_.isUndefined(selection)) return; + + $container.append(editor.render().el); + }); + + this.setElement($field); + + return this; + }, + + /** + * Check the validity of the field + * + * @return {String} + */ + validate: function() { + var error = this.editor.validate(); + + if (error) { + this.setError(error.message); + } else { + this.clearError(); + } + + return error; + }, + + /** + * Set the field into an error state, adding the error class and setting the error message + * + * @param {String} msg Error message + */ + setError: function(msg) { + //Nested form editors (e.g. Object) set their errors internally + if (this.editor.hasNestedForm) return; + + //Add error CSS class + this.$el.addClass(this.errorClassName); + + //Set error message + this.$('[data-error]').html(msg); + }, + + /** + * Clear the error state and reset the help message + */ + clearError: function() { + //Remove error CSS class + this.$el.removeClass(this.errorClassName); + + //Clear error message + this.$('[data-error]').empty(); + }, + + /** + * Update the model with the new value from the editor + * + * @return {Mixed} + */ + commit: function() { + return this.editor.commit(); + }, + + /** + * Get the value from the editor + * + * @return {Mixed} + */ + getValue: function() { + return this.editor.getValue(); + }, + + /** + * Set/change the value of the editor + * + * @param {Mixed} value + */ + setValue: function(value) { + this.editor.setValue(value); + }, + + /** + * Give the editor focus + */ + focus: function() { + this.editor.focus(); + }, + + /** + * Remove focus from the editor + */ + blur: function() { + this.editor.blur(); + }, + + /** + * Remove the field and editor views + */ + remove: function() { + this.editor.remove(); + + Backbone.View.prototype.remove.call(this); + } + +}, { + //STATICS + + template: _.template('\ +
\ + \ +
\ + \ +
\ +
<%= help %>
\ +
\ +
\ + ', null, Form.templateSettings), + + /** + * CSS class name added to the field when there is a validation error + */ + errorClassName: 'error' + +}); + + +//================================================================================================== +//NESTEDFIELD +//================================================================================================== + +Form.NestedField = Form.Field.extend({ + + template: _.template($.trim('\ +
\ + \ + <% if (help) { %>\ +
<%= help %>
\ + <% } %>\ +
\ +
\ + '), null, Form.templateSettings) + +}); + +/** + * Base editor (interface). To be extended, not used directly + * + * @param {Object} options + * @param {String} [options.id] Editor ID + * @param {Model} [options.model] Use instead of value, and use commit() + * @param {String} [options.key] The model attribute key. Required when using 'model' + * @param {Mixed} [options.value] When not using a model. If neither provided, defaultValue will be used + * @param {Object} [options.schema] Field schema; may be required by some editors + * @param {Object} [options.validators] Validators; falls back to those stored on schema + * @param {Object} [options.form] The form + */ +Form.Editor = Form.editors.Base = Backbone.View.extend({ + + defaultValue: null, + + hasFocus: false, + + initialize: function(options) { + var options = options || {}; + + //Set initial value + if (options.model) { + if (!options.key) throw "Missing option: 'key'"; + + this.model = options.model; + + this.value = this.model.get(options.key); + } + else if (options.value) { + this.value = options.value; + } + + if (this.value === undefined) this.value = this.defaultValue; + + //Store important data + _.extend(this, _.pick(options, 'key', 'form')); + + var schema = this.schema = options.schema || {}; + + this.validators = options.validators || schema.validators; + + //Main attributes + this.$el.attr('id', this.id); + this.$el.attr('name', this.getName()); + if (schema.editorClass) this.$el.addClass(schema.editorClass); + if (schema.editorAttrs) this.$el.attr(schema.editorAttrs); + }, + + /** + * Get the value for the form input 'name' attribute + * + * @return {String} + * + * @api private + */ + getName: function() { + var key = this.key || ''; + + //Replace periods with underscores (e.g. for when using paths) + return key.replace(/\./g, '_'); + }, + + /** + * Get editor value + * Extend and override this method to reflect changes in the DOM + * + * @return {Mixed} + */ + getValue: function() { + return this.value; + }, + + /** + * Set editor value + * Extend and override this method to reflect changes in the DOM + * + * @param {Mixed} value + */ + setValue: function(value) { + this.value = value; + }, + + /** + * Give the editor focus + * Extend and override this method + */ + focus: function() { + throw 'Not implemented'; + }, + + /** + * Remove focus from the editor + * Extend and override this method + */ + blur: function() { + throw 'Not implemented'; + }, + + /** + * Update the model with the current value + * + * @param {Object} [options] Options to pass to model.set() + * @param {Boolean} [options.validate] Set to true to trigger built-in model validation + * + * @return {Mixed} error + */ + commit: function(options) { + var error = this.validate(); + if (error) return error; + + this.listenTo(this.model, 'invalid', function(model, e) { + error = e; + }); + this.model.set(this.key, this.getValue(), options); + + if (error) return error; + }, + + /** + * Check validity + * + * @return {Object|Undefined} + */ + validate: function() { + var $el = this.$el, + error = null, + value = this.getValue(), + formValues = this.form ? this.form.getValue() : {}, + validators = this.validators, + getValidator = this.getValidator; + + if (validators) { + //Run through validators until an error is found + _.every(validators, function(validator) { + error = getValidator(validator)(value, formValues); + + return error ? false : true; + }); + } + + return error; + }, + + /** + * Set this.hasFocus, or call parent trigger() + * + * @param {String} event + */ + trigger: function(event) { + if (event === 'focus') { + this.hasFocus = true; + } + else if (event === 'blur') { + this.hasFocus = false; + } + + return Backbone.View.prototype.trigger.apply(this, arguments); + }, + + /** + * Returns a validation function based on the type defined in the schema + * + * @param {RegExp|String|Function} validator + * @return {Function} + */ + getValidator: function(validator) { + var validators = Form.validators; + + //Convert regular expressions to validators + if (_.isRegExp(validator)) { + return validators.regexp({ regexp: validator }); + } + + //Use a built-in validator if given a string + if (_.isString(validator)) { + if (!validators[validator]) throw new Error('Validator "'+validator+'" not found'); + + return validators[validator](); + } + + //Functions can be used directly + if (_.isFunction(validator)) return validator; + + //Use a customised built-in validator if given an object + if (_.isObject(validator) && validator.type) { + var config = validator; + + return validators[config.type](config); + } + + //Unkown validator type + throw new Error('Invalid validator: ' + validator); + } +}); + +/** + * Text + * + * Text input with focus, blur and change events + */ +Form.editors.Text = Form.Editor.extend({ + + tagName: 'input', + + defaultValue: '', + + previousValue: '', + + events: { + 'keyup': 'determineChange', + 'keypress': function(event) { + var self = this; + setTimeout(function() { + self.determineChange(); + }, 0); + }, + 'select': function(event) { + this.trigger('select', this); + }, + 'focus': function(event) { + this.trigger('focus', this); + }, + 'blur': function(event) { + this.trigger('blur', this); + } + }, + + initialize: function(options) { + Form.editors.Base.prototype.initialize.call(this, options); + + var schema = this.schema; + + //Allow customising text type (email, phone etc.) for HTML5 browsers + var type = 'text'; + + if (schema && schema.editorAttrs && schema.editorAttrs.type) type = schema.editorAttrs.type; + if (schema && schema.dataType) type = schema.dataType; + + this.$el.attr('type', type); + }, + + /** + * Adds the editor to the DOM + */ + render: function() { + this.setValue(this.value); + + return this; + }, + + determineChange: function(event) { + var currentValue = this.$el.val(); + var changed = (currentValue !== this.previousValue); + + if (changed) { + this.previousValue = currentValue; + + this.trigger('change', this); + } + }, + + /** + * Returns the current editor value + * @return {String} + */ + getValue: function() { + return this.$el.val(); + }, + + /** + * Sets the value of the form element + * @param {String} + */ + setValue: function(value) { + this.$el.val(value); + }, + + focus: function() { + if (this.hasFocus) return; + + this.$el.focus(); + }, + + blur: function() { + if (!this.hasFocus) return; + + this.$el.blur(); + }, + + select: function() { + this.$el.select(); + } + +}); + +/** + * TextArea editor + */ +Form.editors.TextArea = Form.editors.Text.extend({ + + tagName: 'textarea' + +}); + +/** + * Password editor + */ +Form.editors.Password = Form.editors.Text.extend({ + + initialize: function(options) { + Form.editors.Text.prototype.initialize.call(this, options); + + this.$el.attr('type', 'password'); + } + +}); + +/** + * NUMBER + * + * Normal text input that only allows a number. Letters etc. are not entered. + */ +Form.editors.Number = Form.editors.Text.extend({ + + defaultValue: 0, + + events: _.extend({}, Form.editors.Text.prototype.events, { + 'keypress': 'onKeyPress' + }), + + initialize: function(options) { + Form.editors.Text.prototype.initialize.call(this, options); + + this.$el.attr('type', 'number'); + this.$el.attr('step', 'any'); + }, + + /** + * Check value is numeric + */ + onKeyPress: function(event) { + var self = this, + delayedDetermineChange = function() { + setTimeout(function() { + self.determineChange(); + }, 0); + }; + + //Allow backspace + if (event.charCode === 0) { + delayedDetermineChange(); + return; + } + + //Get the whole new value so that we can prevent things like double decimals points etc. + var newVal = this.$el.val() + String.fromCharCode(event.charCode); + + var numeric = /^[0-9]*\.?[0-9]*?$/.test(newVal); + + if (numeric) { + delayedDetermineChange(); + } + else { + event.preventDefault(); + } + }, + + getValue: function() { + var value = this.$el.val(); + + return value === "" ? null : parseFloat(value, 10); + }, + + setValue: function(value) { + value = (function() { + if (_.isNumber(value)) return value; + + if (_.isString(value) && value !== '') return parseFloat(value, 10); + + return null; + })(); + + if (_.isNaN(value)) value = null; + + Form.editors.Text.prototype.setValue.call(this, value); + } + +}); + +/** + * Hidden editor + */ +Form.editors.Hidden = Form.editors.Base.extend({ + + defaultValue: '', + + initialize: function(options) { + Form.editors.Text.prototype.initialize.call(this, options); + + this.$el.attr('type', 'hidden'); + }, + + focus: function() { + + }, + + blur: function() { + + } + +}); + +/** + * Checkbox editor + * + * Creates a single checkbox, i.e. boolean value + */ +Form.editors.Checkbox = Form.editors.Base.extend({ + + defaultValue: false, + + tagName: 'input', + + events: { + 'click': function(event) { + this.trigger('change', this); + }, + 'focus': function(event) { + this.trigger('focus', this); + }, + 'blur': function(event) { + this.trigger('blur', this); + } + }, + + initialize: function(options) { + Form.editors.Base.prototype.initialize.call(this, options); + + this.$el.attr('type', 'checkbox'); + }, + + /** + * Adds the editor to the DOM + */ + render: function() { + this.setValue(this.value); + + return this; + }, + + getValue: function() { + return this.$el.prop('checked'); + }, + + setValue: function(value) { + if (value) { + this.$el.prop('checked', true); + } + }, + + focus: function() { + if (this.hasFocus) return; + + this.$el.focus(); + }, + + blur: function() { + if (!this.hasFocus) return; + + this.$el.blur(); + } + +}); + +/** + * Select editor + * + * Renders a + * + * @param {Mixed} options + */ + setOptions: function(options) { + var self = this; + + //If a collection was passed, check if it needs fetching + if (options instanceof Backbone.Collection) { + var collection = options; + + //Don't do the fetch if it's already populated + if (collection.length > 0) { + this.renderOptions(options); + } else { + collection.fetch({ + success: function(collection) { + self.renderOptions(options); + } + }); + } + } + + //If a function was passed, run it to get the options + else if (_.isFunction(options)) { + options(function(result) { + self.renderOptions(result); + }, self); + } + + //Otherwise, ready to go straight to renderOptions + else { + this.renderOptions(options); + } + }, + + /** + * Adds the