rails-backbone-forms 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,2309 @@
1
+ /**
2
+ * Backbone Forms v0.10.1
3
+ *
4
+ * Copyright (c) 2012 Charles Davison, Pow Media Ltd
5
+ *
6
+ * License and more information at:
7
+ * http://github.com/powmedia/backbone-forms
8
+ */
9
+ ;(function(root) {
10
+
11
+ //DEPENDENCIES
12
+ //CommonJS
13
+ if (typeof exports !== 'undefined' && typeof require !== 'undefined') {
14
+ var $ = root.jQuery || root.Zepto || root.ender || require('jquery'),
15
+ _ = root._ || require('underscore'),
16
+ Backbone = root.Backbone || require('backbone');
17
+ }
18
+
19
+ //Browser
20
+ else {
21
+ var $ = root.jQuery,
22
+ _ = root._,
23
+ Backbone = root.Backbone;
24
+ }
25
+
26
+
27
+ //SOURCE
28
+
29
+ //==================================================================================================
30
+ //FORM
31
+ //==================================================================================================
32
+
33
+ var Form = (function() {
34
+
35
+ return Backbone.View.extend({
36
+
37
+ hasFocus: false,
38
+
39
+ /**
40
+ * Creates a new form
41
+ *
42
+ * @param {Object} options
43
+ * @param {Model} [options.model] Model the form relates to. Required if options.data is not set
44
+ * @param {Object} [options.data] Date to populate the form. Required if options.model is not set
45
+ * @param {String[]} [options.fields] Fields to include in the form, in order
46
+ * @param {String[]|Object[]} [options.fieldsets] How to divide the fields up by section. E.g. [{ legend: 'Title', fields: ['field1', 'field2'] }]
47
+ * @param {String} [options.idPrefix] Prefix for editor IDs. By default, the model's CID is used.
48
+ * @param {String} [options.template] Form template key/name
49
+ * @param {String} [options.fieldsetTemplate] Fieldset template key/name
50
+ * @param {String} [options.fieldTemplate] Field template key/name
51
+ *
52
+ * @return {Form}
53
+ */
54
+ initialize: function(options) {
55
+ //Check templates have been loaded
56
+ if (!Form.templates.form) throw new Error('Templates not loaded');
57
+
58
+ //Get the schema
59
+ this.schema = (function() {
60
+ if (options.schema) return options.schema;
61
+
62
+ var model = options.model;
63
+ if (!model) throw new Error('Could not find schema');
64
+
65
+ if (_.isFunction(model.schema)) return model.schema();
66
+
67
+ return model.schema;
68
+ })();
69
+
70
+ //Option defaults
71
+ options = _.extend({
72
+ template: 'form',
73
+ fieldsetTemplate: 'fieldset',
74
+ fieldTemplate: 'field'
75
+ }, options);
76
+
77
+ //Determine fieldsets
78
+ if (!options.fieldsets) {
79
+ var fields = options.fields || _.keys(this.schema);
80
+
81
+ options.fieldsets = [{ fields: fields }];
82
+ }
83
+
84
+ //Store main attributes
85
+ this.options = options;
86
+ this.model = options.model;
87
+ this.data = options.data;
88
+ this.fields = {};
89
+ },
90
+
91
+ /**
92
+ * Renders the form and all fields
93
+ */
94
+ render: function() {
95
+ var self = this,
96
+ options = this.options,
97
+ template = Form.templates[options.template];
98
+
99
+ //Create el from template
100
+ var $form = $(template({
101
+ fieldsets: '<b class="bbf-tmp"></b>'
102
+ }));
103
+
104
+ //Render fieldsets
105
+ var $fieldsetContainer = $('.bbf-tmp', $form);
106
+
107
+ _.each(options.fieldsets, function(fieldset) {
108
+ $fieldsetContainer.append(self.renderFieldset(fieldset));
109
+ });
110
+
111
+ $fieldsetContainer.children().unwrap();
112
+
113
+ //Set the template contents as the main element; removes the wrapper element
114
+ this.setElement($form);
115
+
116
+ if (this.hasFocus) this.trigger('blur', this);
117
+
118
+ return this;
119
+ },
120
+
121
+ /**
122
+ * Renders a fieldset and the fields within it
123
+ *
124
+ * Valid fieldset definitions:
125
+ * ['field1', 'field2']
126
+ * { legend: 'Some Fieldset', fields: ['field1', 'field2'] }
127
+ *
128
+ * @param {Object|Array} fieldset A fieldset definition
129
+ *
130
+ * @return {jQuery} The fieldset DOM element
131
+ */
132
+ renderFieldset: function(fieldset) {
133
+ var self = this,
134
+ template = Form.templates[this.options.fieldsetTemplate],
135
+ schema = this.schema,
136
+ getNested = Form.helpers.getNested;
137
+
138
+ //Normalise to object
139
+ if (_.isArray(fieldset)) {
140
+ fieldset = { fields: fieldset };
141
+ }
142
+
143
+ //Concatenating HTML as strings won't work so we need to insert field elements into a placeholder
144
+ var $fieldset = $(template(_.extend({}, fieldset, {
145
+ legend: '<b class="bbf-tmp-legend"></b>',
146
+ fields: '<b class="bbf-tmp-fields"></b>'
147
+ })));
148
+
149
+ //Set legend
150
+ if (fieldset.legend) {
151
+ $fieldset.find('.bbf-tmp-legend').replaceWith(fieldset.legend);
152
+ }
153
+ //or remove the containing tag if there isn't a legend
154
+ else {
155
+ $fieldset.find('.bbf-tmp-legend').parent().remove();
156
+ }
157
+
158
+ var $fieldsContainer = $('.bbf-tmp-fields', $fieldset);
159
+
160
+ //Render fields
161
+ _.each(fieldset.fields, function(key) {
162
+ //Get the field schema
163
+ var itemSchema = (function() {
164
+ //Return a normal key or path key
165
+ if (schema[key]) return schema[key];
166
+
167
+ //Return a nested schema, i.e. Object
168
+ var path = key.replace(/\./g, '.subSchema.');
169
+ return getNested(schema, path);
170
+ })();
171
+
172
+ if (!itemSchema) throw "Field '"+key+"' not found in schema";
173
+
174
+ //Create the field
175
+ var field = self.fields[key] = self.createField(key, itemSchema);
176
+
177
+ //Render the fields with editors, apart from Hidden fields
178
+ var fieldEl = field.render().el;
179
+
180
+ field.editor.on('all', function(event) {
181
+ // args = ["change", editor]
182
+ args = _.toArray(arguments);
183
+ args[0] = key + ':' + event;
184
+ args.splice(1, 0, this);
185
+ // args = ["key:change", this=form, editor]
186
+
187
+ this.trigger.apply(this, args)
188
+ }, self);
189
+
190
+ field.editor.on('change', function() {
191
+ this.trigger('change', self);
192
+ }, self);
193
+
194
+ field.editor.on('focus', function() {
195
+ if (this.hasFocus) return;
196
+ this.trigger('focus', this);
197
+ }, self);
198
+ field.editor.on('blur', function() {
199
+ if (!this.hasFocus) return;
200
+ var self = this;
201
+ setTimeout(function() {
202
+ if (_.find(self.fields, function(field) { return field.editor.hasFocus; })) return;
203
+ self.trigger('blur', self);
204
+ }, 0);
205
+ }, self);
206
+
207
+ if (itemSchema.type != 'Hidden') {
208
+ $fieldsContainer.append(fieldEl);
209
+ }
210
+ });
211
+
212
+ $fieldsContainer = $fieldsContainer.children().unwrap()
213
+
214
+ return $fieldset;
215
+ },
216
+
217
+ /**
218
+ * Renders a field and returns it
219
+ *
220
+ * @param {String} key The key for the field in the form schema
221
+ * @param {Object} schema Field schema
222
+ *
223
+ * @return {Field} The field view
224
+ */
225
+ createField: function(key, schema) {
226
+ schema.template = schema.template || this.options.fieldTemplate;
227
+
228
+ var options = {
229
+ form: this,
230
+ key: key,
231
+ schema: schema,
232
+ idPrefix: this.options.idPrefix,
233
+ template: this.options.fieldTemplate
234
+ };
235
+
236
+ if (this.model) {
237
+ options.model = this.model;
238
+ } else if (this.data) {
239
+ options.value = this.data[key];
240
+ } else {
241
+ options.value = null;
242
+ }
243
+
244
+ return new Form.Field(options);
245
+ },
246
+
247
+ /**
248
+ * Validate the data
249
+ *
250
+ * @return {Object} Validation errors
251
+ */
252
+ validate: function() {
253
+ var self = this,
254
+ fields = this.fields,
255
+ model = this.model,
256
+ errors = {};
257
+
258
+ //Collect errors from schema validation
259
+ _.each(fields, function(field) {
260
+ var error = field.validate();
261
+ if (error) {
262
+ errors[field.key] = error;
263
+ }
264
+ });
265
+
266
+ //Get errors from default Backbone model validator
267
+ if (model && model.validate) {
268
+ var modelErrors = model.validate(this.getValue());
269
+
270
+ if (modelErrors) {
271
+ var isDictionary = _.isObject(modelErrors) && !_.isArray(modelErrors);
272
+
273
+ //If errors are not in object form then just store on the error object
274
+ if (!isDictionary) {
275
+ errors._others = errors._others || [];
276
+ errors._others.push(modelErrors);
277
+ }
278
+
279
+ //Merge programmatic errors (requires model.validate() to return an object e.g. { fieldKey: 'error' })
280
+ if (isDictionary) {
281
+ _.each(modelErrors, function(val, key) {
282
+ //Set error on field if there isn't one already
283
+ if (self.fields[key] && !errors[key]) {
284
+ self.fields[key].setError(val);
285
+ }
286
+
287
+ else {
288
+ //Otherwise add to '_others' key
289
+ errors._others = errors._others || [];
290
+ var tmpErr = {};
291
+ tmpErr[key] = val;
292
+ errors._others.push(tmpErr);
293
+ }
294
+ });
295
+ }
296
+ }
297
+ }
298
+
299
+ return _.isEmpty(errors) ? null : errors;
300
+ },
301
+
302
+ /**
303
+ * Update the model with all latest values.
304
+ *
305
+ * @return {Object} Validation errors
306
+ */
307
+ commit: function() {
308
+ //Validate
309
+ var errors = this.validate();
310
+ if (errors) return errors;
311
+
312
+ //Commit
313
+ var modelError;
314
+ this.model.set(this.getValue(), {
315
+ error: function(model, e) {
316
+ modelError = e;
317
+ }
318
+ });
319
+
320
+ if (modelError) return modelError;
321
+ },
322
+
323
+ /**
324
+ * Get all the field values as an object.
325
+ * Use this method when passing data instead of objects
326
+ *
327
+ * @param {String} [key] Specific field value to get
328
+ */
329
+ getValue: function(key) {
330
+ //Return only given key if specified
331
+ if (key) return this.fields[key].getValue();
332
+
333
+ //Otherwise return entire form
334
+ var values = {};
335
+ _.each(this.fields, function(field) {
336
+ values[field.key] = field.getValue();
337
+ });
338
+
339
+ return values;
340
+ },
341
+
342
+ /**
343
+ * Update field values, referenced by key
344
+ * @param {Object} data New values to set
345
+ */
346
+ setValue: function(data) {
347
+ for (var key in data) {
348
+ if (_.has(this.fields, key)) {
349
+ this.fields[key].setValue(data[key]);
350
+ }
351
+ }
352
+ },
353
+
354
+ focus: function() {
355
+ if (this.hasFocus) return;
356
+
357
+ var fieldset = this.options.fieldsets[0];
358
+ if (fieldset) {
359
+ var field;
360
+ if (_.isArray(fieldset)) {
361
+ field = fieldset[0];
362
+ }
363
+ else {
364
+ field = fieldset.fields[0];
365
+ }
366
+ if (field) {
367
+ this.fields[field].editor.focus();
368
+ }
369
+ }
370
+ },
371
+
372
+ blur: function() {
373
+ if (!this.hasFocus) return;
374
+
375
+ focusedField = _.find(this.fields, function(field) { return field.editor.hasFocus; });
376
+
377
+ if (focusedField) focusedField.editor.blur();
378
+ },
379
+
380
+ /**
381
+ * Override default remove function in order to remove embedded views
382
+ */
383
+ remove: function() {
384
+ var fields = this.fields;
385
+
386
+ for (var key in fields) {
387
+ fields[key].remove();
388
+ }
389
+
390
+ Backbone.View.prototype.remove.call(this);
391
+ },
392
+
393
+
394
+ trigger: function(event) {
395
+ if (event == 'focus') {
396
+ this.hasFocus = true;
397
+ }
398
+ else if (event == 'blur') {
399
+ this.hasFocus = false;
400
+ }
401
+
402
+ return Backbone.View.prototype.trigger.apply(this, arguments);
403
+ }
404
+ });
405
+
406
+ })();
407
+
408
+
409
+ //==================================================================================================
410
+ //HELPERS
411
+ //==================================================================================================
412
+
413
+ Form.helpers = (function() {
414
+
415
+ var helpers = {};
416
+
417
+ /**
418
+ * Gets a nested attribute using a path e.g. 'user.name'
419
+ *
420
+ * @param {Object} obj Object to fetch attribute from
421
+ * @param {String} path Attribute path e.g. 'user.name'
422
+ * @return {Mixed}
423
+ * @api private
424
+ */
425
+ helpers.getNested = function(obj, path) {
426
+ var fields = path.split(".");
427
+ var result = obj;
428
+ for (var i = 0, n = fields.length; i < n; i++) {
429
+ result = result[fields[i]];
430
+ }
431
+ return result;
432
+ }
433
+
434
+ /**
435
+ * This function is used to transform the key from a schema into the title used in a label.
436
+ * (If a specific title is provided it will be used instead).
437
+ *
438
+ * By default this converts a camelCase string into words, i.e. Camel Case
439
+ * If you have a different naming convention for schema keys, replace this function.
440
+ *
441
+ * @param {String} Key
442
+ * @return {String} Title
443
+ */
444
+ helpers.keyToTitle = function(str) {
445
+ //Add spaces
446
+ str = str.replace(/([A-Z])/g, ' $1');
447
+
448
+ //Uppercase first character
449
+ str = str.replace(/^./, function(str) { return str.toUpperCase(); });
450
+
451
+ return str;
452
+ };
453
+
454
+ /**
455
+ * Helper to compile a template with the {{mustache}} style tags. Template settings are reset
456
+ * to user's settings when done to avoid conflicts.
457
+ * @param {String} Template string
458
+ * @return {Template} Compiled template
459
+ */
460
+ helpers.compileTemplate = function(str) {
461
+ //Store user's template options
462
+ var _interpolateBackup = _.templateSettings.interpolate;
463
+
464
+ //Set custom template settings
465
+ _.templateSettings.interpolate = /\{\{(.+?)\}\}/g;
466
+
467
+ var template = _.template(str);
468
+
469
+ //Reset to users' template settings
470
+ _.templateSettings.interpolate = _interpolateBackup;
471
+
472
+ return template;
473
+ }
474
+
475
+ /**
476
+ * Helper to create a template with the {{mustache}} style tags.
477
+ * If context is passed in, the template will be evaluated.
478
+ * @param {String} Template string
479
+ * @param {Object} Optional; values to replace in template
480
+ * @return {Template|String} Compiled template or the evaluated string
481
+ */
482
+ helpers.createTemplate = function(str, context) {
483
+ var template = helpers.compileTemplate(str);
484
+
485
+ if (!context) {
486
+ return template;
487
+ } else {
488
+ return template(context);
489
+ }
490
+ };
491
+
492
+
493
+ /**
494
+ * Sets the template compiler to the given function
495
+ * @param {Function} Template compiler function
496
+ */
497
+ helpers.setTemplateCompiler = function(compiler) {
498
+ helpers.compileTemplate = compiler;
499
+ }
500
+
501
+
502
+ /**
503
+ * Sets the templates to be used.
504
+ *
505
+ * If the templates passed in are strings, they will be compiled, expecting Mustache style tags,
506
+ * i.e. <div>{{varName}}</div>
507
+ *
508
+ * You can also pass in previously compiled Underscore templates, in which case you can use any style
509
+ * tags.
510
+ *
511
+ * @param {Object} templates
512
+ * @param {Object} classNames
513
+ */
514
+ helpers.setTemplates = function(templates, classNames) {
515
+ var createTemplate = helpers.createTemplate;
516
+
517
+ Form.templates = Form.templates || {};
518
+ Form.classNames = Form.classNames || {};
519
+
520
+ //Set templates, compiling them if necessary
521
+ _.each(templates, function(template, key, index) {
522
+ if (_.isString(template)) template = createTemplate(template);
523
+
524
+ Form.templates[key] = template;
525
+ });
526
+
527
+ //Set class names
528
+ _.extend(Form.classNames, classNames);
529
+ };
530
+
531
+
532
+ /**
533
+ * Return the editor constructor for a given schema 'type'.
534
+ * Accepts strings for the default editors, or the reference to the constructor function
535
+ * for custom editors
536
+ *
537
+ * @param {String|Function} The schema type e.g. 'Text', 'Select', or the editor constructor e.g. editors.Date
538
+ * @param {Object} Options to pass to editor, including required 'key', 'schema'
539
+ * @return {Mixed} An instance of the mapped editor
540
+ */
541
+ helpers.createEditor = function(schemaType, options) {
542
+ var constructorFn;
543
+
544
+ if (_.isString(schemaType)) {
545
+ constructorFn = Form.editors[schemaType];
546
+ } else {
547
+ constructorFn = schemaType;
548
+ }
549
+
550
+ return new constructorFn(options);
551
+ };
552
+
553
+ /**
554
+ * Triggers an event that can be cancelled. Requires the user to invoke a callback. If false
555
+ * is passed to the callback, the action does not run.
556
+ *
557
+ * NOTE: This helper uses private Backbone apis so can break when Backbone is upgraded
558
+ *
559
+ * @param {Mixed} Instance of Backbone model, view, collection to trigger event on
560
+ * @param {String} Event name
561
+ * @param {Array} Arguments to pass to the event handlers
562
+ * @param {Function} Callback to run after the event handler has run.
563
+ * If any of them passed false or error, this callback won't run
564
+ */
565
+ helpers.triggerCancellableEvent = function(subject, event, args, callback) {
566
+ //Return if there are no event listeners
567
+ if (!subject._callbacks || !subject._callbacks[event]) return callback();
568
+
569
+ var next = subject._callbacks[event].next;
570
+ if (!next) return callback();
571
+
572
+ var fn = next.callback,
573
+ context = next.context || this;
574
+
575
+ //Add the callback that will be used when done
576
+ args.push(callback);
577
+
578
+ fn.apply(context, args);
579
+ }
580
+
581
+ /**
582
+ * Returns a validation function based on the type defined in the schema
583
+ *
584
+ * @param {RegExp|String|Function} validator
585
+ * @return {Function}
586
+ */
587
+ helpers.getValidator = function(validator) {
588
+ var validators = Form.validators;
589
+
590
+ //Convert regular expressions to validators
591
+ if (_.isRegExp(validator)) {
592
+ return validators.regexp({ regexp: validator });
593
+ }
594
+
595
+ //Use a built-in validator if given a string
596
+ if (_.isString(validator)) {
597
+ if (!validators[validator]) throw new Error('Validator "'+validator+'" not found');
598
+
599
+ return validators[validator]();
600
+ }
601
+
602
+ //Functions can be used directly
603
+ if (_.isFunction(validator)) return validator;
604
+
605
+ //Use a customised built-in validator if given an object
606
+ if (_.isObject(validator) && validator.type) {
607
+ var config = validator;
608
+
609
+ return validators[config.type](config);
610
+ }
611
+
612
+ //Unkown validator type
613
+ throw new Error('Invalid validator: ' + validator);
614
+ };
615
+
616
+
617
+ return helpers;
618
+
619
+ })();
620
+
621
+
622
+ //==================================================================================================
623
+ //VALIDATORS
624
+ //==================================================================================================
625
+
626
+ Form.validators = (function() {
627
+
628
+ var validators = {};
629
+
630
+ validators.errMessages = {
631
+ required: 'Required',
632
+ regexp: 'Invalid',
633
+ email: 'Invalid email address',
634
+ url: 'Invalid URL',
635
+ match: 'Must match field "{{field}}"'
636
+ }
637
+
638
+ validators.required = function(options) {
639
+ options = _.extend({
640
+ type: 'required',
641
+ message: this.errMessages.required
642
+ }, options);
643
+
644
+ return function required(value) {
645
+ options.value = value;
646
+
647
+ var err = {
648
+ type: options.type,
649
+ message: Form.helpers.createTemplate(options.message, options)
650
+ };
651
+
652
+ if (value === null || value === undefined || value === '') return err;
653
+ };
654
+ };
655
+
656
+ validators.regexp = function(options) {
657
+ if (!options.regexp) throw new Error('Missing required "regexp" option for "regexp" validator');
658
+
659
+ options = _.extend({
660
+ type: 'regexp',
661
+ message: this.errMessages.regexp
662
+ }, options);
663
+
664
+ return function regexp(value) {
665
+ options.value = value;
666
+
667
+ var err = {
668
+ type: options.type,
669
+ message: Form.helpers.createTemplate(options.message, options)
670
+ };
671
+
672
+ //Don't check empty values (add a 'required' validator for this)
673
+ if (value === null || value === undefined || value === '') return;
674
+
675
+ if (!options.regexp.test(value)) return err;
676
+ };
677
+ };
678
+
679
+ validators.email = function(options) {
680
+ options = _.extend({
681
+ type: 'email',
682
+ message: this.errMessages.email,
683
+ regexp: /^[\w\-]{1,}([\w\-\+.]{1,1}[\w\-]{1,}){0,}[@][\w\-]{1,}([.]([\w\-]{1,})){1,3}$/
684
+ }, options);
685
+
686
+ return validators.regexp(options);
687
+ };
688
+
689
+ validators.url = function(options) {
690
+ options = _.extend({
691
+ type: 'url',
692
+ message: this.errMessages.url,
693
+ regexp: /^(http|https):\/\/(([A-Z0-9][A-Z0-9_-]*)(\.[A-Z0-9][A-Z0-9_-]*)+)(:(\d+))?\/?/i
694
+ }, options);
695
+
696
+ return validators.regexp(options);
697
+ };
698
+
699
+ validators.match = function(options) {
700
+ if (!options.field) throw new Error('Missing required "field" options for "match" validator');
701
+
702
+ options = _.extend({
703
+ type: 'match',
704
+ message: this.errMessages.match
705
+ }, options);
706
+
707
+ return function match(value, attrs) {
708
+ options.value = value;
709
+
710
+ var err = {
711
+ type: options.type,
712
+ message: Form.helpers.createTemplate(options.message, options)
713
+ };
714
+
715
+ //Don't check empty values (add a 'required' validator for this)
716
+ if (value === null || value === undefined || value === '') return;
717
+
718
+ if (value != attrs[options.field]) return err;
719
+ }
720
+ };
721
+
722
+
723
+ return validators;
724
+
725
+ })();
726
+
727
+
728
+ //==================================================================================================
729
+ //FIELD
730
+ //==================================================================================================
731
+
732
+ Form.Field = (function() {
733
+
734
+ var helpers = Form.helpers,
735
+ templates = Form.templates;
736
+
737
+ return Backbone.View.extend({
738
+
739
+ /**
740
+ * @param {Object} Options
741
+ * Required:
742
+ * key {String} : The model attribute key
743
+ * Optional:
744
+ * schema {Object} : Schema for the field
745
+ * value {Mixed} : Pass value when not using a model. Use getValue() to get out value
746
+ * model {Backbone.Model} : Use instead of value, and use commit().
747
+ * idPrefix {String} : Prefix to add to the editor DOM element's ID
748
+ */
749
+ /**
750
+ * Creates a new field
751
+ *
752
+ * @param {Object} options
753
+ * @param {Object} [options.schema] Field schema. Defaults to { type: 'Text' }
754
+ * @param {Model} [options.model] Model the field relates to. Required if options.data is not set.
755
+ * @param {String} [options.key] Model key/attribute the field relates to.
756
+ * @param {Mixed} [options.value] Field value. Required if options.model is not set.
757
+ * @param {String} [options.idPrefix] Prefix for the editor ID. By default, the model's CID is used.
758
+ *
759
+ * @return {Field}
760
+ */
761
+ initialize: function(options) {
762
+ options = options || {};
763
+
764
+ this.form = options.form;
765
+ this.key = options.key;
766
+ this.value = options.value;
767
+ this.model = options.model;
768
+
769
+ //Turn schema shorthand notation (e.g. 'Text') into schema object
770
+ if (_.isString(options.schema)) options.schema = { type: options.schema };
771
+
772
+ //Set schema defaults
773
+ this.schema = _.extend({
774
+ type: 'Text',
775
+ title: helpers.keyToTitle(this.key),
776
+ template: 'field'
777
+ }, options.schema);
778
+ },
779
+
780
+ /**
781
+ * Renders the field
782
+ */
783
+ render: function() {
784
+ var schema = this.schema,
785
+ templates = Form.templates;
786
+
787
+ //Standard options that will go to all editors
788
+ var options = {
789
+ form: this.form,
790
+ key: this.key,
791
+ schema: schema,
792
+ idPrefix: this.options.idPrefix,
793
+ id: this.getId()
794
+ };
795
+
796
+ //Decide on data delivery type to pass to editors
797
+ if (this.model) {
798
+ options.model = this.model;
799
+ } else {
800
+ options.value = this.value;
801
+ }
802
+
803
+ //Decide on the editor to use
804
+ var editor = this.editor = helpers.createEditor(schema.type, options);
805
+
806
+ //Create the element
807
+ var $field = $(templates[schema.template]({
808
+ key: this.key,
809
+ title: schema.title,
810
+ id: editor.id,
811
+ type: schema.type,
812
+ editor: '<b class="bbf-tmp-editor"></b>',
813
+ help: '<b class="bbf-tmp-help"></b>'
814
+ }));
815
+
816
+ //Render editor
817
+ $field.find('.bbf-tmp-editor').replaceWith(editor.render().el);
818
+
819
+ //Set help text
820
+ this.$help = $('.bbf-tmp-help', $field).parent();
821
+ this.$help.empty();
822
+ if (this.schema.help) this.$help.html(this.schema.help);
823
+
824
+ //Add custom CSS class names
825
+ if (this.schema.fieldClass) $field.addClass(this.schema.fieldClass);
826
+
827
+ //Add custom attributes
828
+ if (this.schema.fieldAttrs) $field.attr(this.schema.fieldAttrs);
829
+
830
+ //Replace the generated wrapper tag
831
+ this.setElement($field);
832
+
833
+ return this;
834
+ },
835
+
836
+ /**
837
+ * Creates the ID that will be assigned to the editor
838
+ *
839
+ * @return {String}
840
+ */
841
+ getId: function() {
842
+ var prefix = this.options.idPrefix,
843
+ id = this.key;
844
+
845
+ //Replace periods with underscores (e.g. for when using paths)
846
+ id = id.replace(/\./g, '_');
847
+
848
+ //If a specific ID prefix is set, use it
849
+ if (_.isString(prefix) || _.isNumber(prefix)) return prefix + id;
850
+ if (_.isNull(prefix)) return id;
851
+
852
+ //Otherwise, if there is a model use it's CID to avoid conflicts when multiple forms are on the page
853
+ if (this.model) return this.model.cid + '_' + id;
854
+
855
+ return id;
856
+ },
857
+
858
+ /**
859
+ * Check the validity of the field
860
+ *
861
+ * @return {String}
862
+ */
863
+ validate: function() {
864
+ var error = this.editor.validate();
865
+
866
+ if (error) {
867
+ this.setError(error.message);
868
+ } else {
869
+ this.clearError();
870
+ }
871
+
872
+ return error;
873
+ },
874
+
875
+ /**
876
+ * Set the field into an error state, adding the error class and setting the error message
877
+ *
878
+ * @param {String} msg Error message
879
+ */
880
+ setError: function(msg) {
881
+ //Object and NestedModel types set their own errors internally
882
+ if (this.editor.hasNestedForm) return;
883
+
884
+ var errClass = Form.classNames.error;
885
+
886
+ this.$el.addClass(errClass);
887
+
888
+ if (this.$help) this.$help.html(msg);
889
+ },
890
+
891
+ /**
892
+ * Clear the error state and reset the help message
893
+ */
894
+ clearError: function() {
895
+ var errClass = Form.classNames.error;
896
+
897
+ this.$el.removeClass(errClass);
898
+
899
+ // some fields (e.g., Hidden), may not have a help el
900
+ if (this.$help) {
901
+ this.$help.empty();
902
+
903
+ //Reset help text if available
904
+ var helpMsg = this.schema.help;
905
+ if (helpMsg) this.$help.html(helpMsg);
906
+ }
907
+ },
908
+
909
+ /**
910
+ * Update the model with the new value from the editor
911
+ */
912
+ commit: function() {
913
+ return this.editor.commit();
914
+ },
915
+
916
+ /**
917
+ * Get the value from the editor
918
+ *
919
+ * @return {Mixed}
920
+ */
921
+ getValue: function() {
922
+ return this.editor.getValue();
923
+ },
924
+
925
+ /**
926
+ * Set/change the value of the editor
927
+ *
928
+ * @param {Mixed} value
929
+ */
930
+ setValue: function(value) {
931
+ this.editor.setValue(value);
932
+ },
933
+
934
+ focus: function() {
935
+ this.editor.focus();
936
+ },
937
+
938
+ blur: function() {
939
+ this.editor.blur();
940
+ },
941
+
942
+ /**
943
+ * Remove the field and editor views
944
+ */
945
+ remove: function() {
946
+ this.editor.remove();
947
+
948
+ Backbone.View.prototype.remove.call(this);
949
+ }
950
+
951
+ });
952
+
953
+ })();
954
+
955
+ //========================================================================
956
+ //EDITORS
957
+ //========================================================================
958
+
959
+ Form.editors = (function() {
960
+
961
+ var helpers = Form.helpers;
962
+
963
+ var editors = {};
964
+
965
+ /**
966
+ * Base editor (interface). To be extended, not used directly
967
+ *
968
+ * @param {Object} Options
969
+ * Optional:
970
+ * model {Backbone.Model} : Use instead of value, and use commit().
971
+ * key {String} : The model attribute key. Required when using 'model'
972
+ * value {String} : When not using a model. If neither provided, defaultValue will be used.
973
+ * schema {Object} : May be required by some editors
974
+ */
975
+ editors.Base = Backbone.View.extend({
976
+
977
+ defaultValue: null,
978
+
979
+ hasFocus: false,
980
+
981
+ initialize: function(options) {
982
+ var options = options || {};
983
+
984
+ if (options.model) {
985
+ if (!options.key) throw "Missing option: 'key'";
986
+
987
+ this.model = options.model;
988
+
989
+ this.value = this.model.get(options.key);
990
+ }
991
+ else if (options.value) {
992
+ this.value = options.value;
993
+ }
994
+
995
+ if (this.value === undefined) this.value = this.defaultValue;
996
+
997
+ this.key = options.key;
998
+ this.form = options.form;
999
+ this.schema = options.schema || {};
1000
+ this.validators = options.validators || this.schema.validators;
1001
+
1002
+ //Main attributes
1003
+ this.$el.attr('name', this.getName());
1004
+
1005
+ //Add custom CSS class names
1006
+ if (this.schema.editorClass) this.$el.addClass(this.schema.editorClass);
1007
+
1008
+ //Add custom attributes
1009
+ if (this.schema.editorAttrs) this.$el.attr(this.schema.editorAttrs);
1010
+ },
1011
+
1012
+ getValue: function() {
1013
+ throw 'Not implemented. Extend and override this method.';
1014
+ },
1015
+
1016
+ setValue: function() {
1017
+ throw 'Not implemented. Extend and override this method.';
1018
+ },
1019
+
1020
+ focus: function() {
1021
+ throw 'Not implemented. Extend and override this method.';
1022
+ },
1023
+
1024
+ blur: function() {
1025
+ throw 'Not implemented. Extend and override this method.';
1026
+ },
1027
+
1028
+ /**
1029
+ * Get the value for the form input 'name' attribute
1030
+ *
1031
+ * @return {String}
1032
+ *
1033
+ * @api private
1034
+ */
1035
+ getName: function() {
1036
+ var key = this.key || '';
1037
+
1038
+ //Replace periods with underscores (e.g. for when using paths)
1039
+ return key.replace(/\./g, '_')
1040
+ },
1041
+
1042
+ /**
1043
+ * Update the model with the current value
1044
+ * NOTE: The method is defined on the editors so that they can be used independently of fields
1045
+ *
1046
+ * @return {Mixed} error
1047
+ */
1048
+ commit: function() {
1049
+ var error = this.validate();
1050
+ if (error) return error;
1051
+
1052
+ this.model.set(this.key, this.getValue(), {
1053
+ error: function(model, e) {
1054
+ error = e;
1055
+ }
1056
+ });
1057
+
1058
+ if (error) return error;
1059
+ },
1060
+
1061
+ /**
1062
+ * Check validity
1063
+ * NOTE: The method is defined on the editors so that they can be used independently of fields
1064
+ *
1065
+ * @return {String}
1066
+ */
1067
+ validate: function() {
1068
+ var $el = this.$el,
1069
+ error = null,
1070
+ value = this.getValue(),
1071
+ formValues = this.form ? this.form.getValue() : {},
1072
+ validators = this.validators,
1073
+ getValidator = Form.helpers.getValidator;
1074
+
1075
+ if (validators) {
1076
+ //Run through validators until an error is found
1077
+ _.every(validators, function(validator) {
1078
+ error = getValidator(validator)(value, formValues);
1079
+
1080
+ return continueLoop = error ? false : true;
1081
+ });
1082
+ }
1083
+
1084
+ return error;
1085
+ },
1086
+
1087
+
1088
+ trigger: function(event) {
1089
+ if (event == 'focus') {
1090
+ this.hasFocus = true;
1091
+ }
1092
+ else if (event == 'blur') {
1093
+ this.hasFocus = false;
1094
+ }
1095
+
1096
+ return Backbone.View.prototype.trigger.apply(this, arguments);
1097
+ }
1098
+ });
1099
+
1100
+
1101
+ //TEXT
1102
+ editors.Text = editors.Base.extend({
1103
+
1104
+ tagName: 'input',
1105
+
1106
+ defaultValue: '',
1107
+
1108
+ previousValue: '',
1109
+
1110
+ events: {
1111
+ 'keyup': 'determineChange',
1112
+ 'keypress': function(event) {
1113
+ var self = this;
1114
+ setTimeout(function() {
1115
+ self.determineChange();
1116
+ }, 0);
1117
+ },
1118
+ 'select': function(event) {
1119
+ this.trigger('select', this);
1120
+ },
1121
+ 'focus': function(event) {
1122
+ this.trigger('focus', this);
1123
+ },
1124
+ 'blur': function(event) {
1125
+ this.trigger('blur', this);
1126
+ }
1127
+ },
1128
+
1129
+ initialize: function(options) {
1130
+ editors.Base.prototype.initialize.call(this, options);
1131
+
1132
+ var schema = this.schema;
1133
+
1134
+ //Allow customising text type (email, phone etc.) for HTML5 browsers
1135
+ var type = 'text';
1136
+
1137
+ if (schema && schema.editorAttrs && schema.editorAttrs.type) type = schema.editorAttrs.type;
1138
+ if (schema && schema.dataType) type = schema.dataType;
1139
+
1140
+ this.$el.attr('type', type);
1141
+ },
1142
+
1143
+ /**
1144
+ * Adds the editor to the DOM
1145
+ */
1146
+ render: function() {
1147
+ this.setValue(this.value);
1148
+
1149
+ return this;
1150
+ },
1151
+
1152
+ determineChange: function(event) {
1153
+ var currentValue = this.$el.val();
1154
+ var changed = (currentValue != this.previousValue);
1155
+
1156
+ if (changed) {
1157
+ this.previousValue = currentValue;
1158
+
1159
+ this.trigger('change', this);
1160
+ }
1161
+ },
1162
+
1163
+ /**
1164
+ * Returns the current editor value
1165
+ * @return {String}
1166
+ */
1167
+ getValue: function() {
1168
+ return this.$el.val();
1169
+ },
1170
+
1171
+ /**
1172
+ * Sets the value of the form element
1173
+ * @param {String}
1174
+ */
1175
+ setValue: function(value) {
1176
+ this.$el.val(value);
1177
+ },
1178
+
1179
+ focus: function() {
1180
+ if (this.hasFocus) return;
1181
+
1182
+ this.$el.focus();
1183
+ },
1184
+
1185
+ blur: function() {
1186
+ if (!this.hasFocus) return;
1187
+
1188
+ this.$el.blur();
1189
+ },
1190
+
1191
+ select: function() {
1192
+ this.$el.select();
1193
+ }
1194
+
1195
+ });
1196
+
1197
+
1198
+ /**
1199
+ * NUMBER
1200
+ * Normal text input that only allows a number. Letters etc. are not entered
1201
+ */
1202
+ editors.Number = editors.Text.extend({
1203
+
1204
+ defaultValue: 0,
1205
+
1206
+ events: _.extend({}, editors.Text.prototype.events, {
1207
+ 'keypress': 'onKeyPress'
1208
+ }),
1209
+
1210
+ initialize: function(options) {
1211
+ editors.Text.prototype.initialize.call(this, options);
1212
+
1213
+ this.$el.attr('type', 'number');
1214
+ },
1215
+
1216
+ /**
1217
+ * Check value is numeric
1218
+ */
1219
+ onKeyPress: function(event) {
1220
+ var self = this,
1221
+ delayedDetermineChange = function() {
1222
+ setTimeout(function() {
1223
+ self.determineChange();
1224
+ }, 0);
1225
+ }
1226
+
1227
+ //Allow backspace
1228
+ if (event.charCode == 0) {
1229
+ delayedDetermineChange();
1230
+ return;
1231
+ }
1232
+
1233
+ //Get the whole new value so that we can prevent things like double decimals points etc.
1234
+ var newVal = this.$el.val() + String.fromCharCode(event.charCode);
1235
+
1236
+ var numeric = /^[0-9]*\.?[0-9]*?$/.test(newVal);
1237
+
1238
+ if (numeric) {
1239
+ delayedDetermineChange();
1240
+ }
1241
+ else {
1242
+ event.preventDefault();
1243
+ }
1244
+ },
1245
+
1246
+ getValue: function() {
1247
+ var value = this.$el.val();
1248
+
1249
+ return value === "" ? null : parseFloat(value, 10);
1250
+ },
1251
+
1252
+ setValue: function(value) {
1253
+ value = (function() {
1254
+ if (_.isNumber(value)) return value;
1255
+
1256
+ if (_.isString(value) && value !== '') return parseFloat(value, 10);
1257
+
1258
+ return null;
1259
+ })();
1260
+
1261
+ if (_.isNaN(value)) value = null;
1262
+
1263
+ editors.Text.prototype.setValue.call(this, value);
1264
+ }
1265
+
1266
+ });
1267
+
1268
+
1269
+ //PASSWORD
1270
+ editors.Password = editors.Text.extend({
1271
+
1272
+ initialize: function(options) {
1273
+ editors.Text.prototype.initialize.call(this, options);
1274
+
1275
+ this.$el.attr('type', 'password');
1276
+ }
1277
+
1278
+ });
1279
+
1280
+
1281
+ //TEXTAREA
1282
+ editors.TextArea = editors.Text.extend({
1283
+
1284
+ tagName: 'textarea'
1285
+
1286
+ });
1287
+
1288
+
1289
+ //CHECKBOX
1290
+ editors.Checkbox = editors.Base.extend({
1291
+
1292
+ defaultValue: false,
1293
+
1294
+ tagName: 'input',
1295
+
1296
+ events: {
1297
+ 'click': function(event) {
1298
+ this.trigger('change', this);
1299
+ },
1300
+ 'focus': function(event) {
1301
+ this.trigger('focus', this);
1302
+ },
1303
+ 'blur': function(event) {
1304
+ this.trigger('blur', this);
1305
+ }
1306
+ },
1307
+
1308
+ initialize: function(options) {
1309
+ editors.Base.prototype.initialize.call(this, options);
1310
+
1311
+ this.$el.attr('type', 'checkbox');
1312
+ },
1313
+
1314
+ /**
1315
+ * Adds the editor to the DOM
1316
+ */
1317
+ render: function() {
1318
+ this.setValue(this.value);
1319
+
1320
+ return this;
1321
+ },
1322
+
1323
+ getValue: function() {
1324
+ return this.$el.prop('checked');
1325
+ },
1326
+
1327
+ setValue: function(value) {
1328
+ if (value) {
1329
+ this.$el.prop('checked', true);
1330
+ }
1331
+ },
1332
+
1333
+ focus: function() {
1334
+ if (this.hasFocus) return;
1335
+
1336
+ this.$el.focus();
1337
+ },
1338
+
1339
+ blur: function() {
1340
+ if (!this.hasFocus) return;
1341
+
1342
+ this.$el.blur();
1343
+ }
1344
+
1345
+ });
1346
+
1347
+
1348
+ //HIDDEN
1349
+ editors.Hidden = editors.Base.extend({
1350
+
1351
+ defaultValue: '',
1352
+
1353
+ initialize: function(options) {
1354
+ editors.Text.prototype.initialize.call(this, options);
1355
+
1356
+ this.$el.attr('type', 'hidden');
1357
+ },
1358
+
1359
+ getValue: function() {
1360
+ return this.value;
1361
+ },
1362
+
1363
+ setValue: function(value) {
1364
+ this.value = value;
1365
+ },
1366
+
1367
+ focus: function() {
1368
+
1369
+ },
1370
+
1371
+ blur: function() {
1372
+
1373
+ }
1374
+
1375
+ });
1376
+
1377
+
1378
+ /**
1379
+ * SELECT
1380
+ *
1381
+ * Renders a <select> with given options
1382
+ *
1383
+ * Requires an 'options' value on the schema.
1384
+ * Can be an array of options, a function that calls back with the array of options, a string of HTML
1385
+ * or a Backbone collection. If a collection, the models must implement a toString() method
1386
+ */
1387
+ editors.Select = editors.Base.extend({
1388
+
1389
+ tagName: 'select',
1390
+
1391
+ events: {
1392
+ 'change': function(event) {
1393
+ this.trigger('change', this);
1394
+ },
1395
+ 'focus': function(event) {
1396
+ this.trigger('focus', this);
1397
+ },
1398
+ 'blur': function(event) {
1399
+ this.trigger('blur', this);
1400
+ }
1401
+ },
1402
+
1403
+ initialize: function(options) {
1404
+ editors.Base.prototype.initialize.call(this, options);
1405
+
1406
+ if (!this.schema || !this.schema.options) throw "Missing required 'schema.options'";
1407
+ },
1408
+
1409
+ render: function() {
1410
+ this.setOptions(this.schema.options);
1411
+
1412
+ return this;
1413
+ },
1414
+
1415
+ /**
1416
+ * Sets the options that populate the <select>
1417
+ *
1418
+ * @param {Mixed} options
1419
+ */
1420
+ setOptions: function(options) {
1421
+ var self = this;
1422
+
1423
+ //If a collection was passed, check if it needs fetching
1424
+ if (options instanceof Backbone.Collection) {
1425
+ var collection = options;
1426
+
1427
+ //Don't do the fetch if it's already populated
1428
+ if (collection.length > 0) {
1429
+ this.renderOptions(options);
1430
+ } else {
1431
+ collection.fetch({
1432
+ success: function(collection) {
1433
+ self.renderOptions(options);
1434
+ }
1435
+ });
1436
+ }
1437
+ }
1438
+
1439
+ //If a function was passed, run it to get the options
1440
+ else if (_.isFunction(options)) {
1441
+ options(function(result) {
1442
+ self.renderOptions(result);
1443
+ });
1444
+ }
1445
+
1446
+ //Otherwise, ready to go straight to renderOptions
1447
+ else {
1448
+ this.renderOptions(options);
1449
+ }
1450
+ },
1451
+
1452
+ /**
1453
+ * Adds the <option> html to the DOM
1454
+ * @param {Mixed} Options as a simple array e.g. ['option1', 'option2']
1455
+ * or as an array of objects e.g. [{val: 543, label: 'Title for object 543'}]
1456
+ * or as a string of <option> HTML to insert into the <select>
1457
+ */
1458
+ renderOptions: function(options) {
1459
+ var $select = this.$el,
1460
+ html;
1461
+
1462
+ //Accept string of HTML
1463
+ if (_.isString(options)) {
1464
+ html = options;
1465
+ }
1466
+
1467
+ //Or array
1468
+ else if (_.isArray(options)) {
1469
+ html = this._arrayToHtml(options);
1470
+ }
1471
+
1472
+ //Or Backbone collection
1473
+ else if (options instanceof Backbone.Collection) {
1474
+ html = this._collectionToHtml(options)
1475
+ }
1476
+
1477
+ //Insert options
1478
+ $select.html(html);
1479
+
1480
+ //Select correct option
1481
+ this.setValue(this.value);
1482
+ },
1483
+
1484
+ getValue: function() {
1485
+ return this.$el.val();
1486
+ },
1487
+
1488
+ setValue: function(value) {
1489
+ this.$el.val(value);
1490
+ },
1491
+
1492
+ focus: function() {
1493
+ if (this.hasFocus) return;
1494
+
1495
+ this.$el.focus();
1496
+ },
1497
+
1498
+ blur: function() {
1499
+ if (!this.hasFocus) return;
1500
+
1501
+ this.$el.blur();
1502
+ },
1503
+
1504
+ /**
1505
+ * Transforms a collection into HTML ready to use in the renderOptions method
1506
+ * @param {Backbone.Collection}
1507
+ * @return {String}
1508
+ */
1509
+ _collectionToHtml: function(collection) {
1510
+ //Convert collection to array first
1511
+ var array = [];
1512
+ collection.each(function(model) {
1513
+ array.push({ val: model.id, label: model.toString() });
1514
+ });
1515
+
1516
+ //Now convert to HTML
1517
+ var html = this._arrayToHtml(array);
1518
+
1519
+ return html;
1520
+ },
1521
+
1522
+ /**
1523
+ * Create the <option> HTML
1524
+ * @param {Array} Options as a simple array e.g. ['option1', 'option2']
1525
+ * or as an array of objects e.g. [{val: 543, label: 'Title for object 543'}]
1526
+ * @return {String} HTML
1527
+ */
1528
+ _arrayToHtml: function(array) {
1529
+ var html = [];
1530
+
1531
+ //Generate HTML
1532
+ _.each(array, function(option) {
1533
+ if (_.isObject(option)) {
1534
+ var val = option.val ? option.val : '';
1535
+ html.push('<option value="'+val+'">'+option.label+'</option>');
1536
+ }
1537
+ else {
1538
+ html.push('<option>'+option+'</option>');
1539
+ }
1540
+ });
1541
+
1542
+ return html.join('');
1543
+ }
1544
+
1545
+ });
1546
+
1547
+
1548
+
1549
+ /**
1550
+ * RADIO
1551
+ *
1552
+ * Renders a <ul> with given options represented as <li> objects containing radio buttons
1553
+ *
1554
+ * Requires an 'options' value on the schema.
1555
+ * Can be an array of options, a function that calls back with the array of options, a string of HTML
1556
+ * or a Backbone collection. If a collection, the models must implement a toString() method
1557
+ */
1558
+ editors.Radio = editors.Select.extend({
1559
+
1560
+ tagName: 'ul',
1561
+ className: 'bbf-radio',
1562
+
1563
+ events: {
1564
+ 'click input[type=radio]:not(:checked)': function() {
1565
+ this.trigger('change', this)
1566
+ },
1567
+ 'focus input[type=radio]': function() {
1568
+ if (this.hasFocus) return;
1569
+ this.trigger('focus', this);
1570
+ },
1571
+ 'blur input[type=radio]': function() {
1572
+ if (!this.hasFocus) return;
1573
+ var self = this;
1574
+ setTimeout(function() {
1575
+ if (self.$('input[type=radio]:focus')[0]) return;
1576
+ self.trigger('blur', self);
1577
+ }, 0);
1578
+ }
1579
+ },
1580
+
1581
+ getValue: function() {
1582
+ return this.$('input[type=radio]:checked').val();
1583
+ },
1584
+
1585
+ setValue: function(value) {
1586
+ this.$('input[type=radio]').val([value]);
1587
+ },
1588
+
1589
+ focus: function() {
1590
+ if (this.hasFocus) return;
1591
+
1592
+ var checked = this.$('input[type=radio]:checked');
1593
+ if (checked[0]) {
1594
+ checked.focus();
1595
+ return;
1596
+ }
1597
+
1598
+ this.$('input[type=radio]').first().focus();
1599
+ },
1600
+
1601
+ blur: function() {
1602
+ if (!this.hasFocus) return;
1603
+
1604
+ this.$('input[type=radio]:focus').blur();
1605
+ },
1606
+
1607
+ /**
1608
+ * Create the radio list HTML
1609
+ * @param {Array} Options as a simple array e.g. ['option1', 'option2']
1610
+ * or as an array of objects e.g. [{val: 543, label: 'Title for object 543'}]
1611
+ * @return {String} HTML
1612
+ */
1613
+ _arrayToHtml: function (array) {
1614
+ var html = [];
1615
+ var self = this;
1616
+
1617
+ _.each(array, function(option, index) {
1618
+ var itemHtml = '<li>';
1619
+ if (_.isObject(option)) {
1620
+ var val = option.val ? option.val : '';
1621
+ itemHtml += ('<input type="radio" name="'+self.id+'" value="'+val+'" id="'+self.id+'-'+index+'" />')
1622
+ itemHtml += ('<label for="'+self.id+'-'+index+'">'+option.label+'</label>')
1623
+ }
1624
+ else {
1625
+ itemHtml += ('<input type="radio" name="'+self.id+'" value="'+option+'" id="'+self.id+'-'+index+'" />')
1626
+ itemHtml += ('<label for="'+self.id+'-'+index+'">'+option+'</label>')
1627
+ }
1628
+ itemHtml += '</li>';
1629
+ html.push(itemHtml);
1630
+ });
1631
+
1632
+ return html.join('');
1633
+ }
1634
+
1635
+ });
1636
+
1637
+
1638
+
1639
+ /**
1640
+ * CHECKBOXES
1641
+ * Renders a <ul> with given options represented as <li> objects containing checkboxes
1642
+ *
1643
+ * Requires an 'options' value on the schema.
1644
+ * Can be an array of options, a function that calls back with the array of options, a string of HTML
1645
+ * or a Backbone collection. If a collection, the models must implement a toString() method
1646
+ */
1647
+ editors.Checkboxes = editors.Select.extend({
1648
+
1649
+ tagName: 'ul',
1650
+ className: 'bbf-checkboxes',
1651
+
1652
+ events: {
1653
+ 'click input[type=checkbox]': function() {
1654
+ this.trigger('change', this)
1655
+ },
1656
+ 'focus input[type=checkbox]': function() {
1657
+ if (this.hasFocus) return;
1658
+ this.trigger('focus', this);
1659
+ },
1660
+ 'blur input[type=checkbox]': function() {
1661
+ if (!this.hasFocus) return;
1662
+ var self = this;
1663
+ setTimeout(function() {
1664
+ if (self.$('input[type=checkbox]:focus')[0]) return;
1665
+ self.trigger('blur', self);
1666
+ }, 0);
1667
+ }
1668
+ },
1669
+
1670
+ getValue: function() {
1671
+ var values = [];
1672
+ this.$('input[type=checkbox]:checked').each(function() {
1673
+ values.push($(this).val());
1674
+ });
1675
+ return values;
1676
+ },
1677
+
1678
+ setValue: function(values) {
1679
+ if (!_.isArray(values)) values = [values];
1680
+ this.$('input[type=checkbox]').val(values);
1681
+ },
1682
+
1683
+ focus: function() {
1684
+ if (this.hasFocus) return;
1685
+
1686
+ this.$('input[type=checkbox]').first().focus();
1687
+ },
1688
+
1689
+ blur: function() {
1690
+ if (!this.hasFocus) return;
1691
+
1692
+ this.$('input[type=checkbox]:focus').blur();
1693
+ },
1694
+
1695
+ /**
1696
+ * Create the checkbox list HTML
1697
+ * @param {Array} Options as a simple array e.g. ['option1', 'option2']
1698
+ * or as an array of objects e.g. [{val: 543, label: 'Title for object 543'}]
1699
+ * @return {String} HTML
1700
+ */
1701
+ _arrayToHtml: function (array) {
1702
+ var html = [];
1703
+ var self = this;
1704
+
1705
+ _.each(array, function(option, index) {
1706
+ var itemHtml = '<li>';
1707
+ if (_.isObject(option)) {
1708
+ var val = option.val ? option.val : '';
1709
+ itemHtml += ('<input type="checkbox" name="'+self.id+'" value="'+val+'" id="'+self.id+'-'+index+'" />')
1710
+ itemHtml += ('<label for="'+self.id+'-'+index+'">'+option.label+'</label>')
1711
+ }
1712
+ else {
1713
+ itemHtml += ('<input type="checkbox" name="'+self.id+'" value="'+option+'" id="'+self.id+'-'+index+'" />')
1714
+ itemHtml += ('<label for="'+self.id+'-'+index+'">'+option+'</label>')
1715
+ }
1716
+ itemHtml += '</li>';
1717
+ html.push(itemHtml);
1718
+ });
1719
+
1720
+ return html.join('');
1721
+ }
1722
+
1723
+ });
1724
+
1725
+
1726
+
1727
+ /**
1728
+ * OBJECT
1729
+ *
1730
+ * Creates a child form. For editing Javascript objects
1731
+ *
1732
+ * @param {Object} options
1733
+ * @param {Object} options.schema The schema for the object
1734
+ * @param {Object} options.schema.subSchema The schema for the nested form
1735
+ */
1736
+ editors.Object = editors.Base.extend({
1737
+ //Prevent error classes being set on the main control; they are internally on the individual fields
1738
+ hasNestedForm: true,
1739
+
1740
+ className: 'bbf-object',
1741
+
1742
+ initialize: function(options) {
1743
+ //Set default value for the instance so it's not a shared object
1744
+ this.value = {};
1745
+
1746
+ //Init
1747
+ editors.Base.prototype.initialize.call(this, options);
1748
+
1749
+ //Check required options
1750
+ if (!this.schema.subSchema) throw new Error("Missing required 'schema.subSchema' option for Object editor");
1751
+ },
1752
+
1753
+ render: function() {
1754
+ //Create the nested form
1755
+ this.form = new Form({
1756
+ schema: this.schema.subSchema,
1757
+ data: this.value,
1758
+ idPrefix: this.id + '_',
1759
+ fieldTemplate: 'nestedField'
1760
+ });
1761
+
1762
+ this._observeFormEvents();
1763
+
1764
+ this.$el.html(this.form.render().el);
1765
+
1766
+ if (this.hasFocus) this.trigger('blur', this);
1767
+
1768
+ return this;
1769
+ },
1770
+
1771
+ getValue: function() {
1772
+ if (this.form) return this.form.getValue();
1773
+
1774
+ return this.value;
1775
+ },
1776
+
1777
+ setValue: function(value) {
1778
+ this.value = value;
1779
+
1780
+ this.render();
1781
+ },
1782
+
1783
+ focus: function() {
1784
+ if (this.hasFocus) return;
1785
+
1786
+ this.form.focus();
1787
+ },
1788
+
1789
+ blur: function() {
1790
+ if (!this.hasFocus) return;
1791
+
1792
+ this.form.blur();
1793
+ },
1794
+
1795
+ remove: function() {
1796
+ this.form.remove();
1797
+
1798
+ Backbone.View.prototype.remove.call(this);
1799
+ },
1800
+
1801
+ validate: function() {
1802
+ return this.form.validate();
1803
+ },
1804
+
1805
+ _observeFormEvents: function() {
1806
+ this.form.on('all', function() {
1807
+ // args = ["key:change", form, fieldEditor]
1808
+ args = _.toArray(arguments);
1809
+ args[1] = this;
1810
+ // args = ["key:change", this=objectEditor, fieldEditor]
1811
+
1812
+ this.trigger.apply(this, args)
1813
+ }, this);
1814
+ }
1815
+
1816
+ });
1817
+
1818
+
1819
+
1820
+ /**
1821
+ * NESTED MODEL
1822
+ *
1823
+ * Creates a child form. For editing nested Backbone models
1824
+ *
1825
+ * Special options:
1826
+ * schema.model: Embedded model constructor
1827
+ */
1828
+ editors.NestedModel = editors.Object.extend({
1829
+ initialize: function(options) {
1830
+ editors.Base.prototype.initialize.call(this, options);
1831
+
1832
+ if (!options.schema.model)
1833
+ throw 'Missing required "schema.model" option for NestedModel editor';
1834
+ },
1835
+
1836
+ render: function() {
1837
+ var data = this.value || {},
1838
+ key = this.key,
1839
+ nestedModel = this.schema.model;
1840
+
1841
+ //Wrap the data in a model if it isn't already a model instance
1842
+ var modelInstance = (data.constructor == nestedModel)
1843
+ ? data
1844
+ : new nestedModel(data);
1845
+
1846
+ this.form = new Form({
1847
+ model: modelInstance,
1848
+ idPrefix: this.id + '_',
1849
+ fieldTemplate: 'nestedField'
1850
+ });
1851
+
1852
+ this._observeFormEvents();
1853
+
1854
+ //Render form
1855
+ this.$el.html(this.form.render().el);
1856
+
1857
+ if (this.hasFocus) this.trigger('blur', this);
1858
+
1859
+ return this;
1860
+ },
1861
+
1862
+ /**
1863
+ * Update the embedded model, checking for nested validation errors and pass them up
1864
+ * Then update the main model if all OK
1865
+ *
1866
+ * @return {Error|null} Validation error or null
1867
+ */
1868
+ commit: function() {
1869
+ var error = this.form.commit();
1870
+ if (error) {
1871
+ this.$el.addClass('error');
1872
+ return error;
1873
+ }
1874
+
1875
+ return editors.Object.prototype.commit.call(this);
1876
+ }
1877
+
1878
+ });
1879
+
1880
+
1881
+
1882
+ /**
1883
+ * DATE
1884
+ *
1885
+ * Schema options
1886
+ * @param {Number|String} [options.schema.yearStart] First year in list. Default: 100 years ago
1887
+ * @param {Number|String} [options.schema.yearEnd] Last year in list. Default: current year
1888
+ *
1889
+ * Config options (if not set, defaults to options stored on the main Date class)
1890
+ * @param {Boolean} [options.showMonthNames] Use month names instead of numbers. Default: true
1891
+ * @param {String[]} [options.monthNames] Month names. Default: Full English names
1892
+ */
1893
+ editors.Date = editors.Base.extend({
1894
+
1895
+ events: {
1896
+ 'change select': function() {
1897
+ this.updateHidden();
1898
+ this.trigger('change', this);
1899
+ },
1900
+ 'focus select': function() {
1901
+ if (this.hasFocus) return;
1902
+ this.trigger('focus', this);
1903
+ },
1904
+ 'blur select': function() {
1905
+ if (!this.hasFocus) return;
1906
+ var self = this;
1907
+ setTimeout(function() {
1908
+ if (self.$('select:focus')[0]) return;
1909
+ self.trigger('blur', self);
1910
+ }, 0);
1911
+ }
1912
+ },
1913
+
1914
+ initialize: function(options) {
1915
+ options = options || {}
1916
+
1917
+ editors.Base.prototype.initialize.call(this, options);
1918
+
1919
+ var Self = editors.Date,
1920
+ today = new Date;
1921
+
1922
+ //Option defaults
1923
+ this.options = _.extend({
1924
+ monthNames: Self.monthNames,
1925
+ showMonthNames: Self.showMonthNames
1926
+ }, options);
1927
+
1928
+ //Schema defaults
1929
+ this.schema = _.extend({
1930
+ yearStart: today.getFullYear() - 100,
1931
+ yearEnd: today.getFullYear()
1932
+ }, options.schema || {});
1933
+
1934
+ //Cast to Date
1935
+ if (this.value && !_.isDate(this.value)) {
1936
+ this.value = new Date(this.value);
1937
+ }
1938
+
1939
+ //Set default date
1940
+ if (!this.value) {
1941
+ var date = new Date();
1942
+ date.setSeconds(0);
1943
+ date.setMilliseconds(0);
1944
+
1945
+ this.value = date;
1946
+ }
1947
+ },
1948
+
1949
+ render: function() {
1950
+ var options = this.options,
1951
+ schema = this.schema;
1952
+
1953
+ var datesOptions = _.map(_.range(1, 32), function(date) {
1954
+ return '<option value="'+date+'">' + date + '</option>';
1955
+ });
1956
+
1957
+ var monthsOptions = _.map(_.range(0, 12), function(month) {
1958
+ var value = options.showMonthNames ? options.monthNames[month] : (month + 1);
1959
+ return '<option value="'+month+'">' + value + '</option>';
1960
+ });
1961
+
1962
+ var yearsOptions = _.map(_.range(schema.yearStart, schema.yearEnd + 1), function(year) {
1963
+ return '<option value="'+year+'">' + year + '</option>';
1964
+ });
1965
+
1966
+ //Render the selects
1967
+ var $el = $(Form.templates.date({
1968
+ dates: datesOptions.join(''),
1969
+ months: monthsOptions.join(''),
1970
+ years: yearsOptions.join('')
1971
+ }));
1972
+
1973
+ //Store references to selects
1974
+ this.$date = $el.find('select[data-type="date"]');
1975
+ this.$month = $el.find('select[data-type="month"]');
1976
+ this.$year = $el.find('select[data-type="year"]');
1977
+
1978
+ //Create the hidden field to store values in case POSTed to server
1979
+ this.$hidden = $('<input type="hidden" name="'+this.key+'" />');
1980
+ $el.append(this.$hidden);
1981
+
1982
+ //Set value on this and hidden field
1983
+ this.setValue(this.value);
1984
+
1985
+ //Remove the wrapper tag
1986
+ this.setElement($el);
1987
+ this.$el.attr('id', this.id);
1988
+
1989
+ if (this.hasFocus) this.trigger('blur', this);
1990
+
1991
+ return this;
1992
+ },
1993
+
1994
+ /**
1995
+ * @return {Date} Selected date
1996
+ */
1997
+ getValue: function() {
1998
+ var year = this.$year.val(),
1999
+ month = this.$month.val(),
2000
+ date = this.$date.val();
2001
+
2002
+ if (!year || !month || !date) return null;
2003
+
2004
+ return new Date(year, month, date);
2005
+ },
2006
+
2007
+ /**
2008
+ * @param {Date} date
2009
+ */
2010
+ setValue: function(date) {
2011
+ this.$date.val(date.getDate());
2012
+ this.$month.val(date.getMonth());
2013
+ this.$year.val(date.getFullYear());
2014
+
2015
+ this.updateHidden();
2016
+ },
2017
+
2018
+ focus: function() {
2019
+ if (this.hasFocus) return;
2020
+
2021
+ this.$('select').first().focus();
2022
+ },
2023
+
2024
+ blur: function() {
2025
+ if (!this.hasFocus) return;
2026
+
2027
+ this.$('select:focus').blur();
2028
+ },
2029
+
2030
+ /**
2031
+ * Update the hidden input which is maintained for when submitting a form
2032
+ * via a normal browser POST
2033
+ */
2034
+ updateHidden: function() {
2035
+ var val = this.getValue();
2036
+ if (_.isDate(val)) val = val.toISOString();
2037
+
2038
+ this.$hidden.val(val);
2039
+ }
2040
+
2041
+ }, {
2042
+ //STATICS
2043
+
2044
+ //Whether to show month names instead of numbers
2045
+ showMonthNames: true,
2046
+
2047
+ //Month names to use if showMonthNames is true
2048
+ //Replace for localisation, e.g. Form.editors.Date.monthNames = ['Janvier', 'Fevrier'...]
2049
+ monthNames: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
2050
+ });
2051
+
2052
+
2053
+ /**
2054
+ * DATETIME
2055
+ *
2056
+ * @param {Editor} [options.DateEditor] Date editor view to use (not definition)
2057
+ * @param {Number} [options.schema.minsInterval] Interval between minutes. Default: 15
2058
+ */
2059
+ editors.DateTime = editors.Base.extend({
2060
+
2061
+ events: {
2062
+ 'change select': function() {
2063
+ this.updateHidden();
2064
+ this.trigger('change', this);
2065
+ },
2066
+ 'focus select': function() {
2067
+ if (this.hasFocus) return;
2068
+ this.trigger('focus', this);
2069
+ },
2070
+ 'blur select': function() {
2071
+ if (!this.hasFocus) return;
2072
+ var self = this;
2073
+ setTimeout(function() {
2074
+ if (self.$('select:focus')[0]) return;
2075
+ self.trigger('blur', self);
2076
+ }, 0);
2077
+ }
2078
+ },
2079
+
2080
+ initialize: function(options) {
2081
+ options = options || {};
2082
+
2083
+ editors.Base.prototype.initialize.call(this, options);
2084
+
2085
+ //Option defaults
2086
+ this.options = _.extend({
2087
+ DateEditor: editors.DateTime.DateEditor
2088
+ }, options);
2089
+
2090
+ //Schema defaults
2091
+ this.schema = _.extend({
2092
+ minsInterval: 15
2093
+ }, options.schema || {});
2094
+
2095
+ //Create embedded date editor
2096
+ this.dateEditor = new this.options.DateEditor(options);
2097
+
2098
+ this.value = this.dateEditor.value;
2099
+ },
2100
+
2101
+ render: function() {
2102
+ function pad(n) {
2103
+ return n < 10 ? '0' + n : n
2104
+ }
2105
+
2106
+ var schema = this.schema;
2107
+
2108
+ //Create options
2109
+ var hoursOptions = _.map(_.range(0, 24), function(hour) {
2110
+ return '<option value="'+hour+'">' + pad(hour) + '</option>';
2111
+ });
2112
+
2113
+ var minsOptions = _.map(_.range(0, 60, schema.minsInterval), function(min) {
2114
+ return '<option value="'+min+'">' + pad(min) + '</option>';
2115
+ });
2116
+
2117
+ //Render time selects
2118
+ var $el = $(Form.templates.dateTime({
2119
+ date: '<b class="bbf-tmp"></b>',
2120
+ hours: hoursOptions.join(),
2121
+ mins: minsOptions.join()
2122
+ }));
2123
+
2124
+ //Include the date editor
2125
+ $el.find('.bbf-tmp').replaceWith(this.dateEditor.render().el);
2126
+
2127
+ //Store references to selects
2128
+ this.$hour = $el.find('select[data-type="hour"]');
2129
+ this.$min = $el.find('select[data-type="min"]');
2130
+
2131
+ //Get the hidden date field to store values in case POSTed to server
2132
+ this.$hidden = $el.find('input[type="hidden"]');
2133
+
2134
+ //Set time
2135
+ this.setValue(this.value);
2136
+
2137
+ this.setElement($el);
2138
+ this.$el.attr('id', this.id);
2139
+
2140
+ if (this.hasFocus) this.trigger('blur', this);
2141
+
2142
+ return this;
2143
+ },
2144
+
2145
+ /**
2146
+ * @return {Date} Selected datetime
2147
+ */
2148
+ getValue: function() {
2149
+ var date = this.dateEditor.getValue();
2150
+
2151
+ var hour = this.$hour.val(),
2152
+ min = this.$min.val();
2153
+
2154
+ if (!date || !hour || !min) return null;
2155
+
2156
+ date.setHours(hour);
2157
+ date.setMinutes(min);
2158
+
2159
+ return date;
2160
+ },
2161
+
2162
+ setValue: function(date) {
2163
+ if (!_.isDate(date)) date = new Date(date);
2164
+
2165
+ this.dateEditor.setValue(date);
2166
+
2167
+ this.$hour.val(date.getHours());
2168
+ this.$min.val(date.getMinutes());
2169
+
2170
+ this.updateHidden();
2171
+ },
2172
+
2173
+ focus: function() {
2174
+ if (this.hasFocus) return;
2175
+
2176
+ this.$('select').first().focus();
2177
+ },
2178
+
2179
+ blur: function() {
2180
+ if (!this.hasFocus) return;
2181
+
2182
+ this.$('select:focus').blur();
2183
+ },
2184
+
2185
+ /**
2186
+ * Update the hidden input which is maintained for when submitting a form
2187
+ * via a normal browser POST
2188
+ */
2189
+ updateHidden: function() {
2190
+ var val = this.getValue();
2191
+ if (_.isDate(val)) val = val.toISOString();
2192
+
2193
+ this.$hidden.val(val);
2194
+ },
2195
+
2196
+ /**
2197
+ * Remove the Date editor before removing self
2198
+ */
2199
+ remove: function() {
2200
+ this.dateEditor.remove();
2201
+
2202
+ editors.Base.prototype.remove.call(this);
2203
+ }
2204
+
2205
+ }, {
2206
+ //STATICS
2207
+
2208
+ //The date editor to use (constructor function, not instance)
2209
+ DateEditor: editors.Date
2210
+ });
2211
+
2212
+ return editors;
2213
+
2214
+ })();
2215
+
2216
+
2217
+ //SETUP
2218
+
2219
+ //Add function shortcuts
2220
+ Form.setTemplates = Form.helpers.setTemplates;
2221
+ Form.setTemplateCompiler = Form.helpers.setTemplateCompiler;
2222
+
2223
+ Form.templates = {};
2224
+
2225
+
2226
+ //DEFAULT TEMPLATES
2227
+ Form.setTemplates({
2228
+
2229
+ //HTML
2230
+ form: '\
2231
+ <form class="bbf-form">{{fieldsets}}</form>\
2232
+ ',
2233
+
2234
+ fieldset: '\
2235
+ <fieldset>\
2236
+ <legend>{{legend}}</legend>\
2237
+ <ul>{{fields}}</ul>\
2238
+ </fieldset>\
2239
+ ',
2240
+
2241
+ field: '\
2242
+ <li class="bbf-field field-{{key}}">\
2243
+ <label for="{{id}}">{{title}}</label>\
2244
+ <div class="bbf-editor">{{editor}}</div>\
2245
+ <div class="bbf-help">{{help}}</div>\
2246
+ </li>\
2247
+ ',
2248
+
2249
+ nestedField: '\
2250
+ <li class="bbf-field bbf-nested-field field-{{key}}" title="{{title}}">\
2251
+ <label for="{{id}}">{{title}}</label>\
2252
+ <div class="bbf-editor">{{editor}}</div>\
2253
+ <div class="bbf-help">{{help}}</div>\
2254
+ </li>\
2255
+ ',
2256
+
2257
+ list: '\
2258
+ <div class="bbf-list">\
2259
+ <ul>{{items}}</ul>\
2260
+ <div class="bbf-actions"><button type="button" data-action="add">Add</div>\
2261
+ </div>\
2262
+ ',
2263
+
2264
+ listItem: '\
2265
+ <li>\
2266
+ <button type="button" data-action="remove" class="bbf-remove">&times;</button>\
2267
+ <div class="bbf-editor-container">{{editor}}</div>\
2268
+ </li>\
2269
+ ',
2270
+
2271
+ date: '\
2272
+ <div class="bbf-date">\
2273
+ <select data-type="date" class="bbf-date">{{dates}}</select>\
2274
+ <select data-type="month" class="bbf-month">{{months}}</select>\
2275
+ <select data-type="year" class="bbf-year">{{years}}</select>\
2276
+ </div>\
2277
+ ',
2278
+
2279
+ dateTime: '\
2280
+ <div class="bbf-datetime">\
2281
+ <div class="bbf-date-container">{{date}}</div>\
2282
+ <select data-type="hour">{{hours}}</select>\
2283
+ :\
2284
+ <select data-type="min">{{mins}}</select>\
2285
+ </div>\
2286
+ ',
2287
+
2288
+ 'list.Modal': '\
2289
+ <div class="bbf-list-modal">\
2290
+ {{summary}}\
2291
+ </div>\
2292
+ '
2293
+ }, {
2294
+
2295
+ //CLASSNAMES
2296
+ error: 'bbf-error'
2297
+
2298
+ });
2299
+
2300
+
2301
+
2302
+ //Metadata
2303
+ Form.VERSION = '0.10.1';
2304
+
2305
+
2306
+ //Exports
2307
+ Backbone.Form = Form;
2308
+
2309
+ })(this);