Last active
August 29, 2015 13:59
-
-
Save marfalkov/10478524 to your computer and use it in GitHub Desktop.
thorax integration test : http://jsfiddle.net/marfalkov/2gmjq/
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Backbone Forms v0.14.0 | |
* | |
* Copyright (c) 2014 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._ || require('underscore'), | |
Backbone = root.Backbone || require('backbone'); | |
} | |
//Browser | |
else { | |
var _ = root._, | |
Backbone = root.Backbone; | |
} | |
//SOURCE | |
//================================================================================================== | |
//FORM | |
//================================================================================================== | |
var Form = Thorax.View.extend({ | |
events: { | |
'submit': function(event) { | |
this.trigger('submit', event); | |
} | |
}, | |
/** | |
* 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 _.result(model, 'schema'); | |
//Then built-in schema | |
if (self.schema) return _.result(self, 'schema'); | |
//Fallback to empty schema | |
return {}; | |
})(); | |
//Store important data | |
_.extend(this, _.pick(options, 'model', 'data', 'idPrefix', 'templateData')); | |
//Override defaults | |
var constructor = this.constructor; | |
this.template = options.template || this.template || constructor.template; | |
this.Fieldset = options.Fieldset || this.Fieldset || constructor.Fieldset; | |
this.Field = options.Field || this.Field || constructor.Field; | |
this.NestedField = options.NestedField || this.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 || _.result(this, '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, Array.prototype.slice.call(arguments, 2)); | |
//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, | |
$ = Backbone.$; | |
//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); | |
//Set class | |
$form.addClass(this.className); | |
return this; | |
}, | |
/** | |
* Validate the data | |
* | |
* @return {Object} Validation errors | |
*/ | |
validate: function(options) { | |
var self = this, | |
fields = this.fields, | |
model = this.model, | |
errors = {}; | |
options = options || {}; | |
//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 (!options.skipModelValidate && 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 | |
options = options || {}; | |
var validateOptions = { | |
skipModelValidate: !options.validate | |
}; | |
var errors = this.validate(validateOptions); | |
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 new Error('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 Thorax.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(); | |
}); | |
return Thorax.View.prototype.remove.apply(this, arguments); | |
} | |
}, { | |
//STATICS | |
template: _.template('\ | |
<form data-fieldsets></form>\ | |
', 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', | |
number: 'Must be a number', | |
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', | |
match: true, | |
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; | |
//Create RegExp from string if it's valid | |
if ('string' === typeof options.regexp) options.regexp = new RegExp(options.regexp, options.flags); | |
if ((options.match) ? !options.regexp.test(value) : options.regexp.test(value)) return err; | |
}; | |
}; | |
validators.number = function(options) { | |
options = _.extend({ | |
type: 'number', | |
message: this.errMessages.number, | |
regexp: /^[0-9]*\.?[0-9]*?$/ | |
}, options); | |
return validators.regexp(options); | |
}; | |
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 = Thorax.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 || schema.template || this.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, | |
$ = Backbone.$; | |
//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(); | |
}); | |
Thorax.View.prototype.remove.call(this); | |
} | |
}, { | |
//STATICS | |
template: _.template('\ | |
<fieldset data-fields>\ | |
<% if (legend) { %>\ | |
<legend><%= legend %></legend>\ | |
<% } %>\ | |
</fieldset>\ | |
', null, Form.templateSettings) | |
}); | |
//================================================================================================== | |
//FIELD | |
//================================================================================================== | |
Form.Field = Thorax.View.extend({ | |
/** | |
* Constructor | |
* | |
* @param {Object} options.key | |
* @param {Object} options.form | |
* @param {Object} [options.schema] | |
* @param {Function} [options.schema.template] | |
* @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')); | |
//Create the full field schema, merging defaults etc. | |
var schema = this.schema = this.createSchema(options.schema); | |
//Override defaults | |
this.template = options.template || schema.template || this.template || this.constructor.template; | |
this.errorClassName = options.errorClassName || this.errorClassName || this.constructor.errorClassName; | |
//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, | |
$ = Backbone.$; | |
//Only render the editor if Hidden | |
if (schema.type == Form.editors.Hidden) { | |
return this.setElement(editor.render().el); | |
} | |
//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(); | |
Thorax.View.prototype.remove.call(this); | |
} | |
}, { | |
//STATICS | |
template: _.template('\ | |
<div>\ | |
<label for="<%= editorId %>"><%= title %></label>\ | |
<div>\ | |
<span data-editor></span>\ | |
<div data-error></div>\ | |
<div><%= help %></div>\ | |
</div>\ | |
</div>\ | |
', 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('\ | |
<div>\ | |
<span data-editor></span>\ | |
<% if (help) { %>\ | |
<div><%= help %></div>\ | |
<% } %>\ | |
<div data-error></div>\ | |
</div>\ | |
', 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 = Thorax.View.extend({ | |
defaultValue: null, | |
hasFocus: false, | |
initialize: function(options) { | |
var options = options || {}; | |
//Set initial value | |
if (options.model) { | |
if (!options.key) throw new Error("Missing option: 'key'"); | |
this.model = options.model; | |
this.value = this.model.get(options.key); | |
} | |
else if (options.value !== undefined) { | |
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 new Error('Not implemented'); | |
}, | |
/** | |
* Remove focus from the editor | |
* Extend and override this method | |
*/ | |
blur: function() { | |
throw new Error('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 Thorax.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', | |
/** | |
* Override Text constructor so type property isn't set (issue #261) | |
*/ | |
initialize: function(options) { | |
Form.editors.Base.prototype.initialize.call(this, options); | |
} | |
}); | |
/** | |
* 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', | |
'change': 'onKeyPress' | |
}), | |
initialize: function(options) { | |
Form.editors.Text.prototype.initialize.call(this, options); | |
var schema = this.schema; | |
this.$el.attr('type', 'number'); | |
if (!schema || !schema.editorAttrs || !schema.editorAttrs.step) { | |
// provide a default for `step` attr, | |
// but don't overwrite if already specified | |
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() | |
if( event.charCode != undefined ) { | |
newVal = newVal + 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.Text.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); | |
}else{ | |
this.$el.prop('checked', false); | |
} | |
}, | |
focus: function() { | |
if (this.hasFocus) return; | |
this.$el.focus(); | |
}, | |
blur: function() { | |
if (!this.hasFocus) return; | |
this.$el.blur(); | |
} | |
}); | |
/** | |
* Select editor | |
* | |
* Renders a <select> with given options | |
* | |
* Requires an 'options' value on the schema. | |
* Can be an array of options, a function that calls back with the array of options, a string of HTML | |
* or a Backbone collection. If a collection, the models must implement a toString() method | |
*/ | |
Form.editors.Select = Form.editors.Base.extend({ | |
tagName: 'select', | |
events: { | |
'change': 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); | |
if (!this.schema || !this.schema.options) throw new Error("Missing required 'schema.options'"); | |
}, | |
render: function() { | |
this.setOptions(this.schema.options); | |
return this; | |
}, | |
/** | |
* Sets the options that populate the <select> | |
* | |
* @param {Mixed} options | |
*/ | |
setOptions: function(options) { | |
var self = this; | |
//If a collection was passed, check if it needs fetching | |
if (options instanceof Thorax.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 <option> html to the DOM | |
* @param {Mixed} Options as a simple array e.g. ['option1', 'option2'] | |
* or as an array of objects e.g. [{val: 543, label: 'Title for object 543'}] | |
* or as a string of <option> HTML to insert into the <select> | |
* or any object | |
*/ | |
renderOptions: function(options) { | |
var $select = this.$el, | |
html; | |
html = this._getOptionsHtml(options); | |
//Insert options | |
$select.html(html); | |
//Select correct option | |
this.setValue(this.value); | |
}, | |
_getOptionsHtml: function(options) { | |
var html; | |
//Accept string of HTML | |
if (_.isString(options)) { | |
html = options; | |
} | |
//Or array | |
else if (_.isArray(options)) { | |
html = this._arrayToHtml(options); | |
} | |
//Or Backbone collection | |
else if (options instanceof Thorax.Collection) { | |
html = this._collectionToHtml(options); | |
} | |
else if (_.isFunction(options)) { | |
var newOptions; | |
options(function(opts) { | |
newOptions = opts; | |
}, this); | |
html = this._getOptionsHtml(newOptions); | |
//Or any object | |
}else{ | |
html=this._objectToHtml(options); | |
} | |
return html; | |
}, | |
getValue: function() { | |
return this.$el.val(); | |
}, | |
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(); | |
}, | |
/** | |
* Transforms a collection into HTML ready to use in the renderOptions method | |
* @param {Backbone.Collection} | |
* @return {String} | |
*/ | |
_collectionToHtml: function(collection) { | |
//Convert collection to array first | |
var array = []; | |
collection.each(function(model) { | |
array.push({ val: model.id, label: model.toString() }); | |
}); | |
//Now convert to HTML | |
var html = this._arrayToHtml(array); | |
return html; | |
}, | |
/** | |
* Transforms an object into HTML ready to use in the renderOptions method | |
* @param {Object} | |
* @return {String} | |
*/ | |
_objectToHtml: function(obj) { | |
//Convert object to array first | |
var array = []; | |
for(var key in obj){ | |
if( obj.hasOwnProperty( key ) ) { | |
array.push({ val: key, label: obj[key] }); | |
} | |
} | |
//Now convert to HTML | |
var html = this._arrayToHtml(array); | |
return html; | |
}, | |
/** | |
* Create the <option> HTML | |
* @param {Array} Options as a simple array e.g. ['option1', 'option2'] | |
* or as an array of objects e.g. [{val: 543, label: 'Title for object 543'}] | |
* @return {String} HTML | |
*/ | |
_arrayToHtml: function(array) { | |
var html = []; | |
//Generate HTML | |
_.each(array, function(option) { | |
if (_.isObject(option)) { | |
if (option.group) { | |
html.push('<optgroup label="'+option.group+'">'); | |
html.push(this._getOptionsHtml(option.options)) | |
html.push('</optgroup>'); | |
} else { | |
var val = (option.val || option.val === 0) ? option.val : ''; | |
html.push('<option value="'+val+'">'+option.label+'</option>'); | |
} | |
} | |
else { | |
html.push('<option>'+option+'</option>'); | |
} | |
}, this); | |
return html.join(''); | |
} | |
}); | |
/** | |
* Radio editor | |
* | |
* Renders a <ul> with given options represented as <li> objects containing radio buttons | |
* | |
* Requires an 'options' value on the schema. | |
* Can be an array of options, a function that calls back with the array of options, a string of HTML | |
* or a Backbone collection. If a collection, the models must implement a toString() method | |
*/ | |
Form.editors.Radio = Form.editors.Select.extend({ | |
tagName: 'ul', | |
events: { | |
'change input[type=radio]': function() { | |
this.trigger('change', this); | |
}, | |
'focus input[type=radio]': function() { | |
if (this.hasFocus) return; | |
this.trigger('focus', this); | |
}, | |
'blur input[type=radio]': function() { | |
if (!this.hasFocus) return; | |
var self = this; | |
setTimeout(function() { | |
if (self.$('input[type=radio]:focus')[0]) return; | |
self.trigger('blur', self); | |
}, 0); | |
} | |
}, | |
/** | |
* Returns the template. Override for custom templates | |
* | |
* @return {Function} Compiled template | |
*/ | |
getTemplate: function() { | |
return this.schema.template || this.constructor.template; | |
}, | |
getValue: function() { | |
return this.$('input[type=radio]:checked').val(); | |
}, | |
setValue: function(value) { | |
this.$('input[type=radio]').val([value]); | |
}, | |
focus: function() { | |
if (this.hasFocus) return; | |
var checked = this.$('input[type=radio]:checked'); | |
if (checked[0]) { | |
checked.focus(); | |
return; | |
} | |
this.$('input[type=radio]').first().focus(); | |
}, | |
blur: function() { | |
if (!this.hasFocus) return; | |
this.$('input[type=radio]:focus').blur(); | |
}, | |
/** | |
* Create the radio list HTML | |
* @param {Array} Options as a simple array e.g. ['option1', 'option2'] | |
* or as an array of objects e.g. [{val: 543, label: 'Title for object 543'}] | |
* @return {String} HTML | |
*/ | |
_arrayToHtml: function (array) { | |
var self = this; | |
var template = this.getTemplate(), | |
name = self.getName(), | |
id = self.id; | |
var items = _.map(array, function(option, index) { | |
var item = { | |
name: name, | |
id: id + '-' + index | |
} | |
if (_.isObject(option)) { | |
item.value = (option.val || option.val === 0) ? option.val : ''; | |
item.label = option.label; | |
} else { | |
item.value = option; | |
item.label = option; | |
} | |
return item; | |
}); | |
return template({ items: items }); | |
} | |
}, { | |
//STATICS | |
template: _.template('\ | |
<% _.each(items, function(item) { %>\ | |
<li>\ | |
<input type="radio" name="<%= item.name %>" value="<%= item.value %>" id="<%= item.id %>" />\ | |
<label for="<%= item.id %>"><%= item.label %></label>\ | |
</li>\ | |
<% }); %>\ | |
', null, Form.templateSettings) | |
}); | |
/** | |
* Checkboxes editor | |
* | |
* Renders a <ul> with given options represented as <li> objects containing checkboxes | |
* | |
* Requires an 'options' value on the schema. | |
* Can be an array of options, a function that calls back with the array of options, a string of HTML | |
* or a Backbone collection. If a collection, the models must implement a toString() method | |
*/ | |
Form.editors.Checkboxes = Form.editors.Select.extend({ | |
tagName: 'ul', | |
groupNumber: 0, | |
events: { | |
'click input[type=checkbox]': function() { | |
this.trigger('change', this); | |
}, | |
'focus input[type=checkbox]': function() { | |
if (this.hasFocus) return; | |
this.trigger('focus', this); | |
}, | |
'blur input[type=checkbox]': function() { | |
if (!this.hasFocus) return; | |
var self = this; | |
setTimeout(function() { | |
if (self.$('input[type=checkbox]:focus')[0]) return; | |
self.trigger('blur', self); | |
}, 0); | |
} | |
}, | |
getValue: function() { | |
var values = []; | |
this.$('input[type=checkbox]:checked').each(function() { | |
values.push($(this).val()); | |
}); | |
return values; | |
}, | |
setValue: function(values) { | |
if (!_.isArray(values)) values = [values]; | |
this.$('input[type=checkbox]').val(values); | |
}, | |
focus: function() { | |
if (this.hasFocus) return; | |
this.$('input[type=checkbox]').first().focus(); | |
}, | |
blur: function() { | |
if (!this.hasFocus) return; | |
this.$('input[type=checkbox]:focus').blur(); | |
}, | |
/** | |
* Create the checkbox list HTML | |
* @param {Array} Options as a simple array e.g. ['option1', 'option2'] | |
* or as an array of objects e.g. [{val: 543, label: 'Title for object 543'}] | |
* @return {String} HTML | |
*/ | |
_arrayToHtml: function (array) { | |
var html = []; | |
var self = this; | |
_.each(array, function(option, index) { | |
var itemHtml = '<li>'; | |
var close = true; | |
if (_.isObject(option)) { | |
if (option.group) { | |
var originalId = self.id; | |
self.id += "-" + self.groupNumber++; | |
itemHtml = ('<fieldset class="group"> <legend>'+option.group+'</legend>'); | |
itemHtml += (self._arrayToHtml(option.options)); | |
itemHtml += ('</fieldset>'); | |
self.id = originalId; | |
close = false; | |
}else{ | |
var val = (option.val || option.val === 0) ? option.val : ''; | |
itemHtml += ('<input type="checkbox" name="'+self.getName()+'" value="'+val+'" id="'+self.id+'-'+index+'" />'); | |
itemHtml += ('<label for="'+self.id+'-'+index+'">'+option.label+'</label>'); | |
} | |
} | |
else { | |
itemHtml += ('<input type="checkbox" name="'+self.getName()+'" value="'+option+'" id="'+self.id+'-'+index+'" />'); | |
itemHtml += ('<label for="'+self.id+'-'+index+'">'+option+'</label>'); | |
} | |
if(close){ | |
itemHtml += '</li>'; | |
} | |
html.push(itemHtml); | |
}); | |
return html.join(''); | |
} | |
}); | |
/** | |
* Object editor | |
* | |
* Creates a child form. For editing Javascript objects | |
* | |
* @param {Object} options | |
* @param {Form} options.form The form this editor belongs to; used to determine the constructor for the nested form | |
* @param {Object} options.schema The schema for the object | |
* @param {Object} options.schema.subSchema The schema for the nested form | |
*/ | |
Form.editors.Object = Form.editors.Base.extend({ | |
//Prevent error classes being set on the main control; they are internally on the individual fields | |
hasNestedForm: true, | |
initialize: function(options) { | |
//Set default value for the instance so it's not a shared object | |
this.value = {}; | |
//Init | |
Form.editors.Base.prototype.initialize.call(this, options); | |
//Check required options | |
if (!this.form) throw new Error('Missing required option "form"'); | |
if (!this.schema.subSchema) throw new Error("Missing required 'schema.subSchema' option for Object editor"); | |
}, | |
render: function() { | |
//Get the constructor for creating the nested form; i.e. the same constructor as used by the parent form | |
var NestedForm = this.form.constructor; | |
//Create the nested form | |
this.nestedForm = new NestedForm({ | |
schema: this.schema.subSchema, | |
data: this.value, | |
idPrefix: this.id + '_', | |
Field: NestedForm.NestedField | |
}); | |
this._observeFormEvents(); | |
this.$el.html(this.nestedForm.render().el); | |
if (this.hasFocus) this.trigger('blur', this); | |
return this; | |
}, | |
getValue: function() { | |
if (this.nestedForm) return this.nestedForm.getValue(); | |
return this.value; | |
}, | |
setValue: function(value) { | |
this.value = value; | |
this.render(); | |
}, | |
focus: function() { | |
if (this.hasFocus) return; | |
this.nestedForm.focus(); | |
}, | |
blur: function() { | |
if (!this.hasFocus) return; | |
this.nestedForm.blur(); | |
}, | |
remove: function() { | |
this.nestedForm.remove(); | |
Thorax.View.prototype.remove.call(this); | |
}, | |
validate: function() { | |
return this.nestedForm.validate(); | |
}, | |
_observeFormEvents: function() { | |
if (!this.nestedForm) return; | |
this.nestedForm.on('all', function() { | |
// args = ["key:change", form, fieldEditor] | |
var args = _.toArray(arguments); | |
args[1] = this; | |
// args = ["key:change", this=objectEditor, fieldEditor] | |
this.trigger.apply(this, args); | |
}, this); | |
} | |
}); | |
/** | |
* NestedModel editor | |
* | |
* Creates a child form. For editing nested Backbone models | |
* | |
* Special options: | |
* schema.model: Embedded model constructor | |
*/ | |
Form.editors.NestedModel = Form.editors.Object.extend({ | |
initialize: function(options) { | |
Form.editors.Base.prototype.initialize.call(this, options); | |
if (!this.form) throw new Error('Missing required option "form"'); | |
if (!options.schema.model) throw new Error('Missing required "schema.model" option for NestedModel editor'); | |
}, | |
render: function() { | |
//Get the constructor for creating the nested form; i.e. the same constructor as used by the parent form | |
var NestedForm = this.form.constructor; | |
var data = this.value || {}, | |
key = this.key, | |
nestedModel = this.schema.model; | |
//Wrap the data in a model if it isn't already a model instance | |
var modelInstance = (data.constructor === nestedModel) ? data : new nestedModel(data); | |
this.nestedForm = new NestedForm({ | |
model: modelInstance, | |
idPrefix: this.id + '_', | |
fieldTemplate: 'nestedField' | |
}); | |
this._observeFormEvents(); | |
//Render form | |
this.$el.html(this.nestedForm.render().el); | |
if (this.hasFocus) this.trigger('blur', this); | |
return this; | |
}, | |
/** | |
* Update the embedded model, checking for nested validation errors and pass them up | |
* Then update the main model if all OK | |
* | |
* @return {Error|null} Validation error or null | |
*/ | |
commit: function() { | |
var error = this.nestedForm.commit(); | |
if (error) { | |
this.$el.addClass('error'); | |
return error; | |
} | |
return Form.editors.Object.prototype.commit.call(this); | |
} | |
}); | |
/** | |
* Date editor | |
* | |
* Schema options | |
* @param {Number|String} [options.schema.yearStart] First year in list. Default: 100 years ago | |
* @param {Number|String} [options.schema.yearEnd] Last year in list. Default: current year | |
* | |
* Config options (if not set, defaults to options stored on the main Date class) | |
* @param {Boolean} [options.showMonthNames] Use month names instead of numbers. Default: true | |
* @param {String[]} [options.monthNames] Month names. Default: Full English names | |
*/ | |
Form.editors.Date = Form.editors.Base.extend({ | |
events: { | |
'change select': function() { | |
this.updateHidden(); | |
this.trigger('change', this); | |
}, | |
'focus select': function() { | |
if (this.hasFocus) return; | |
this.trigger('focus', this); | |
}, | |
'blur select': function() { | |
if (!this.hasFocus) return; | |
var self = this; | |
setTimeout(function() { | |
if (self.$('select:focus')[0]) return; | |
self.trigger('blur', self); | |
}, 0); | |
} | |
}, | |
initialize: function(options) { | |
options = options || {}; | |
Form.editors.Base.prototype.initialize.call(this, options); | |
var Self = Form.editors.Date, | |
today = new Date(); | |
//Option defaults | |
this.options = _.extend({ | |
monthNames: Self.monthNames, | |
showMonthNames: Self.showMonthNames | |
}, options); | |
//Schema defaults | |
this.schema = _.extend({ | |
yearStart: today.getFullYear() - 100, | |
yearEnd: today.getFullYear() | |
}, options.schema || {}); | |
//Cast to Date | |
if (this.value && !_.isDate(this.value)) { | |
this.value = new Date(this.value); | |
} | |
//Set default date | |
if (!this.value) { | |
var date = new Date(); | |
date.setSeconds(0); | |
date.setMilliseconds(0); | |
this.value = date; | |
} | |
//Template | |
this.template = options.template || this.constructor.template; | |
}, | |
render: function() { | |
var options = this.options, | |
schema = this.schema, | |
$ = Backbone.$; | |
var datesOptions = _.map(_.range(1, 32), function(date) { | |
return '<option value="'+date+'">' + date + '</option>'; | |
}); | |
var monthsOptions = _.map(_.range(0, 12), function(month) { | |
var value = (options.showMonthNames) | |
? options.monthNames[month] | |
: (month + 1); | |
return '<option value="'+month+'">' + value + '</option>'; | |
}); | |
var yearRange = (schema.yearStart < schema.yearEnd) | |
? _.range(schema.yearStart, schema.yearEnd + 1) | |
: _.range(schema.yearStart, schema.yearEnd - 1, -1); | |
var yearsOptions = _.map(yearRange, function(year) { | |
return '<option value="'+year+'">' + year + '</option>'; | |
}); | |
//Render the selects | |
var $el = $($.trim(this.template({ | |
dates: datesOptions.join(''), | |
months: monthsOptions.join(''), | |
years: yearsOptions.join('') | |
}))); | |
//Store references to selects | |
this.$date = $el.find('[data-type="date"]'); | |
this.$month = $el.find('[data-type="month"]'); | |
this.$year = $el.find('[data-type="year"]'); | |
//Create the hidden field to store values in case POSTed to server | |
this.$hidden = $('<input type="hidden" name="'+this.key+'" />'); | |
$el.append(this.$hidden); | |
//Set value on this and hidden field | |
this.setValue(this.value); | |
//Remove the wrapper tag | |
this.setElement($el); | |
this.$el.attr('id', this.id); | |
this.$el.attr('name', this.getName()); | |
if (this.hasFocus) this.trigger('blur', this); | |
return this; | |
}, | |
/** | |
* @return {Date} Selected date | |
*/ | |
getValue: function() { | |
var year = this.$year.val(), | |
month = this.$month.val(), | |
date = this.$date.val(); | |
if (!year || !month || !date) return null; | |
return new Date(year, month, date); | |
}, | |
/** | |
* @param {Date} date | |
*/ | |
setValue: function(date) { | |
this.$date.val(date.getDate()); | |
this.$month.val(date.getMonth()); | |
this.$year.val(date.getFullYear()); | |
this.updateHidden(); | |
}, | |
focus: function() { | |
if (this.hasFocus) return; | |
this.$('select').first().focus(); | |
}, | |
blur: function() { | |
if (!this.hasFocus) return; | |
this.$('select:focus').blur(); | |
}, | |
/** | |
* Update the hidden input which is maintained for when submitting a form | |
* via a normal browser POST | |
*/ | |
updateHidden: function() { | |
var val = this.getValue(); | |
if (_.isDate(val)) val = val.toISOString(); | |
this.$hidden.val(val); | |
} | |
}, { | |
//STATICS | |
template: _.template('\ | |
<div>\ | |
<select data-type="date"><%= dates %></select>\ | |
<select data-type="month"><%= months %></select>\ | |
<select data-type="year"><%= years %></select>\ | |
</div>\ | |
', null, Form.templateSettings), | |
//Whether to show month names instead of numbers | |
showMonthNames: true, | |
//Month names to use if showMonthNames is true | |
//Replace for localisation, e.g. Form.editors.Date.monthNames = ['Janvier', 'Fevrier'...] | |
monthNames: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'] | |
}); | |
/** | |
* DateTime editor | |
* | |
* @param {Editor} [options.DateEditor] Date editor view to use (not definition) | |
* @param {Number} [options.schema.minsInterval] Interval between minutes. Default: 15 | |
*/ | |
Form.editors.DateTime = Form.editors.Base.extend({ | |
events: { | |
'change select': function() { | |
this.updateHidden(); | |
this.trigger('change', this); | |
}, | |
'focus select': function() { | |
if (this.hasFocus) return; | |
this.trigger('focus', this); | |
}, | |
'blur select': function() { | |
if (!this.hasFocus) return; | |
var self = this; | |
setTimeout(function() { | |
if (self.$('select:focus')[0]) return; | |
self.trigger('blur', self); | |
}, 0); | |
} | |
}, | |
initialize: function(options) { | |
options = options || {}; | |
Form.editors.Base.prototype.initialize.call(this, options); | |
//Option defaults | |
this.options = _.extend({ | |
DateEditor: Form.editors.DateTime.DateEditor | |
}, options); | |
//Schema defaults | |
this.schema = _.extend({ | |
minsInterval: 15 | |
}, options.schema || {}); | |
//Create embedded date editor | |
this.dateEditor = new this.options.DateEditor(options); | |
this.value = this.dateEditor.value; | |
//Template | |
this.template = options.template || this.constructor.template; | |
}, | |
render: function() { | |
function pad(n) { | |
return n < 10 ? '0' + n : n; | |
} | |
var schema = this.schema, | |
$ = Backbone.$; | |
//Create options | |
var hoursOptions = _.map(_.range(0, 24), function(hour) { | |
return '<option value="'+hour+'">' + pad(hour) + '</option>'; | |
}); | |
var minsOptions = _.map(_.range(0, 60, schema.minsInterval), function(min) { | |
return '<option value="'+min+'">' + pad(min) + '</option>'; | |
}); | |
//Render time selects | |
var $el = $($.trim(this.template({ | |
hours: hoursOptions.join(), | |
mins: minsOptions.join() | |
}))); | |
//Include the date editor | |
$el.find('[data-date]').append(this.dateEditor.render().el); | |
//Store references to selects | |
this.$hour = $el.find('select[data-type="hour"]'); | |
this.$min = $el.find('select[data-type="min"]'); | |
//Get the hidden date field to store values in case POSTed to server | |
this.$hidden = $el.find('input[type="hidden"]'); | |
//Set time | |
this.setValue(this.value); | |
this.setElement($el); | |
this.$el.attr('id', this.id); | |
this.$el.attr('name', this.getName()); | |
if (this.hasFocus) this.trigger('blur', this); | |
return this; | |
}, | |
/** | |
* @return {Date} Selected datetime | |
*/ | |
getValue: function() { | |
var date = this.dateEditor.getValue(); | |
var hour = this.$hour.val(), | |
min = this.$min.val(); | |
if (!date || !hour || !min) return null; | |
date.setHours(hour); | |
date.setMinutes(min); | |
return date; | |
}, | |
/** | |
* @param {Date} | |
*/ | |
setValue: function(date) { | |
if (!_.isDate(date)) date = new Date(date); | |
this.dateEditor.setValue(date); | |
this.$hour.val(date.getHours()); | |
this.$min.val(date.getMinutes()); | |
this.updateHidden(); | |
}, | |
focus: function() { | |
if (this.hasFocus) return; | |
this.$('select').first().focus(); | |
}, | |
blur: function() { | |
if (!this.hasFocus) return; | |
this.$('select:focus').blur(); | |
}, | |
/** | |
* Update the hidden input which is maintained for when submitting a form | |
* via a normal browser POST | |
*/ | |
updateHidden: function() { | |
var val = this.getValue(); | |
if (_.isDate(val)) val = val.toISOString(); | |
this.$hidden.val(val); | |
}, | |
/** | |
* Remove the Date editor before removing self | |
*/ | |
remove: function() { | |
this.dateEditor.remove(); | |
Form.editors.Base.prototype.remove.call(this); | |
} | |
}, { | |
//STATICS | |
template: _.template('\ | |
<div class="bbf-datetime">\ | |
<div class="bbf-date-container" data-date></div>\ | |
<select data-type="hour"><%= hours %></select>\ | |
:\ | |
<select data-type="min"><%= mins %></select>\ | |
</div>\ | |
', null, Form.templateSettings), | |
//The date editor to use (constructor function, not instance) | |
DateEditor: Form.editors.Date | |
}); | |
//Metadata | |
Form.VERSION = '0.14.0'; | |
//Exports | |
Backbone.Form = Form; | |
if (typeof module !== 'undefined') module.exports = Form; | |
})(window || global || this); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment