rails-backbone-forms 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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);