flapjack 0.8.4 → 0.8.5

Sign up to get free protection for your applications and to get access to all the features.
@@ -131,11 +131,6 @@ module Flapjack
131
131
  '{"entities":[' + entities_json + ']}'
132
132
  end
133
133
 
134
- app.get '/checks/:entity' do
135
- content_type :json
136
- entity = find_entity(params[:entity])
137
- entity.check_list.to_json
138
- end
139
134
 
140
135
  app.get %r{/status#{ENTITY_CHECK_FRAGMENT}} do
141
136
  content_type :json
@@ -316,29 +311,6 @@ module Flapjack
316
311
  errors.empty? ? 204 : err(403, *errors)
317
312
  end
318
313
 
319
- app.post '/entities/:entity/tags' do
320
- content_type :json
321
-
322
- tags = find_tags(params[:tag])
323
- entity = find_entity(params[:entity])
324
- entity.add_tags(*tags)
325
- entity.tags.to_json
326
- end
327
-
328
- app.delete '/entities/:entity/tags' do
329
- tags = find_tags(params[:tag])
330
- entity = find_entity(params[:entity])
331
- entity.delete_tags(*tags)
332
- status 204
333
- end
334
-
335
- app.get '/entities/:entity/tags' do
336
- content_type :json
337
-
338
- entity = find_entity(params[:entity])
339
- entity.tags.to_json
340
- end
341
-
342
314
  end
343
315
 
344
316
  end
@@ -8,11 +8,15 @@ module Flapjack
8
8
  module Rack
9
9
  class JsonParamsParser < Struct.new(:app)
10
10
  def call(env)
11
- if env['rack.input'] and not input_parsed?(env) and type_match?(env)
11
+ t = type(env)
12
+ if env['rack.input'] and not input_parsed?(env) and type_match?(t)
12
13
  env['rack.request.form_input'] = env['rack.input']
13
- data = env['rack.input'].read
14
+ json_data = env['rack.input'].read
14
15
  env['rack.input'].rewind
15
- env['rack.request.form_hash'] = data.empty? ? {} : Oj.load(data)
16
+
17
+ data = Oj.load(json_data)
18
+ env['rack.request.form_hash'] = data.empty? ? {} :
19
+ (('application/json-patch+json'.eql?(t)) ? {'ops' => data} : data)
16
20
  end
17
21
  app.call(env)
18
22
  end
@@ -21,9 +25,13 @@ module Flapjack
21
25
  env['rack.request.form_input'].eql? env['rack.input']
22
26
  end
23
27
 
24
- def type_match? env
25
- type = env['CONTENT_TYPE'] and
26
- Flapjack::Gateways::JSONAPI::JSON_REQUEST_MIME_TYPES.include?(type.split(/\s*[;,]\s*/, 2).first.downcase)
28
+ def type(env)
29
+ return unless env['CONTENT_TYPE']
30
+ env['CONTENT_TYPE'].split(/\s*[;,]\s*/, 2).first.downcase
31
+ end
32
+
33
+ def type_match?(t)
34
+ Flapjack::Gateways::JSONAPI::JSON_REQUEST_MIME_TYPES.include?(t)
27
35
  end
28
36
  end
29
37
  end
@@ -441,6 +441,7 @@ module Flapjack
441
441
  def require_js(*js)
442
442
  @required_js ||= []
443
443
  @required_js += js
444
+ @required_js.uniq!
444
445
  end
445
446
 
446
447
  def include_required_js
@@ -73,3 +73,61 @@ Backbone.Model.prototype.parse = function (response) {
73
73
  return obj;
74
74
  };
75
75
 
76
+ toolbox.savePatch = function(model, attrs, patch) {
77
+ var patch_json = JSON.stringify(patch);
78
+ return model.save(attrs, {
79
+ data: patch_json,
80
+ patch: true,
81
+ contentType: 'application/json-patch+json'
82
+ });
83
+ };
84
+
85
+ // makes sense to call this with model.patch(model.changedAttributes),
86
+ // if that value isn't false
87
+ Backbone.Model.prototype.patch = function(attrs) {
88
+ if (attrs == null) {
89
+ attrs = {};
90
+ }
91
+
92
+ var context = this;
93
+
94
+ var patch = _.inject(attrs, function(memo, val, key) {
95
+ // skip if not a simple attribute value
96
+ if ( (key == 'links') || _.isObject(val) || _.isArray(val) ) {
97
+ return memo;
98
+ }
99
+
100
+ memo.push({
101
+ op: 'replace',
102
+ path: '/' + context.urlType + '/0/' + key,
103
+ value: val
104
+ });
105
+
106
+ return memo;
107
+ }, new Array());
108
+
109
+ toolbox.savePatch(this, attrs, patch);
110
+ };
111
+
112
+ // singular operation only -- TODO batch up and submit en masse
113
+ Backbone.Model.prototype.addLinked = function(type, obj) {
114
+ var patch = [{
115
+ op: 'add',
116
+ path: '/' + this.urlType + '/0/links/' + type + '/-',
117
+ value: obj.get('id')
118
+ }];
119
+
120
+ toolbox.savePatch(this, {}, patch);
121
+ this.get('links')[type].add(obj);
122
+ };
123
+
124
+ // singular operation only -- TODO batch up and submit en masse
125
+ Backbone.Model.prototype.removeLinked = function(type, obj) {
126
+ var patch = [{
127
+ op: 'remove',
128
+ path: '/' + this.urlType + '/0/links/' + type + '/' + obj.get('id'),
129
+ }];
130
+
131
+ toolbox.savePatch(this, {}, patch);
132
+ this.get('links')[type].remove(obj);
133
+ };
@@ -1,5 +1,8 @@
1
1
  $(document).ready(function() {
2
2
 
3
+ // fix select2 with modal
4
+ $.fn.modal.Constructor.prototype.enforceFocus = function() {};
5
+
3
6
  var app = {
4
7
  api_url: $('div#data-api-url').data('api-url')
5
8
  };
@@ -19,13 +22,38 @@ $(document).ready(function() {
19
22
  toJSON: function() {
20
23
  return { entities: [ _.clone( this.attributes ) ] }
21
24
  },
22
- urlRoot: app.api_url + "/entities"
25
+ urlType: 'entities',
26
+ urlRoot: function() { return app.api_url + "/" + this.urlType; }
23
27
  });
24
28
 
25
29
  app.EntityCollection = Backbone.Collection.extend({
26
30
  model: app.Entity,
27
31
  comparator: 'name',
28
- url: app.api_url + "/entities"
32
+ urlType: 'entities',
33
+ url: function() { return app.api_url + "/" + this.urlType; }
34
+ });
35
+
36
+ app.Medium = Backbone.Model.extend({
37
+ name: 'media',
38
+ defaults: {
39
+ address: '',
40
+ interval: 60,
41
+ rollup_threshold: 3,
42
+ id: null,
43
+ contact_id: null,
44
+ },
45
+ toJSON: function() {
46
+ return { media: [ _.clone( this.attributes ) ] }
47
+ },
48
+ urlType: 'media',
49
+ urlRoot: function() { return app.api_url + "/" + this.urlType; }
50
+ });
51
+
52
+ app.MediumCollection = Backbone.Collection.extend({
53
+ model: app.Medium,
54
+ comparator: 'type',
55
+ urlType: 'media',
56
+ url: function() { return app.api_url + "/" + this.urlType; }
29
57
  });
30
58
 
31
59
  app.Contact = Backbone.Model.extend({
@@ -43,18 +71,24 @@ $(document).ready(function() {
43
71
  // TODO how will we handle circular references? make it a string and eval it?
44
72
  linkages: {
45
73
  entity: app.Entity,
46
- entities: app.EntityCollection
74
+ entities: app.EntityCollection,
75
+ medium: app.Medium,
76
+ media: app.MediumCollection
47
77
  },
48
- urlRoot : app.api_url + "/contacts"
78
+ urlType: 'contacts',
79
+ urlRoot: function() { return app.api_url + "/" + this.urlType; }
49
80
  });
50
81
 
51
82
  app.ContactCollection = Backbone.Collection.extend({
52
83
  model: app.Contact,
53
- url: app.api_url + "/contacts",
54
84
  linkages: {
55
85
  entity: app.Entity,
56
- entities: app.EntityCollection
86
+ entities: app.EntityCollection,
87
+ medium: app.Medium,
88
+ media: app.MediumCollection
57
89
  },
90
+ urlType: 'contacts',
91
+ url: function() { return app.api_url + "/" + this.urlType; }
58
92
  });
59
93
 
60
94
  app.ActionsView = Backbone.View.extend({
@@ -69,17 +103,53 @@ $(document).ready(function() {
69
103
  if ( $('#contactModal').hasClass('in') ) { return; }
70
104
 
71
105
  $('#contactModal h4#contactModalLabel').text('New Contact');
72
- $('#contactModal button.btn.btn-success').text('Create Contact');
106
+ $('#contactModal button#contactAccept').text('Create Contact');
107
+
108
+ var context = this;
109
+
110
+ // TODO if validating or leaving modal open, re-establish the event
111
+ $('#contactModal button#contactAccept').one('click', function() { context.save(); });
112
+
113
+ this.model = new app.Contact();
114
+ this.model.set('links', {entities: new app.EntityCollection(), media: new app.MediumCollection()});
115
+
116
+ var contactView = new app.ContactView({model: this.model});
117
+
118
+ $('#contactModal div.modal-footer').siblings().remove();
119
+ $('#contactModal div.modal-footer').before(contactView.render().$el);
120
+
121
+ var currentEntities = this.model.get('links')['entities'];
122
+
123
+ var contactEntityList = new app.ContactEntityList({collection: currentEntities, contact: this.model});
124
+ $('#contactModal tbody#contactEntityList').replaceWith( contactEntityList.render().$el );
125
+
126
+ var entityChooser = new app.EntityChooser({model: this.model, currentEntities: currentEntities});
127
+ entityChooser.render();
128
+
129
+ var context = this;
130
+
131
+ // Setup contact media
132
+ var contactMediaList = new app.ContactMediaList({
133
+ collection: this.model.get('links')['media'],
134
+ contact: this.model
135
+ });
136
+
137
+ $('#contactModal tbody#contactMediaList')
138
+ .replaceWith( contactMediaList.render().$el )
73
139
 
74
- $('#contactModal input[name=contact_id]').val('');
75
- $('#contactModal input[name=contact_first_name]').val('');
76
- $('#contactModal input[name=contact_last_name]').val('');
77
- $('#contactModal input[name=contact_email]').val('');
78
140
  $('#contactModal').modal('show');
79
141
  },
80
142
  render: function() {
81
143
  this.$el.html(this.template({}));
82
144
  return this;
145
+ },
146
+ save: function() {
147
+ data = {'first_name': $('#contactModal input[name=contact_first_name]').val(),
148
+ 'last_name': $('#contactModal input[name=contact_last_name]').val(),
149
+ 'email': $('#contactModal input[name=contact_email]').val()};
150
+ this.model.save(data, {type: 'POST', contentType: 'application/vnd.api+json'});
151
+ contacts.add(this.model);
152
+ $('#contactModal').modal('hide');
83
153
  }
84
154
  });
85
155
 
@@ -87,73 +157,111 @@ $(document).ready(function() {
87
157
  // this.collection == duplicate of entities with
88
158
  // entities enabled for this contact removed
89
159
  app.EntityChooser = Backbone.View.extend({
90
- tagName: 'input',
91
- attributes: {type: 'hidden'},
92
- className: 'entityChooser',
93
- initialize: function() {
94
- var contact_entity_ids = this.model.get('links')['entities'].pluck('id');
160
+ template: _.template($('#contact-entity-chooser').html()),
161
+ el: $("#entityAdd"),
162
+ events: {
163
+ 'click button#add-contact-entity' : 'addEntities',
164
+ },
165
+ initialize: function(options) {
166
+ this.options = options || {};
167
+ this.listenTo(options.currentEntities, 'add', this.refresh);
168
+ this.listenTo(options.currentEntities, 'remove', this.refresh);
169
+ },
170
+ render: function() {
95
171
 
96
- var someEntities = allEntities.reject(function(item, context) {
97
- return _.contains(contact_entity_ids, item.get('id'));
98
- });
172
+ this.calculate();
99
173
 
100
- this.collection = new app.EntityCollection(someEntities);
101
- },
102
- render: function() {
103
- var jqel = $(this.el);
174
+ // clear array
175
+ this.entityIdsToAdd = new Array();
104
176
 
105
- var results = this.collection.map( function(item) {
106
- return item.attributes;
177
+ this.$el.html(this.template({}));
178
+
179
+ var jqel = $(this.el).find('input#entityChooser');
180
+
181
+ var context = this;
182
+ jqel.on('change', function(e) {
183
+ if ( !_.isArray(e.removed) && _.isObject(e.removed) ) {
184
+ context.entityIdsToAdd = _.without(context.entityIdsToAdd, e.removed.id);
185
+ }
186
+
187
+ if ( !_.isArray(e.added) && _.isObject(e.added) && (context.entityIdsToAdd.indexOf(e.added.id) == -1) ) {
188
+ context.entityIdsToAdd.push(e.added.id);
189
+ }
107
190
  });
108
191
 
109
192
  var format = function(item) { return item.name; }
193
+ var context = this;
110
194
 
111
195
  jqel.select2({
112
- placeholder: "Select an Entity",
113
- data: { results: results, text: 'name'},
196
+ placeholder: "Select Entities",
197
+ data: {results: context.results, text: 'name'},
114
198
  formatSelection: format,
115
- formatResult: format
199
+ formatResult: format,
200
+ multiple: true,
201
+ width: 'off',
116
202
  });
117
203
 
118
204
  return this;
119
205
  },
206
+ calculate: function() {
207
+ var contact_entity_ids = this.options.currentEntities.pluck('id');
120
208
 
121
- });
209
+ var someEntities = allEntities.reject(function(item, context) {
210
+ return _.contains(contact_entity_ids, item.get('id'));
211
+ });
212
+
213
+ this.collection = new app.EntityCollection(someEntities);
122
214
 
123
- app.EntitiesEnabled = Backbone.View.extend({
124
- tagName: 'select',
125
- className: 'entityList',
126
- attributes: {
127
- size: "12",
128
- multiple: "multiple"
215
+ this.results = this.collection.map( function(item) {
216
+ return item.attributes;
217
+ });
129
218
  },
130
- initialize: function() {
131
- this.collection.on('add', this.render, this);
219
+ refresh: function(model, collection, options) {
220
+ this.calculate();
221
+ var jqel = $(this.el).find('input#entityChooser');
222
+ var context = this;
223
+ var format = function(item) { return item.name; }
224
+ jqel.select2({
225
+ placeholder: "Select Entities",
226
+ data: {results: context.results, text: 'name'},
227
+ formatSelection: format,
228
+ formatResult: format,
229
+ multiple: true,
230
+ width: 'off',
231
+ });
132
232
  },
133
- render: function() {
134
- var jqel = $(this.el);
135
-
136
- jqel.find('option').remove();
137
-
138
- this.collection.each(function(entity) {
139
- var item = new app.EntityListItem({ model: entity });
140
- jqel.append(item.render().el);
233
+ addEntities: function() {
234
+ var jqel = $(this.el).find('input#entityChooser');
235
+ jqel.select2("val", null);
236
+ var context = this;
237
+ _.each(this.entityIdsToAdd, function(entity_id) {
238
+ var newEntity = allEntities.find(function(entity) { return entity.id == entity_id; });
239
+ context.model.addLinked('entities', newEntity);
141
240
  });
241
+ this.entityIdsToAdd.length = 0;
242
+ },
243
+ });
142
244
 
245
+ app.ContactView = Backbone.View.extend({
246
+ template: _.template($('#contact-template').html()),
247
+ id: 'contactView',
248
+ render: function() {
249
+ var template_values = _.clone(this.model.attributes);
250
+ this.$el.html(this.template(template_values));
143
251
  return this;
144
- },
252
+ }
145
253
  });
146
254
 
147
255
  app.ContactList = Backbone.View.extend({
148
- tagName: 'table',
149
- className: 'table contactList',
256
+ tagName: 'tbody',
257
+ el: $('#contactList'),
150
258
  initialize: function() {
151
259
  this.collection.on('add', this.render, this);
152
260
  },
153
261
  render: function() {
154
262
  var jqel = $(this.el);
155
263
  jqel.empty();
156
-
264
+ var context = this;
157
265
  this.collection.each(function(contact) {
158
266
  var item = new app.ContactListItem({ model: contact });
159
267
  jqel.append($(item.render().el));
@@ -163,15 +271,45 @@ $(document).ready(function() {
163
271
  },
164
272
  });
165
273
 
166
- app.EntityListItem = Backbone.View.extend({
167
- tagName: 'option',
274
+ app.ContactEntityList = Backbone.View.extend({
275
+ tagName: 'tbody',
276
+ id: 'contactEntityList',
277
+ initialize: function(options) {
278
+ this.options = options || {};
279
+ this.collection.on('add', this.render, this);
280
+ this.collection.on('remove', this.render, this);
281
+ },
168
282
  render: function() {
283
+ var jqel = $(this.el);
284
+ jqel.empty();
285
+ var contact = this.options.contact;
286
+ this.collection.each(function(entity) {
287
+ var item = new app.ContactEntityListItem({ model: entity, contact: contact });
288
+ jqel.append(item.render().el);
289
+ });
169
290
 
170
- // TODO piggyback data from model record
171
- this.$el.html('<option>' + this.model.escape('name') + '</option>');
291
+ return this;
292
+ },
293
+ });
172
294
 
295
+ app.ContactEntityListItem = Backbone.View.extend({
296
+ tagName: 'tr',
297
+ template: _.template($('#contact-entities-list-item-template').html()),
298
+ events: {
299
+ 'click button.delete-entity' : 'removeEntity',
300
+ },
301
+ initialize: function(options) {
302
+ this.options = options || {};
303
+ },
304
+ render: function() {
305
+ var template_values = _.clone(this.model.attributes);
306
+ this.$el.html(this.template(template_values));
173
307
  return this;
174
- }
308
+ },
309
+ removeEntity: function() {
310
+ this.options.contact.removeLinked('entities', this.model);
311
+ this.$el.remove();
312
+ },
175
313
  });
176
314
 
177
315
  app.ContactListItem = Backbone.View.extend({
@@ -179,7 +317,12 @@ $(document).ready(function() {
179
317
  className: 'contact_list_item',
180
318
  template: _.template($('#contact-list-item-template').html()),
181
319
  events: {
182
- "click" : "editContact",
320
+ 'click .button.delete-contact': 'removeContact',
321
+ 'click': 'editContact',
322
+ },
323
+ initialize: function() {
324
+ // causes an unnecessary render on create, but required for update TODO cleanup
325
+ this.listenTo(this.model, "sync", this.render);
183
326
  },
184
327
 
185
328
  render: function() {
@@ -193,16 +336,151 @@ $(document).ready(function() {
193
336
  if ( $('#contactModal').hasClass('in') ) { return; }
194
337
 
195
338
  $('#contactModal h4#contactModalLabel').text('Edit Contact');
196
- $('#contactModal button.btn.btn-success').text('Update Contact');
339
+ $('#contactModal button#contactAccept').text('Update Contact');
340
+
341
+ var context = this;
342
+
343
+ // TODO if validating or leaving modal open, re-establish the event
344
+ $('#contactModal button#contactAccept').one('click', function() { context.save(); });
345
+
346
+ var contactView = new app.ContactView({model: this.model});
347
+
348
+ $('#contactModal div.modal-footer').siblings().remove();
349
+ $('#contactModal div.modal-footer').before(contactView.render().$el);
350
+
351
+ var currentEntities = this.model.get('links')['entities'];
352
+
353
+ var contactEntityList = new app.ContactEntityList({collection: currentEntities, contact: this.model});
354
+ $('#contactModal tbody#contactEntityList').replaceWith( contactEntityList.render().$el );
355
+
356
+ var entityChooser = new app.EntityChooser({model: this.model, currentEntities: currentEntities});
357
+ entityChooser.render();
358
+
359
+ // Setup contact media
360
+ var contactMediaList = new app.ContactMediaList({
361
+ collection: this.model.get('links')['media'],
362
+ contact: this.model
363
+ });
197
364
 
198
- $('#contactModal input[name=contact_id]').val(this.model.get('id'));
199
- $('#contactModal input[name=contact_first_name]').val(this.model.get('first_name'));
200
- $('#contactModal input[name=contact_last_name]').val(this.model.get('last_name'));
201
- $('#contactModal input[name=contact_email]').val(this.model.get('email'));
365
+ $('#contactModal tbody#contactMediaList')
366
+ .replaceWith( contactMediaList.render().$el )
202
367
 
203
368
  $('#contactModal').modal('show');
204
369
  },
205
370
 
371
+ save: function() {
372
+ data = {'first_name': $('#contactModal input[name=contact_first_name]').val(),
373
+ 'last_name': $('#contactModal input[name=contact_last_name]').val(),
374
+ 'email': $('#contactModal input[name=contact_email]').val()};
375
+ this.model.save(data, {type: 'PUT', contentType: 'application/vnd.api+json'});
376
+ $('#contactModal').modal('hide');
377
+ },
378
+
379
+ removeContact: function(e) {
380
+ e.stopImmediatePropagation();
381
+
382
+ var context = this;
383
+
384
+ context.model.destroy({
385
+ success: function() {
386
+ context.remove()
387
+ }
388
+ });
389
+ },
390
+ });
391
+
392
+ app.ContactMediaList = Backbone.View.extend({
393
+ tagName: 'tbody',
394
+ id: 'contactMediaList',
395
+ initialize: function(options) {
396
+ var context = this;
397
+ _.each(['email', 'sms', 'jabber'], function(type) {
398
+ var medium = context.collection.find(function(cm) {
399
+ return cm.get('type') == type;
400
+ });
401
+
402
+ if ( _.isUndefined(medium) ) {
403
+ medium = new app.Medium({
404
+ type: type,
405
+ address: '',
406
+ interval: 15,
407
+ rollup_threshold: 3,
408
+ contact_id: options.contact.get('id')
409
+ });
410
+ context.collection.add(medium);
411
+ }
412
+ });
413
+ },
414
+ render: function() {
415
+ var jqel = $(this.el);
416
+ jqel.empty();
417
+
418
+ this.collection.each(function(medium) {
419
+ var item = new app.ContactMediaListItem({ model: medium });
420
+ jqel.append(item.render().el);
421
+ });
422
+
423
+ return this;
424
+ },
425
+ });
426
+
427
+ app.ContactMediaListItem = Backbone.View.extend({
428
+ tagName: 'tr',
429
+ template: _.template($('#contact-media-list-item-template').html()),
430
+ events: {
431
+ // scoped to this view's el
432
+ 'change input' : 'updateMedium'
433
+ },
434
+ render: function() {
435
+ var template_values = _.clone(this.model.attributes);
436
+ template_values['labels'] = {
437
+ 'email' : 'Email',
438
+ 'sms' : 'SMS',
439
+ 'jabber' : 'Jabber'
440
+ };
441
+ this.$el.html(this.template(template_values));
442
+ return this;
443
+ },
444
+ updateMedium: function(event) {
445
+ var address = $(event.target).parent('td')
446
+ .siblings().addBack().find('input[data-attr=address]');
447
+ var interval = $(event.target).parent('td')
448
+ .siblings().addBack().find('input[data-attr=interval]');
449
+ var rollupThreshold =
450
+ $(event.target).parent('td')
451
+ .siblings().addBack().find('input[data-attr=rollup_threshold]');
452
+
453
+ var addressVal = address.val();
454
+ var intervalVal = interval.val();
455
+ var rollupThresholdVal = rollupThreshold.val();
456
+
457
+ var numRE = /^[0-9]+$/;
458
+
459
+ if ( !numRE.test(intervalVal) || !numRE.test(rollupThresholdVal) ) {
460
+ // only save if numeric fields have acceptable values
461
+ return;
462
+ }
463
+
464
+ if ( _.isUndefined(addressVal) || (addressVal.length == 0) ) {
465
+ // only save if address not blank
466
+ return;
467
+ }
468
+
469
+ // TODO visually highlight error
470
+
471
+ var attrName = event.target.getAttribute('data-attr');
472
+ var value = event.target.value;
473
+
474
+ var attrs = {};
475
+ attrs[attrName] = value;
476
+
477
+ if ( this.model.isNew() ) {
478
+ this.model.save(attrs);
479
+ this.model.set('id', this.model.get('contact_id') + '_' + this.model.get('type'));
480
+ } else {
481
+ this.model.patch(attrs);
482
+ }
483
+ }
206
484
  });
207
485
 
208
486
  var allEntities = new app.EntityCollection();
@@ -215,8 +493,7 @@ $(document).ready(function() {
215
493
  var actionsView = new app.ActionsView({collection: collection});
216
494
  var contactList = new app.ContactList({collection: collection});
217
495
  $('#container').append(actionsView.render().el);
218
-
219
- $('#contactList').append($(contactList.render().el).find('tr'));
496
+ contactList.render();
220
497
  }
221
498
  });
222
499
  }