One of the most common issues with Backbone is how to deal with complex model data:
{ "name": "John Doe", "address": { "street": "Seleme", "number": "1975 int 6", "city": "Culiacán" }, "phones": [{ "label": "Home", "number": "55 555 123" }, { "label": "Office", "number": "55 555 234" }], "emails": [{ "label": "Work", "email": "[email protected]" }] }
It could be easy to render a read-only view for this model data; however, the real challenge is how to bind form actions with embedded arrays. In Backbone, it is difficult to use the event system on array objects; if you push a new item in the list, no event will be triggered. This makes it difficult to keep model data in sync with the a view that edits its contents.
Imagine that our Contacts App will now allow us to add more than one phone and email. We will need to change the edit form view to add support for adding, removing, and modifying items on the array of phones and emails:
Figure 3.3 shows the result of adding a New button to allow the user to dynamically add the number of phones and emails he/she wants. Each item in the list should include a Delete button too to allow the user to remove them.
To render the phone and email lists and sync the forms with the model, we will follow a different strategy; Figure 3.4 illustrates how our strategy will look:
We will create two new Backbone collections, one for phones and another for emails. With the data in the Contact
model we can initialize these collections and render them as regular CollectionView
.
As we saw in the previous chapter, CollectionView
objects take care of the changes in the collection that it renders, so that we can modify the collection object and the view will behave as expected.
When the user clicks on the Save button, we can serialize the content of these collections and update the model before we call the save()
method.
Each item for phones and emails will have a very similar template:
<script id="contact-form-phone-item" type="text/template"> <div class="col-sm-4 col-md-2"> <input type="text" class="form-control description" placeholder="home, office, mobile" value="<%= description %>" /> </div> <div class="col-sm-6 col-md-8"> <input type="text" class="form-control phone" placeholder="(123) 456 7890" value="<%= phone %>" /> </div> <div class="col-sm-2 col-md-2 action-links"> <a href="#" class="pull-rigth delete">delete</a> </div> </script>
This template will be used as a ModelView
for a CollectionView
:
// apps/contacts/contactEditor.js class PhoneListItemView extends ModelView { constructor(options) { super(options); this.template = '#contact-form-phone-item'; } get className() { return 'form-group'; } } class PhoneListView extends CollectionView { constructor(options) { super(options); this.modelView = PhoneListItemView; } }
The contact form now should include two regions for PhoneListView
and EmailListView
:
<div class="panel panel-simple"> <div class="panel-heading"> Phones <button id="new-phone" class="btn btn-primary btn-sm pull-right">New</button> </div> <div class="panel-body"> <form class="form-horizontal phone-list-container"></form> </div> </div> <div class="panel panel-simple"> <div class="panel-heading"> Emails <button id="new-email" class="btn btn-primary btn-sm pull-right">New</button> </div> <div class="panel-body"> <form class="form-horizontal email-list-container"></form> </div> </div>
ContactForm
should be changed to support regions; we will extend from Layout
instead of ModelView
:
// apps/contacts/contactEditor.js class ContactForm extends Layout { constructor(options) { super(options); this.template = '#contact-form'; this.regions = { phones: '.phone-list-container', emails: '.email-list-container' }; } // ... }
We will need two new models: Phone
and Email
. Because both models are very similar, I will only show Phone
:
// apps/contacts/models/phone.js 'use strict'; App.Models = App.Models || {}; class Phone extends Backbone.Model { get defaults() { return { description: '', phone: '' }; } } App.Models.Phone = Phone;
And collection that uses the Phone
model:
// apps/contacts/collections/phoneCollection.js 'use strict'; App.Collections = App.Collections || {}; class PhoneCollection extends Backbone.Collection { constructor(options) { super(options); } get model() { return App.Models.Phone; } } App.Collections.PhoneCollection = PhoneCollection;
Now that we have the necessary objects to render the form, let's put them all together in the controller. First, we need to create the collection instances from the model data:
// apps/contacts/contactEditor.js class ContactEditor { // ... showEditor(contact) { // Data var phonesData = contact.get('phones') || []; var emailsData = contact.get('emails') || []; this.phones = new App.Collections.PhoneCollection(phonesData); this.emails = new App.Collections.EmailCollection(emailsData); // ... } // ... }
With the collections in place, we can build CollectionView
s properly:
// apps/contacts/contactEditor.js class ContactEditor { // ... showEditor(contact) { // ... // Create the views var layout = new ContactFormLayout({model: contact}); var phonesView = new PhoneListView({collection: this.phones}); var emailsView = new EmailListView({collection: this.emails}); var contactForm = new ContactForm({model: contact}); var contactPreview = new ContactPreview({model: contact}); // ... } // ... }
The phonesView
and emailsView
can be rendered in the regions exposed in the contactForm
object:
// apps/contacts/contactEditor.js class ContactEditor { // ... showEditor(contact) { // ... // Render the views this.region.show(layout); layout.getRegion('form').show(contactForm); layout.getRegion('preview').show(contactPreview); contactForm.getRegion('phones').show(phonesView); contactForm.getRegion('emails').show(emailsView); // ... } // ... }
When the user clicks on the New button, a new item in a proper list should be added:
// apps/contacts/contactEditor.js class ContactForm extends Layout { // ... get events() { return { 'click #new-phone': 'addPhone', 'click #new-email': 'addEmail', 'click #save': 'saveContact', 'click #cancel': 'cancel' }; } addPhone() { this.trigger('phone:add'); } addEmail() { this.trigger('email:add'); } // ... }
The ContactForm
knows nothing about the collections that we are using in the controller, so they can't add an item in the collection directly; the controller should listen for events in the contactForm
and update the collection:
// apps/contacts/contactEditor.js class ContactEditor { // ... showEditor(contact) { // ... this.listenTo(contactForm, 'phone:add', this.addPhone); this.listenTo(contactForm, 'email:add', this.addEmail); // ... } addPhone() { this.phones.add({}); } addEmail() { this.emals.add({}); } // ... }
When the user clicks on the delete link in an item of the list, the item should be removed from the collection:
// apps/contacts/contactEditor.js class PhoneListItemView extends ModelView { //... get events() { return { 'click a': 'deletePhone' }; } deletePhone(event) { event.preventDefault(); this.trigger('phone:deleted', this.model); } }
As we did with add
, the controller will take care of managing the collection data by attaching an event listener in the list view:
// apps/contacts/contactEditor.js class ContactEditor { // ... showEditor(contact) { // ... this.listenTo(phonesView, 'item:phone:deleted', (view, phone) => { this.deletePhone(phone); } ); this.listenTo(emailsView, 'item:email:deleted', (view, email) => { this.deleteEmail(email); } ); // ... } deletePhone(phone) { this.phones.remove(phone); } deleteEmail(email) { this.emails.remove(email); } // ... }
As you can see in the preceding snippets, adding items to the list (and removing them) is pretty simple; we just need to update the underlying collection and the views will be updated automatically. We did a great job with CollectionViews
in the previous chapter.
To save the phone and email attributes in the model, we need to extract the data stored in the collections and replace the existing data in the model:
// apps/contacts/contactEditor.js class ContactEditor { // ... saveContact(contact) { var phonesData = this.phones.toJSON(); var emailsData = this.emails.toJSON(); contact.set({ phones: phonesData, emails: emailsData }); contact.save(null, { success() { // Redirect user to contact list after save App.notifySuccess('Contact saved'); App.router.navigate('contacts', true); }, error() { // Show error message if something goes wrong App.notifyError('Something goes wrong'); } }); } // ... }
However, the collections are not in sync with the forms and you will end up with empty emails and phones. To fix this, we need to bind the models with the inputs:
// apps/contacts/contactEditor.js class PhoneListItemView extends ModelView { // ... get events() { return { 'change .description': 'updateDescription', 'change .phone': 'updatePhone', 'click a': 'deletePhone' }; } updateDescription() { var $el = this.$('.description'); this.model.set('description', $el.val()); } updatePhone() { var $el = this.$('.phone'); this.model.set('phone', $el.val()); } // ... }
Now, if you click the Save button, the data about the phones and emails will be stored as expected.
This way of binding embedded arrays in views through intermediate collections simplifies the way you work with lists and will make your code a lot simpler and more maintainable.
13.59.227.82