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,575 @@
1
+ ;(function() {
2
+
3
+ var Form = Backbone.Form,
4
+ editors = Form.editors;
5
+
6
+ /**
7
+ * LIST
8
+ *
9
+ * An array editor. Creates a list of other editor items.
10
+ *
11
+ * Special options:
12
+ * @param {String} [options.schema.itemType] The editor type for each item in the list. Default: 'Text'
13
+ * @param {String} [options.schema.confirmDelete] Text to display in a delete confirmation dialog. If falsey, will not ask for confirmation.
14
+ */
15
+ editors.List = editors.Base.extend({
16
+
17
+ events: {
18
+ 'click [data-action="add"]': function(event) {
19
+ event.preventDefault();
20
+ this.addItem(null, true);
21
+ }
22
+ },
23
+
24
+ initialize: function(options) {
25
+ editors.Base.prototype.initialize.call(this, options);
26
+
27
+ var schema = this.schema;
28
+ if (!schema) throw "Missing required option 'schema'";
29
+
30
+ //List schema defaults
31
+ this.schema = _.extend({
32
+ listTemplate: 'list',
33
+ listItemTemplate: 'listItem'
34
+ }, schema);
35
+
36
+ //Determine the editor to use
37
+ this.Editor = (function() {
38
+ var type = schema.itemType;
39
+
40
+ //Default to Text
41
+ if (!type) return editors.Text;
42
+
43
+ //Use List-specific version if available
44
+ if (editors.List[type]) return editors.List[type];
45
+
46
+ //Or whichever was passed
47
+ return editors[type];
48
+ })();
49
+
50
+ this.items = [];
51
+ },
52
+
53
+ render: function() {
54
+ var self = this,
55
+ value = this.value || [];
56
+
57
+ //Create main element
58
+ var $el = $(Form.templates[this.schema.listTemplate]({
59
+ items: '<b class="bbf-tmp"></b>'
60
+ }));
61
+
62
+ //Store a reference to the list (item container)
63
+ this.$list = $el.find('.bbf-tmp').parent().empty();
64
+
65
+ //Add existing items
66
+ if (value.length) {
67
+ _.each(value, function(itemValue) {
68
+ self.addItem(itemValue);
69
+ });
70
+ }
71
+
72
+ //If no existing items create an empty one, unless the editor specifies otherwise
73
+ else {
74
+ if (!this.Editor.isAsync) this.addItem();
75
+ }
76
+
77
+ this.setElement($el);
78
+ this.$el.attr('id', this.id);
79
+ this.$el.attr('name', this.key);
80
+
81
+ if (this.hasFocus) this.trigger('blur', this);
82
+
83
+ return this;
84
+ },
85
+
86
+ /**
87
+ * Add a new item to the list
88
+ * @param {Mixed} [value] Value for the new item editor
89
+ * @param {Boolean} [userInitiated] If the item was added by the user clicking 'add'
90
+ */
91
+ addItem: function(value, userInitiated) {
92
+ var self = this;
93
+
94
+ //Create the item
95
+ var item = new editors.List.Item({
96
+ list: this,
97
+ schema: this.schema,
98
+ value: value,
99
+ Editor: this.Editor,
100
+ key: this.key
101
+ }).render();
102
+
103
+ var _addItem = function() {
104
+ self.items.push(item);
105
+ self.$list.append(item.el);
106
+
107
+ item.editor.on('all', function(event) {
108
+ if (event == 'change') return;
109
+
110
+ // args = ["key:change", itemEditor, fieldEditor]
111
+ args = _.toArray(arguments);
112
+ args[0] = 'item:' + event;
113
+ args.splice(1, 0, self);
114
+ // args = ["item:key:change", this=listEditor, itemEditor, fieldEditor]
115
+
116
+ editors.List.prototype.trigger.apply(this, args);
117
+ }, self);
118
+
119
+ item.editor.on('change', function() {
120
+ if (!item.addEventTriggered) {
121
+ item.addEventTriggered = true;
122
+ this.trigger('add', this, item.editor);
123
+ }
124
+ this.trigger('item:change', this, item.editor);
125
+ this.trigger('change', this);
126
+ }, self);
127
+
128
+ item.editor.on('focus', function() {
129
+ if (this.hasFocus) return;
130
+ this.trigger('focus', this);
131
+ }, self);
132
+ item.editor.on('blur', function() {
133
+ if (!this.hasFocus) return;
134
+ var self = this;
135
+ setTimeout(function() {
136
+ if (_.find(self.items, function(item) { return item.editor.hasFocus; })) return;
137
+ self.trigger('blur', self);
138
+ }, 0);
139
+ }, self);
140
+
141
+ if (userInitiated || value) {
142
+ item.addEventTriggered = true;
143
+ }
144
+
145
+ if (userInitiated) {
146
+ self.trigger('add', self, item.editor);
147
+ self.trigger('change', self);
148
+ }
149
+ };
150
+
151
+ //Check if we need to wait for the item to complete before adding to the list
152
+ if (this.Editor.isAsync) {
153
+ item.editor.on('readyToAdd', _addItem, this);
154
+ }
155
+
156
+ //Most editors can be added automatically
157
+ else {
158
+ _addItem();
159
+ }
160
+
161
+ return item;
162
+ },
163
+
164
+ /**
165
+ * Remove an item from the list
166
+ * @param {List.Item} item
167
+ */
168
+ removeItem: function(item) {
169
+ //Confirm delete
170
+ var confirmMsg = this.schema.confirmDelete;
171
+ if (confirmMsg && !confirm(confirmMsg)) return;
172
+
173
+ var index = _.indexOf(this.items, item);
174
+
175
+ this.items[index].remove();
176
+ this.items.splice(index, 1);
177
+
178
+ if (item.addEventTriggered) {
179
+ this.trigger('remove', this, item.editor);
180
+ this.trigger('change', this);
181
+ }
182
+
183
+ if (!this.items.length && !this.Editor.isAsync) this.addItem();
184
+ },
185
+
186
+ getValue: function() {
187
+ var values = _.map(this.items, function(item) {
188
+ return item.getValue();
189
+ });
190
+
191
+ //Filter empty items
192
+ return _.without(values, undefined, '');
193
+ },
194
+
195
+ setValue: function(value) {
196
+ this.value = value;
197
+ this.render();
198
+ },
199
+
200
+ focus: function() {
201
+ if (this.hasFocus) return;
202
+
203
+ if (this.items[0]) this.items[0].editor.focus();
204
+ },
205
+
206
+ blur: function() {
207
+ if (!this.hasFocus) return;
208
+
209
+ focusedItem = _.find(this.items, function(item) { return item.editor.hasFocus; });
210
+
211
+ if (focusedItem) focusedItem.editor.blur();
212
+ },
213
+
214
+ /**
215
+ * Override default remove function in order to remove item views
216
+ */
217
+ remove: function() {
218
+ _.invoke(this.items, 'remove');
219
+
220
+ editors.Base.prototype.remove.call(this);
221
+ },
222
+
223
+ /**
224
+ * Run validation
225
+ *
226
+ * @return {Object|Null}
227
+ */
228
+ validate: function() {
229
+ if (!this.validators) return null;
230
+
231
+ //Collect errors
232
+ var errors = _.map(this.items, function(item) {
233
+ return item.validate();
234
+ });
235
+
236
+ //Check if any item has errors
237
+ var hasErrors = _.compact(errors).length ? true : false;
238
+ if (!hasErrors) return null;
239
+
240
+ //If so create a shared error
241
+ var fieldError = {
242
+ type: 'list',
243
+ message: 'Some of the items in the list failed validation',
244
+ errors: errors
245
+ };
246
+
247
+ return fieldError;
248
+ }
249
+ });
250
+
251
+
252
+ /**
253
+ * A single item in the list
254
+ *
255
+ * @param {editors.List} options.list The List editor instance this item belongs to
256
+ * @param {Function} options.Editor Editor constructor function
257
+ * @param {String} options.key Model key
258
+ * @param {Mixed} options.value Value
259
+ * @param {Object} options.schema Field schema
260
+ */
261
+ editors.List.Item = Backbone.View.extend({
262
+ events: {
263
+ 'click [data-action="remove"]': function(event) {
264
+ event.preventDefault();
265
+ this.list.removeItem(this);
266
+ },
267
+ 'keydown input[type=text]': function(event) {
268
+ if(event.keyCode != 13) return;
269
+ event.preventDefault();
270
+ this.list.addItem();
271
+ this.list.$list.find("> li:last input").focus();
272
+ }
273
+ },
274
+
275
+ initialize: function(options) {
276
+ this.list = options.list;
277
+ this.schema = options.schema || this.list.schema;
278
+ this.value = options.value;
279
+ this.Editor = options.Editor || editors.Text;
280
+ this.key = options.key;
281
+ },
282
+
283
+ render: function() {
284
+ //Create editor
285
+ this.editor = new this.Editor({
286
+ key: this.key,
287
+ schema: this.schema,
288
+ value: this.value,
289
+ list: this.list,
290
+ item: this
291
+ }).render();
292
+
293
+ //Create main element
294
+ var $el = $(Form.templates[this.schema.listItemTemplate]({
295
+ editor: '<b class="bbf-tmp"></b>'
296
+ }));
297
+
298
+ $el.find('.bbf-tmp').replaceWith(this.editor.el);
299
+
300
+ //Replace the entire element so there isn't a wrapper tag
301
+ this.setElement($el);
302
+
303
+ return this;
304
+ },
305
+
306
+ getValue: function() {
307
+ return this.editor.getValue();
308
+ },
309
+
310
+ setValue: function(value) {
311
+ this.editor.setValue(value);
312
+ },
313
+
314
+ focus: function() {
315
+ this.editor.focus();
316
+ },
317
+
318
+ blur: function() {
319
+ this.editor.blur();
320
+ },
321
+
322
+ remove: function() {
323
+ this.editor.remove();
324
+
325
+ Backbone.View.prototype.remove.call(this);
326
+ },
327
+
328
+ validate: function() {
329
+ var value = this.getValue(),
330
+ formValues = this.list.form ? this.list.form.getValue() : {},
331
+ validators = this.schema.validators,
332
+ getValidator = Form.helpers.getValidator;
333
+
334
+ if (!validators) return null;
335
+
336
+ //Run through validators until an error is found
337
+ var error = null;
338
+ _.every(validators, function(validator) {
339
+ error = getValidator(validator)(value, formValues);
340
+
341
+ return continueLoop = error ? false : true;
342
+ });
343
+
344
+ //Show/hide error
345
+ error ? this.setError(error) : this.clearError();
346
+
347
+ //Return error to be aggregated by list
348
+ return error ? error : null;
349
+ },
350
+
351
+ /**
352
+ * Show a validation error
353
+ */
354
+ setError: function(err) {
355
+ this.$el.addClass(Form.classNames.error);
356
+ this.$el.attr('title', err.message);
357
+ },
358
+
359
+ /**
360
+ * Hide validation errors
361
+ */
362
+ clearError: function() {
363
+ this.$el.removeClass(Form.classNames.error);
364
+ this.$el.attr('title', null);
365
+ }
366
+ });
367
+
368
+
369
+ /**
370
+ * Modal object editor for use with the List editor.
371
+ * To use it, set the 'itemType' property in a List schema to 'Object' or 'NestedModel'
372
+ */
373
+ editors.List.Modal = editors.List.Object = editors.List.NestedModel = editors.Base.extend({
374
+ events: {
375
+ 'click': 'openEditor'
376
+ },
377
+
378
+ /**
379
+ * @param {Object} options
380
+ * @param {Function} [options.schema.itemToString] Function to transform the value for display in the list.
381
+ * @param {String} [options.schema.itemType] Editor type e.g. 'Text', 'Object'.
382
+ * @param {Object} [options.schema.subSchema] Schema for nested form,. Required when itemType is 'Object'
383
+ * @param {Function} [options.schema.model] Model constructor function. Required when itemType is 'NestedModel'
384
+ */
385
+ initialize: function(options) {
386
+ editors.Base.prototype.initialize.call(this, options);
387
+
388
+ var schema = this.schema;
389
+
390
+ //Dependencies
391
+ if (!editors.List.Modal.ModalAdapter) throw 'A ModalAdapter is required';
392
+
393
+ //Get nested schema if Object
394
+ if (schema.itemType == 'Object') {
395
+ if (!schema.subSchema) throw 'Missing required option "schema.subSchema"';
396
+
397
+ this.nestedSchema = schema.subSchema;
398
+ }
399
+
400
+ //Get nested schema if NestedModel
401
+ if (schema.itemType == 'NestedModel') {
402
+ if (!schema.model) throw 'Missing required option "schema.model"';
403
+
404
+ this.nestedSchema = schema.model.prototype.schema;
405
+ if (_.isFunction(this.nestedSchema)) this.nestedSchema = this.nestedSchema();
406
+ }
407
+ },
408
+
409
+ /**
410
+ * Render the list item representation
411
+ */
412
+ render: function() {
413
+ var self = this;
414
+
415
+ //New items in the list are only rendered when the editor has been OK'd
416
+ if (_.isEmpty(this.value)) {
417
+ this.openEditor();
418
+ }
419
+
420
+ //But items with values are added automatically
421
+ else {
422
+ this.renderSummary();
423
+
424
+ setTimeout(function() {
425
+ self.trigger('readyToAdd');
426
+ }, 0);
427
+ }
428
+
429
+ if (this.hasFocus) this.trigger('blur', this);
430
+
431
+ return this;
432
+ },
433
+
434
+ /**
435
+ * Renders the list item representation
436
+ */
437
+ renderSummary: function() {
438
+ var template = Form.templates['list.Modal'];
439
+
440
+ this.$el.html(template({
441
+ summary: this.getStringValue()
442
+ }));
443
+ },
444
+
445
+ /**
446
+ * Function which returns a generic string representation of an object
447
+ *
448
+ * @param {Object} value
449
+ *
450
+ * @return {String}
451
+ */
452
+ itemToString: function(value) {
453
+ value = value || {};
454
+
455
+ //Pretty print the object keys and values
456
+ var parts = [];
457
+ _.each(this.nestedSchema, function(schema, key) {
458
+ var desc = schema.title ? schema.title : Form.helpers.keyToTitle(key),
459
+ val = value[key];
460
+
461
+ if (_.isUndefined(val) || _.isNull(val)) val = '';
462
+
463
+ parts.push(desc + ': ' + val);
464
+ });
465
+
466
+ return parts.join('<br />');
467
+ },
468
+
469
+ /**
470
+ * Returns the string representation of the object value
471
+ */
472
+ getStringValue: function() {
473
+ var schema = this.schema,
474
+ value = this.getValue();
475
+
476
+ if (_.isEmpty(value)) return '[Empty]';
477
+
478
+ //If there's a specified toString use that
479
+ if (schema.itemToString) return schema.itemToString(value);
480
+
481
+ //Otherwise check if it's NestedModel with it's own toString() method
482
+ if (schema.itemType == 'NestedModel') {
483
+ return new (schema.model)(value).toString();
484
+ }
485
+
486
+ //Otherwise use the generic method or custom overridden method
487
+ return this.itemToString(value);
488
+ },
489
+
490
+ openEditor: function() {
491
+ var self = this;
492
+
493
+ var form = new Form({
494
+ schema: this.nestedSchema,
495
+ data: this.value
496
+ });
497
+
498
+ var modal = this.modal = new Backbone.BootstrapModal({
499
+ content: form,
500
+ animate: true
501
+ }).open();
502
+
503
+ this.trigger('open', this);
504
+ this.trigger('focus', this);
505
+
506
+ modal.on('cancel', function() {
507
+ this.modal = null;
508
+
509
+ this.trigger('close', this);
510
+ this.trigger('blur', this);
511
+ }, this);
512
+
513
+ modal.on('ok', _.bind(this.onModalSubmitted, this, form, modal));
514
+ },
515
+
516
+ /**
517
+ * Called when the user clicks 'OK'.
518
+ * Runs validation and tells the list when ready to add the item
519
+ */
520
+ onModalSubmitted: function(form, modal) {
521
+ var isNew = !this.value;
522
+
523
+ //Stop if there are validation errors
524
+ var error = form.validate();
525
+ if (error) return modal.preventClose();
526
+ this.modal = null;
527
+
528
+ //If OK, render the list item
529
+ this.value = form.getValue();
530
+
531
+ this.renderSummary();
532
+
533
+ if (isNew) this.trigger('readyToAdd');
534
+
535
+ this.trigger('change', this);
536
+
537
+ this.trigger('close', this);
538
+ this.trigger('blur', this);
539
+ },
540
+
541
+ getValue: function() {
542
+ return this.value;
543
+ },
544
+
545
+ setValue: function(value) {
546
+ this.value = value;
547
+ },
548
+
549
+ focus: function() {
550
+ if (this.hasFocus) return;
551
+
552
+ this.openEditor();
553
+ },
554
+
555
+ blur: function() {
556
+ if (!this.hasFocus) return;
557
+
558
+ if (this.modal) {
559
+ this.modal.trigger('cancel');
560
+ this.modal.close();
561
+ }
562
+ }
563
+ }, {
564
+ //STATICS
565
+
566
+ //The modal adapter that creates and manages the modal dialog.
567
+ //Defaults to BootstrapModal (http://github.com/powmedia/backbone.bootstrap-modal)
568
+ //Can be replaced with another adapter that implements the same interface.
569
+ ModalAdapter: Backbone.BootstrapModal,
570
+
571
+ //Make the wait list for the 'ready' event before adding the item to the list
572
+ isAsync: true
573
+ });
574
+
575
+ })();