UNPKG

backform

Version:

Backform takes a Backbone model and transforms it into an HTML form. Any changes to the form are relfected back to the model and vice versa.

659 lines (600 loc) 24.4 kB
/* Backform http://github.com/amiliaapp/backform Copyright (c) 2014 Amilia Inc. Written by Martin Drapeau Licensed under the MIT @license */ (function(root, factory) { // Set up Backform appropriately for the environment. Start with AMD. if (typeof define === 'function' && define.amd) { define(['underscore', 'jquery', 'backbone'], function(_, $, Backbone) { // Export global even in AMD case in case this script is loaded with // others that may still expect a global Backform. return factory(root, _, $, Backbone); }); // Next for Node.js or CommonJS. jQuery may not be needed as a module. } else if (typeof exports !== 'undefined') { var _ = require('underscore'); factory(root, _, (root.jQuery || root.$ || root.Zepto || root.ender), root.Backbone); // Finally, as a browser global. } else { factory(root, root._, (root.jQuery || root.Zepto || root.ender || root.$), root.Backbone); } } (this, function(root, _, $, Backbone) { // Backform namespace and global options Backform = root.Backform = { // HTML markup global class names. More can be added by individual controls // using _.extend. Look at RadioControl as an example. formClassName: "backform form-horizontal", groupClassName: "form-group", controlLabelClassName: "control-label col-sm-4", controlsClassName: "col-sm-8", controlClassName: "form-control", helpClassName: "help-block", errorClassName: "has-error", helpMessageClassName: "help-block", hiddenClassName: "hidden", requiredInputClassName: undefined, // Bootstrap 2.3 adapter bootstrap2: function() { _.extend(Backform, { groupClassName: "control-group", controlLabelClassName: "control-label", controlsClassName: "controls", controlClassName: "input-xlarge", helpClassName: "text-error", errorClassName: "error", helpMessageClassName: "help-message small" }); _.each(Backform, function(value, name) { if (_.isFunction(Backform[name]) && _.isFunction(Backform[name].prototype["bootstrap2"])) Backform[name].prototype["bootstrap2"](); }); }, // https://github.com/wyuenho/backgrid/blob/master/lib/backgrid.js resolveNameToClass: function(name, suffix) { if (_.isString(name)) { var key = _.map(name.split('-'), function(e) { return e.slice(0, 1).toUpperCase() + e.slice(1); }).join('') + suffix; var klass = Backform[key]; if (_.isUndefined(klass)) { throw new ReferenceError("Class '" + key + "' not found"); } return klass; } return name; } }; // Backform Form view // A collection of field models. var Form = Backform.Form = Backbone.View.extend({ fields: undefined, errorModel: undefined, tagName: "form", className: function() { return Backform.formClassName; }, initialize: function(options) { if (!(options.fields instanceof Backbone.Collection)) options.fields = new Fields(options.fields || this.fields); this.fields = options.fields; this.model.errorModel = options.errorModel || this.model.errorModel || new Backbone.Model(); this.controls = []; }, cleanup: function() { _.each(this.controls, function(c) { c.remove(); }); this.controls.length = 0; }, remove: function() { /* First do the clean up */ this.cleanup(); Backbone.View.prototype.remove.apply(this, arguments); }, render: function() { this.cleanup(); this.$el.empty(); var form = this, $form = this.$el, model = this.model, controls = this.controls; this.fields.each(function(field) { var control = new (field.get("control"))({ field: field, model: model }); $form.append(control.render().$el); controls.push(control); }); return this; } }); // Converting data to/from Model/DOM. // Stolen directly from Backgrid's CellFormatter. // Source: http://backgridjs.com/ref/formatter.html /** Just a convenient class for interested parties to subclass. The default Cell classes don't require the formatter to be a subclass of Formatter as long as the fromRaw(rawData) and toRaw(formattedData) methods are defined. @abstract @class Backform.ControlFormatter @constructor */ var ControlFormatter = Backform.ControlFormatter = function() {}; _.extend(ControlFormatter.prototype, { /** Takes a raw value from a model and returns an optionally formatted string for display. The default implementation simply returns the supplied value as is without any type conversion. @member Backform.ControlFormatter @param {*} rawData @param {Backbone.Model} model Used for more complicated formatting @return {*} */ fromRaw: function (rawData, model) { return rawData; }, /** Takes a formatted string, usually from user input, and returns a appropriately typed value for persistence in the model. If the user input is invalid or unable to be converted to a raw value suitable for persistence in the model, toRaw must return `undefined`. @member Backform.ControlFormatter @param {string} formattedData @param {Backbone.Model} model Used for more complicated formatting @return {*|undefined} */ toRaw: function (formattedData, model) { return formattedData; } }); // Store value in DOM as stringified JSON. var JSONFormatter = Backform.JSONFormatter = function() {}; _.extend(JSONFormatter.prototype, { fromRaw: function(rawData, model) { return JSON.stringify(rawData); }, toRaw: function(formattedData, model) { return JSON.parse(formattedData); } }); // Field model and collection // // A field maps a model attriute to a control for rendering and capturing // user input. var Field = Backform.Field = Backbone.Model.extend({ defaults: { // Name of the model attribute // - It accepts "." nested path (e.g. x.y.z) name: "", // Placeholder for the input placeholder: "", // Disable the input control // (Optional - true/false/function returning boolean) // (Default Value: false) disabled: false, // Visible // (Optional - true/false/function returning boolean) // (Default Value: true) visible: true, // Value Required (validation) // (Optional - true/false/function returning boolean) // (Default Value: true) required: false, // Default value for the field // (Optional) value: undefined, // Control or class name for the control representing this field control: undefined, formatter: undefined }, initialize: function(attributes, options) { var control = Backform.resolveNameToClass(this.get("control"), "Control"); this.set({control: control}, {silent: true}); } }); var Fields = Backform.Fields = Backbone.Collection.extend({ model: Field }); // Base Control class var Control = Backform.Control = Backbone.View.extend({ // Additional field defaults defaults: {}, className: function() { return Backform.groupClassName; }, events: { 'keydown :input': 'processTab' }, template: _.template([ '<label class="<%=Backform.controlLabelClassName%>"><%=label%></label>', '<div class="<%=Backform.controlsClassName%>">', ' <span class="<%=Backform.controlClassName%> uneditable-input">', ' <%=value%>', ' </span>', '</div>' ].join("\n")), initialize: function(options) { // Back-reference to the field this.field = options.field; var formatter = Backform.resolveNameToClass(this.field.get("formatter") || this.formatter, "Formatter"); if (!_.isFunction(formatter.fromRaw) && !_.isFunction(formatter.toRaw)) { formatter = new formatter(); } this.formatter = formatter; var attrArr = this.field.get('name').split('.'); var name = attrArr.shift(); // Listen to the field in the model for any change this.listenTo(this.model, "change:" + name, this.render); // Listen for the field in the error model for any change if (this.model.errorModel instanceof Backbone.Model) this.listenTo(this.model.errorModel, "change:" + name, this.updateInvalid); }, formatter: ControlFormatter, getValueFromDOM: function() { return this.formatter.toRaw(this.$el.find(".uneditable-input").text(), this.model); }, onChange: function(e) { var model = this.model, $el = $(e.target), attrArr = this.field.get("name").split('.'), name = attrArr.shift(), path = attrArr.join('.'), value = this.getValueFromDOM(), changes = {}; if (this.model.errorModel instanceof Backbone.Model) { if (_.isEmpty(path)) { this.model.errorModel.unset(name); } else { var nestedError = this.model.errorModel.get(name); if (nestedError) { this.keyPathSetter(nestedError, path, null); this.model.errorModel.set(name, nestedError); } } } changes[name] = _.isEmpty(path) ? value : _.clone(model.get(name)) || {}; if (!_.isEmpty(path)) this.keyPathSetter(changes[name], path, value); this.stopListening(this.model, "change:" + name, this.render); model.set(changes); this.listenTo(this.model, "change:" + name, this.render); }, render: function() { var field = _.defaults(this.field.toJSON(), this.defaults), attributes = this.model.toJSON(), attrArr = field.name.split('.'), name = attrArr.shift(), path = attrArr.join('.'), rawValue = this.keyPathAccessor(attributes[name], path), data = _.extend(field, { rawValue: rawValue, value: this.formatter.fromRaw(rawValue, this.model), attributes: attributes, formatter: this.formatter }), evalF = function(f, m) { return (_.isFunction(f) ? !!f(m) : !!f); }; // Evaluate the disabled, visible, and required option _.extend(data, { disabled: evalF(data.disabled, this.model), visible: evalF(data.visible, this.model), required: evalF(data.required, this.model) }); // Clean up first this.$el.removeClass(Backform.hiddenClassName); if (!data.visible) this.$el.addClass(Backform.hiddenClassName); if(Backform.requiredInputClassName) { this.$el.removeClass(Backform.requiredInputClassName); } if (data.required) { this.$el.addClass(Backform.requiredInputClassName); } this.$el.html(this.template(data)).addClass(field.name); this.updateInvalid(); return this; }, clearInvalid: function() { this.$el.removeClass(Backform.errorClassName) .find("." + Backform.helpClassName + ".error").remove(); return this; }, updateInvalid: function() { var self = this; var errorModel = this.model.errorModel; if (!(errorModel instanceof Backbone.Model)) return this; this.clearInvalid(); this.$el.find(':input').not('button').each(function(ix, el) { var attrArr = $(el).attr('name').split('.'), name = attrArr.shift(), path = attrArr.join('.'), error = self.keyPathAccessor(errorModel.toJSON(), $(el).attr('name')); if (_.isEmpty(error)) return; if (_.isEmpty(error)) return; self.$el.addClass(Backform.errorClassName); self.$el.find("." + Backform.controlsClassName) .append('<span class="' + Backform.helpClassName + ' error">' + (_.isArray(error) ? error.join(", ") : error) + '</span>'); }); return this; }, keyPathAccessor: function(obj, path) { var res = obj; path = path.split('.'); for (var i = 0; i < path.length; i++) { if (_.isNull(res)) return null; if (_.isEmpty(path[i])) continue; if (!_.isUndefined(res[path[i]])) res = res[path[i]]; } return _.isObject(res) && !_.isArray(res) ? null : res; }, keyPathSetter: function(obj, path, value) { path = path.split('.'); while (path.length > 1) { if (!obj[path[0]]) obj[path[0]] = {}; obj = obj[path.shift()]; } return obj[path.shift()] = value; }, processTab: function(e) { if (e.which == 9) { var $target = $(e.currentTarget); setTimeout(function() { var $nextFocus; if (e.shiftKey) { $nextFocus = !!$target.prevAll(':input:visible').length ? $target.prevAll(':input:visible').first() : $target.closest('.control-group:visible').prev('.control-group:visible').find(':input:visible'); } else { $nextFocus = !!$target.nextAll(':input:visible').length ? $target.nextAll(':input:visible').first() : $target.closest('.control-group:visible').next('.control-group:visible').find(':input:visible'); } if ($nextFocus.length) $nextFocus.first().focus(); }, 0); } } }); // Built-in controls var UneditableInputControl = Backform.UneditableInputControl = Control; var HelpControl = Backform.HelpControl = Control.extend({ template: _.template([ '<label class="<%=Backform.controlLabelClassName%>">&nbsp;</label>', '<div class="<%=Backform.controlsClassName%>">', ' <span class="<%=Backform.helpMessageClassName%> help-block"><%=label%></span>', '</div>' ].join("\n")) }); var SpacerControl = Backform.SpacerControl = Control.extend({ template: _.template([ '<label class="<%=Backform.controlLabelClassName%>">&nbsp;</label>', '<div class="<%=Backform.controlsClassName%>"></div>' ].join("\n")) }); var TextareaControl = Backform.TextareaControl = Control.extend({ defaults: { label: "", maxlength: 4000, extraClasses: [], helpMessage: null }, template: _.template([ '<label class="<%=Backform.controlLabelClassName%>"><%=label%></label>', '<div class="<%=Backform.controlsClassName%>">', ' <textarea class="<%=Backform.controlClassName%> <%=extraClasses.join(\' \')%>" name="<%=name%>" maxlength="<%=maxlength%>" placeholder="<%-placeholder%>" <%=disabled ? "disabled" : ""%> <%=required ? "required" : ""%>><%-value%></textarea>', ' <% if (helpMessage && helpMessage.length) { %>', ' <span class="<%=Backform.helpMessageClassName%>"><%=helpMessage%></span>', ' <% } %>', '</div>' ].join("\n")), events: _.extend({}, Control.prototype.events, { "change textarea": "onChange", "focus textarea": "clearInvalid" }), getValueFromDOM: function() { return this.formatter.toRaw(this.$el.find("textarea").val(), this.model); } }); var SelectControl = Backform.SelectControl = Control.extend({ defaults: { label: "", options: [], // List of options as [{label:<label>, value:<value>}, ...] extraClasses: [] }, template: _.template([ '<label class="<%=Backform.controlLabelClassName%>"><%=label%></label>', '<div class="<%=Backform.controlsClassName%>">', ' <select class="<%=Backform.controlClassName%> <%=extraClasses.join(\' \')%>" name="<%=name%>" value="<%-value%>" <%=disabled ? "disabled" : ""%> <%=required ? "required" : ""%> >', ' <% for (var i=0; i < options.length; i++) { %>', ' <% var option = options[i]; %>', ' <option value="<%-formatter.fromRaw(option.value)%>" <%=option.value === rawValue ? "selected=\'selected\'" : ""%> <%=option.disabled ? "disabled=\'disabled\'" : ""%>><%-option.label%></option>', ' <% } %>', ' </select>', '</div>' ].join("\n")), events: _.extend({}, Control.prototype.events, { "change select": "onChange", "focus select": "clearInvalid" }), formatter: JSONFormatter, getValueFromDOM: function() { return this.formatter.toRaw(this.$el.find("select").val(), this.model); } }); // Note: Value here is null or an array. Since jQuery val() returns either. var MultiSelectControl = Backform.MultiSelectControl = SelectControl.extend({ defaults: { label: "", options: [], // List of options as [{label:<label>, value:<value>}, ...] extraClasses: [], height: '78px' }, template: _.template([ '<label class="<%=Backform.controlLabelClassName%>"><%=label%></label>', '<div class="<%=Backform.controlsClassName%>">', ' <select multiple="multiple" class="<%=Backform.controlClassName%> <%=extraClasses.join(\' \')%>" name="<%=name%>" value="<%-JSON.stringify(value)%>" <%=disabled ? "disabled" : ""%> <%=required ? "required" : ""%> style="height:<%=height%>">', ' <% for (var i=0; i < options.length; i++) { %>', ' <% var option = options[i]; %>', ' <option value="<%-option.value%>" <%=value != null && _.indexOf(value, option.value) != -1 ? "selected=\'selected\'" : ""%> <%=option.disabled ? "disabled=\'disabled\'" : ""%>><%-option.label%></option>', ' <% } %>', ' </select>', '</div>' ].join("\n")), events: _.extend({}, Control.prototype.events, { "change select": "onChange", "dblclick select": "onDoubleClick", "focus select": "clearInvalid" }), formatter: { fromRaw: function(rawData, model) { return rawData; }, toRaw: function(formattedData, model) { return typeof formattedData == "object" ? formattedData : JSON.parse(formattedData); } }, onDoubleClick: function(e) { this.model.trigger('doubleclick', e); } }); var InputControl = Backform.InputControl = Control.extend({ defaults: { type: "text", label: "", maxlength: 255, extraClasses: [], helpMessage: null }, template: _.template([ '<label class="<%=Backform.controlLabelClassName%>"><%=label%></label>', '<div class="<%=Backform.controlsClassName%>">', ' <input type="<%=type%>" class="<%=Backform.controlClassName%> <%=extraClasses.join(\' \')%>" name="<%=name%>" maxlength="<%=maxlength%>" value="<%-value%>" placeholder="<%-placeholder%>" <%=disabled ? "disabled" : ""%> <%=required ? "required" : ""%> />', ' <% if (helpMessage && helpMessage.length) { %>', ' <span class="<%=Backform.helpMessageClassName%>"><%=helpMessage%></span>', ' <% } %>', '</div>' ].join("\n")), events: _.extend({}, Control.prototype.events, { "change input": "onChange", "focus input": "clearInvalid" }), getValueFromDOM: function() { return this.formatter.toRaw(this.$el.find("input").val(), this.model); } }); var BooleanControl = Backform.BooleanControl = InputControl.extend({ defaults: { type: "checkbox", label: "", controlLabel: '&nbsp;', extraClasses: [] }, template: _.template([ '<label class="<%=Backform.controlLabelClassName%>"><%=controlLabel%></label>', '<div class="<%=Backform.controlsClassName%>">', ' <div class="checkbox">', ' <label>', ' <input type="<%=type%>" class="<%=extraClasses.join(\' \')%>" name="<%=name%>" <%=value ? "checked=\'checked\'" : ""%> <%=disabled ? "disabled" : ""%> <%=required ? "required" : ""%> /> <%=label%>', ' </label>', ' </div>', '</div>' ].join("\n")), getValueFromDOM: function() { return this.formatter.toRaw(this.$el.find("input").is(":checked"), this.model); } }); var CheckboxControl = Backform.CheckboxControl = BooleanControl; var RadioControl = Backform.RadioControl = InputControl.extend({ defaults: { type: "radio", label: "", options: [], extraClasses: [], helpMessage: null }, template: _.template([ '<label class="<%=Backform.controlLabelClassName%>"><%=label%></label>', '<div class="<%=Backform.controlsClassName%> <%=Backform.radioControlsClassName%>">', ' <% for (var i=0; i < options.length; i++) { %>', ' <% var option = options[i]; %>', ' <label class="<%=Backform.radioLabelClassName%>">', ' <input type="<%=type%>" class="<%=extraClasses.join(\' \')%>" name="<%=name%>" value="<%-formatter.fromRaw(option.value)%>" <%=rawValue == option.value ? "checked=\'checked\'" : ""%> <%=disabled ? "disabled" : ""%> <%=required ? "required" : ""%> /> <%-option.label%>', ' </label>', ' <% } %>', ' <% if (helpMessage && helpMessage.length) { %>', ' <span class="<%=Backform.helpMessageClassName%>"><%=helpMessage%></span>', ' <% } %>', '</div>' ].join("\n")), formatter: JSONFormatter, getValueFromDOM: function() { return this.formatter.toRaw(this.$el.find("input:checked").val(), this.model); }, bootstrap2: function() { Backform.radioControlsClassName = "controls"; Backform.radioLabelClassName = "radio inline"; } }); _.extend(Backform, { radioControlsClassName: "checkbox", radioLabelClassName: "checkbox-inline" }); // Requires the Bootstrap Datepicker to work. var DatepickerControl = Backform.DatepickerControl = InputControl.extend({ defaults: { type: "text", label: "", options: {}, extraClasses: [], maxlength: 255, helpMessage: null }, events: _.extend({}, Control.prototype.events, { "blur input": "onChange", "change input": "onChange", "changeDate input": "onChange", "focus input": "clearInvalid" }), render: function() { InputControl.prototype.render.apply(this, arguments); this.$el.find("input").datepicker(this.field.get("options")); return this; } }); var ButtonControl = Backform.ButtonControl = Control.extend({ defaults: { type: "submit", label: "Submit", status: undefined, // error or success message: undefined, extraClasses: [] }, template: _.template([ '<label class="<%=Backform.controlLabelClassName%>">&nbsp;</label>', '<div class="<%=Backform.controlsClassName%>">', ' <button type="<%=type%>" name="<%=name%>" class="btn <%=extraClasses.join(\' \')%>" <%=disabled ? "disabled" : ""%> ><%=label%></button>', ' <% var cls = ""; if (status == "error") cls = Backform.buttonStatusErrorClassName; if (status == "success") cls = Backform.buttonStatusSuccessClassname; %>', ' <span class="status <%=cls%>"><%=message%></span>', '</div>' ].join("\n")), initialize: function() { Control.prototype.initialize.apply(this, arguments); this.listenTo(this.field, "change:status", this.render); this.listenTo(this.field, "change:message", this.render); }, bootstrap2: function() { Backform.buttonStatusErrorClassName = "text-error"; Backform.buttonStatusSuccessClassname = "text-success"; } }); _.extend(Backform, { buttonStatusErrorClassName: "text-danger", buttonStatusSuccessClassname: "text-success" }); return Backform; }));