/** * Backbone Forms v0.11.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 = (function() { return Backbone.View.extend({ hasFocus: false, /** * Creates a new form * * @param {Object} options * @param {Model} [options.model] Model the form relates to. Required if options.data is not set * @param {Object} [options.data] Date to populate the form. Required if options.model is not set * @param {String[]} [options.fields] Fields to include in the form, in order * @param {String[]|Object[]} [options.fieldsets] How to divide the fields up by section. E.g. [{ legend: 'Title', fields: ['field1', 'field2'] }] * @param {String} [options.idPrefix] Prefix for editor IDs. By default, the model's CID is used. * @param {String} [options.template] Form template key/name * @param {String} [options.fieldsetTemplate] Fieldset template key/name * @param {String} [options.fieldTemplate] Field template key/name * * @return {Form} */ initialize: function(options) { //Check templates have been loaded if (!Form.templates.form) throw new Error('Templates not loaded'); //Get the schema this.schema = (function() { if (options.schema) return options.schema; var model = options.model; if (!model) throw new Error('Could not find schema'); if (_.isFunction(model.schema)) return model.schema(); return model.schema; })(); //Option defaults options = _.extend({ template: 'form', fieldsetTemplate: 'fieldset', fieldTemplate: 'field' }, options); //Determine fieldsets if (!options.fieldsets) { var fields = options.fields || _.keys(this.schema); options.fieldsets = [{ fields: fields }]; } //Store main attributes this.options = options; this.model = options.model; this.data = options.data; this.fields = {}; }, /** * Renders the form and all fields */ render: function() { var self = this, options = this.options, template = Form.templates[options.template]; //Create el from template var $form = Form.helpers.parseHTML(template({ fieldsets: '' })); //Render fieldsets var $fieldsetContainer = $('.bbf-tmp', $form); _.each(options.fieldsets, function(fieldset) { $fieldsetContainer.append(self.renderFieldset(fieldset)); }); $fieldsetContainer.children().unwrap(); //Set the template contents as the main element; removes the wrapper element this.setElement($form); if (this.hasFocus) this.trigger('blur', this); return this; }, /** * Renders a fieldset and the fields within it * * Valid fieldset definitions: * ['field1', 'field2'] * { legend: 'Some Fieldset', fields: ['field1', 'field2'] } * * @param {Object|Array} fieldset A fieldset definition * * @return {jQuery} The fieldset DOM element */ renderFieldset: function(fieldset) { var self = this, template = Form.templates[this.options.fieldsetTemplate], schema = this.schema, getNested = Form.helpers.getNested; //Normalise to object if (_.isArray(fieldset)) { fieldset = { fields: fieldset }; } //Concatenating HTML as strings won't work so we need to insert field elements into a placeholder var $fieldset = Form.helpers.parseHTML(template(_.extend({}, fieldset, { legend: '', fields: '' }))); //Set legend if (fieldset.legend) { $fieldset.find('.bbf-tmp-legend').replaceWith(fieldset.legend); } //or remove the containing tag if there isn't a legend else { $fieldset.find('.bbf-tmp-legend').parent().remove(); } var $fieldsContainer = $('.bbf-tmp-fields', $fieldset); //Render fields _.each(fieldset.fields, function(key) { //Get the field schema var itemSchema = (function() { //Return a normal key or path key if (schema[key]) return schema[key]; //Return a nested schema, i.e. Object var path = key.replace(/\./g, '.subSchema.'); return getNested(schema, path); })(); if (!itemSchema) throw "Field '"+key+"' not found in schema"; //Create the field var field = self.fields[key] = self.createField(key, itemSchema); //Render the fields with editors, apart from Hidden fields var fieldEl = field.render().el; field.editor.on('all', function(event) { // args = ["change", editor] var args = _.toArray(arguments); args[0] = key + ':' + event; args.splice(1, 0, this); // args = ["key:change", this=form, editor] this.trigger.apply(this, args); }, self); field.editor.on('change', function() { this.trigger('change', self); }, self); field.editor.on('focus', function() { if (this.hasFocus) return; this.trigger('focus', this); }, self); field.editor.on('blur', function() { if (!this.hasFocus) return; var self = this; setTimeout(function() { if (_.find(self.fields, function(field) { return field.editor.hasFocus; })) return; self.trigger('blur', self); }, 0); }, self); if (itemSchema.type !== 'Hidden') { $fieldsContainer.append(fieldEl); } }); $fieldsContainer = $fieldsContainer.children().unwrap(); return $fieldset; }, /** * Renders a field and returns it * * @param {String} key The key for the field in the form schema * @param {Object} schema Field schema * * @return {Field} The field view */ createField: function(key, schema) { schema.template = schema.template || this.options.fieldTemplate; var options = { form: this, key: key, schema: schema, idPrefix: this.options.idPrefix, template: this.options.fieldTemplate }; if (this.model) { options.model = this.model; } else if (this.data) { options.value = this.data[key]; } else { options.value = null; } return new Form.Field(options); }, /** * 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 (self.fields[key] && !errors[key]) { self.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]); } } }, focus: function() { if (this.hasFocus) return; var fieldset = this.options.fieldsets[0]; if (fieldset) { var field; if (_.isArray(fieldset)) { field = fieldset[0]; } else { field = fieldset.fields[0]; } if (field) { this.fields[field].editor.focus(); } } }, blur: function() { if (!this.hasFocus) return; var focusedField = _.find(this.fields, function(field) { return field.editor.hasFocus; }); if (focusedField) focusedField.editor.blur(); }, /** * Override default remove function in order to remove embedded views */ remove: function() { var fields = this.fields; for (var key in fields) { fields[key].remove(); } Backbone.View.prototype.remove.call(this); }, trigger: function(event) { if (event === 'focus') { this.hasFocus = true; } else if (event === 'blur') { this.hasFocus = false; } return Backbone.View.prototype.trigger.apply(this, arguments); } }); })(); //================================================================================================== //HELPERS //================================================================================================== Form.helpers = (function() { var helpers = {}; /** * Gets a nested attribute using a path e.g. 'user.name' * * @param {Object} obj Object to fetch attribute from * @param {String} path Attribute path e.g. 'user.name' * @return {Mixed} * @api private */ helpers.getNested = function(obj, path) { var fields = path.split("."); var result = obj; for (var i = 0, n = fields.length; i < n; i++) { result = result[fields[i]]; } return result; }; /** * This function is used to transform the key from a schema into the title used in a label. * (If a specific title is provided it will be used instead). * * By default this converts a camelCase string into words, i.e. Camel Case * If you have a different naming convention for schema keys, replace this function. * * @param {String} Key * @return {String} Title */ helpers.keyToTitle = function(str) { //Add spaces str = str.replace(/([A-Z])/g, ' $1'); //Uppercase first character str = str.replace(/^./, function(str) { return str.toUpperCase(); }); return str; }; /** * Helper to compile a template with the {{mustache}} style tags. Template settings are reset * to user's settings when done to avoid conflicts. * @param {String} Template string * @return {Template} Compiled template */ helpers.compileTemplate = function(str) { //Store user's template options var _interpolateBackup = _.templateSettings.interpolate; //Set custom template settings _.templateSettings.interpolate = /\{\{(.+?)\}\}/g; var template = _.template(str); //Reset to users' template settings _.templateSettings.interpolate = _interpolateBackup; return template; }; /** * Helper to create a template with the {{mustache}} style tags. * If context is passed in, the template will be evaluated. * @param {String} Template string * @param {Object} Optional; values to replace in template * @return {Template|String} Compiled template or the evaluated string */ helpers.createTemplate = function(str, context) { var template = helpers.compileTemplate($.trim(str)); if (!context) { return template; } else { return template(context); } }; /** * Sets the template compiler to the given function * @param {Function} Template compiler function */ helpers.setTemplateCompiler = function(compiler) { helpers.compileTemplate = compiler; }; /** * Sets the templates to be used. * * If the templates passed in are strings, they will be compiled, expecting Mustache style tags, * i.e.
{{varName}}
* * You can also pass in previously compiled Underscore templates, in which case you can use any style * tags. * * @param {Object} templates * @param {Object} classNames */ helpers.setTemplates = function(templates, classNames) { var createTemplate = helpers.createTemplate; Form.templates = Form.templates || {}; Form.classNames = Form.classNames || {}; //Set templates, compiling them if necessary _.each(templates, function(template, key, index) { if (_.isString(template)) template = createTemplate(template); Form.templates[key] = template; }); //Set class names _.extend(Form.classNames, classNames); }; /** * Return the editor constructor for a given schema 'type'. * Accepts strings for the default editors, or the reference to the constructor function * for custom editors * * @param {String|Function} The schema type e.g. 'Text', 'Select', or the editor constructor e.g. editors.Date * @param {Object} Options to pass to editor, including required 'key', 'schema' * @return {Mixed} An instance of the mapped editor */ helpers.createEditor = function(schemaType, options) { var constructorFn; if (_.isString(schemaType)) { constructorFn = Form.editors[schemaType]; } else { constructorFn = schemaType; } return new constructorFn(options); }; /** * Returns a validation function based on the type defined in the schema * * @param {RegExp|String|Function} validator * @return {Function} */ helpers.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); }; /** * Given an HTML string, return a jQuery-wrapped array of DOM nodes. * * @param {String} html * @return {Object} */ helpers.parseHTML = function(html) { if ($.parseHTML !== undefined) { return $($.parseHTML(html)); } return $(html); }; return helpers; })(); //================================================================================================== //VALIDATORS //================================================================================================== Form.validators = (function() { var validators = {}; validators.errMessages = { required: 'Required', regexp: 'Invalid', email: 'Invalid email address', url: 'Invalid URL', match: 'Must match field "{{field}}"' }; 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: Form.helpers.createTemplate(options.message, options) }; 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: Form.helpers.createTemplate(options.message, options) }; //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: Form.helpers.createTemplate(options.message, options) }; //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; })(); //================================================================================================== //FIELD //================================================================================================== Form.Field = (function() { var helpers = Form.helpers, templates = Form.templates; return Backbone.View.extend({ /** * @param {Object} Options * Required: * key {String} : The model attribute key * Optional: * schema {Object} : Schema for the field * value {Mixed} : Pass value when not using a model. Use getValue() to get out value * model {Backbone.Model} : Use instead of value, and use commit(). * idPrefix {String} : Prefix to add to the editor DOM element's ID */ /** * Creates a new field * * @param {Object} options * @param {Object} [options.schema] Field schema. Defaults to { type: 'Text' } * @param {Model} [options.model] Model the field relates to. Required if options.data is not set. * @param {String} [options.key] Model key/attribute the field relates to. * @param {Mixed} [options.value] Field value. Required if options.model is not set. * @param {String} [options.idPrefix] Prefix for the editor ID. By default, the model's CID is used. * * @return {Field} */ initialize: function(options) { options = options || {}; this.form = options.form; this.key = options.key; this.value = options.value; this.model = options.model; //Turn schema shorthand notation (e.g. 'Text') into schema object if (_.isString(options.schema)) options.schema = { type: options.schema }; //Set schema defaults this.schema = _.extend({ type: 'Text', title: helpers.keyToTitle(this.key), template: 'field' }, options.schema); }, /** * Provides the context for rendering the field * Override this to extend the default context * * @param {Object} schema * @param {View} editor * * @return {Object} Locals passed to the template */ renderingContext: function(schema, editor) { return { key: this.key, title: schema.title, id: editor.id, type: schema.type, editor: '', help: '', error: '' }; }, /** * Renders the field */ render: function() { var schema = this.schema, templates = Form.templates; //Standard options that will go to all editors var options = { form: this.form, key: this.key, schema: schema, idPrefix: this.options.idPrefix, id: this.getId() }; //Decide on data delivery type to pass to editors if (this.model) { options.model = this.model; } else { options.value = this.value; } //Decide on the editor to use var editor = this.editor = helpers.createEditor(schema.type, options); //Create the element var $field = Form.helpers.parseHTML(templates[schema.template](this.renderingContext(schema, editor))); //Remove