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,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
+ })();