Now that we know how to work with user input elements, we can add forms to create and edit contacts in our contact management application.
We need to add three new routes: one to create new contacts, another to edit an existing contact, and the last one to upload the photo of a contact. Let's add them in our root component:
The src/app.js
file will be as follows:
export class App { configureRouter(config, router) { this.router = router; config.title = 'Learning Aurelia'; config.map([ { route: '', redirect: 'contacts' }, { route: 'contacts', name: 'contacts', moduleId: 'contact-list', nav: true, title: 'Contacts' }, { route: 'contacts/new', name: 'contact-creation', moduleId: 'contact-edition', title: 'New contact' }, { route: 'contacts/:id', name: 'contact-details', moduleId: 'contact-details' }, { route: 'contacts/:id/edit', name: 'contact-edition', moduleId: 'contact-edition' }, { route: 'contacts/:id/photo', name: 'contact-photo', moduleId: 'contact-photo' }, ]); config.mapUnknownRoutes('not-found'); } }
The three new routes are highlighted in the preceding code snippet.
The positioning is important here. The contact-creation
route is placed before the contact-details
route because of their respective route
property. When trying to find a matching route upon a URL change, the router drills down through the route definitions in the order they were defined in. Since the pattern for contact-details
matches any path starting with contacts/
and followed by a second part interpreted as a parameter, the path contacts/new
matches this pattern, so the contact-creation
route would be unreachable if it was defined later, and the contact-details
route would be reached instead with an id
parameter equal to new
.
A better alternative to relying on the order of the routes would be to change the patterns so there is no possible collision. For example, we could change the pattern of contact-details
to something like contacts/:id/details
. In such a case, the order of the routes would not matter anymore.
You may have noticed that two of the new routes have the same moduleId
. This is because we will use the same component for both creating a new contact and editing an existing one.
The next step will be to add links leading to the routes we just added. We will first add a link to the contact-creation
route in the contact-list
component:
src/contact-list.html
<template>
<section class="container">
<h1>Contacts</h1>
<div class="row">
<div class="col-sm-1">
<a route-href="route: contact-creation" class= "btn btn-primary">
<i class="fa fa-plus-square-o"></i> New
</a>
</div>
<div class="col-sm-2">
<!-- Search box omitted for brevity -->
</div>
</div>
<!-- Contact list omitted for brevity -->
</section>
</template>
Here we add an a
element and leverage the route-href
attribute to render the URL for the contact-creation
route.
We also need to add links to the contact-photo
and the contact-edition
routes. We will do this in the contact-details
component:
src/contact-details.html
<template> <section class="container"> <div class="row"> <div class="col-sm-2"> <a route-href="route: contact-photo; params.bind: { id: contact.id }" > <img src.bind="contact.photoUrl" class= "img-responsive" alt="Photo"> </a> </div> <div class="col-sm-10"> <template if.bind="contact.isPerson"> <h1>${contact.fullName}</h1> <h2>${contact.company}</h2> </template> <template if.bind="!contact.isPerson"> <h1>${contact.company}</h1> </template> <a class="btn btn-default" route-href="route: contact-edition; params.bind: { id: contact.id }"> <i class="fa fa-pencil-square-o"></i> Modify </a> </div> </div> <!-- Rest of template omitted for brevity --> </section> </template>
Here we first refactor the template displaying fullName
and company
if the contact is a person, by adding an enclosing div
and moving the col-sm-10
CSS class from the titles to this div
.
Next, we wrap the img
element displaying the contact's photo inside an anchor navigating to the contact-photo
route, using the contact's id
as a parameter.
Lastly, we add another anchor leading to the contact-edition
route, using the contact's id
as a parameter.
In order to reuse code, we will stick with the Contact
class and use it in our form component. We will also create classes for phone numbers, email addresses, addresses, and social profiles, so our contact-edition
component won't have to know the details of how to create empty instances of those objects.
We need to add the ability to create empty instances of our models, and have all their properties initialized to proper default values. As such, we will add default values for all properties on our model classes.
Lastly, we need to update the Contact
fromObject
factory method so all list items are properly mapped to instances of our model classes.
src/models.js
export class PhoneNumber { static fromObject(src) { return Object.assign(new PhoneNumber(), src); } type = 'Home'; number = ''; } export class EmailAddress { static fromObject(src) { return Object.assign(new EmailAddress(), src); } type = 'Home'; address = ''; } export class Address { static fromObject(src) { return Object.assign(new Address(), src); } type = 'Home'; number = ''; street = ''; postalCode = ''; city = ''; state = ''; country = ''; } export class SocialProfile { static fromObject(src) { return Object.assign(new SocialProfile(), src); } type = 'GitHub'; username = ''; } export class Contact { static fromObject(src) { const contact = Object.assign(new Contact(), src); contact.phoneNumbers = contact.phoneNumbers .map(PhoneNumber.fromObject); contact.emailAddresses = contact.emailAddresses .map(EmailAddress.fromObject); contact.addresses = contact.addresses .map(Address.fromObject); contact.socialProfiles = contact.socialProfiles .map(SocialProfile.fromObject); return contact; } firstName = ''; lastName = ''; company = ''; birthday = ''; phoneNumbers = []; emailAddresses = []; addresses = []; socialProfiles = []; note = ''; // Omitted snippet... }
Here, we first add classes for a PhoneNumber
, an EmailAddress
, an Address
, and a SocialProfile
. Each of those classes has a static fromObject
factory method and its properties are properly initialized with default values.
Next, we add the properties of a Contact
, initialized with default values, and change its fromObject
factory method so the list items are properly mapped to their respective classes.
Now we can create our new contact-edition
component. As mentioned earlier, this component will be used for both creating and editing. It will be able to detect if it is used to create a new contact or to edit an existing one by checking whether it receives an id
parameter in its activate
callback method. Indeed, the pattern for the contact-creation
route defines no parameter, so when our form component gets activated by this route, it won't receive any id
parameter. On the other hand, since the pattern for the contact-edition
route does define an id
parameter, our form component will receive the parameter when activated by this route.
We can do this because, in the scope of our contacts management application, the creation and editing processes are almost identical. However, in many cases, it might be a better design to have separate components for creating and editing.
Let's first start with the view-model and the activate
callback method:
src/contact-edition.js
import {inject} from 'aurelia-framework'; import {ContactGateway} from './contact-gateway'; import {Contact} from './models'; @inject(ContactGateway) export class ContactEdition { constructor(contactGateway) { this.contactGateway = contactGateway; } activate(params, config) { this.isNew = params.id === undefined; if (this.isNew) { this.contact = new Contact(); } else { return this.contactGateway.getById(params.id).then(contact => { this.contact = contact; config.navModel.setTitle(contact.fullName); }); } } }
Here, we start by injecting an instance of the ContactGateway
class into our view-model. Then, in the activate
callback method, we first define an isNew
property, based on the existence of an id
parameter. This property will be used by our component to know if it is being used to create a new contact or to edit an existing one.
Next, based on this isNew
property, we initialize the component. If we are creating a new contact, then we simply create a contact
property and assign to it a new, empty Contact
instance; otherwise, we use the ContactGateway
to retrieve the proper contact based on the id
parameter and, when the Promise
resolves, assign the Contact
instance to the contact
property and set the document title to the contact's fullName
property.
Once the activation cycle completes, the view-model has a contact
property properly initialized to a Contact
object, and an isNew
property indicating if the contact is a new or existing one.
Next, let's build the template to display the form. This template being pretty big, I will break it down in parts, so you can build it gradually and test it at each step if you want.
The template consists of a header, followed by a form
element, which will enclose the rest of the template:
src/contact-edition.html
<template> <section class="container"> <h1 if.bind="isNew">New contact</h1> <h1 if.bind="!isNew">Contact #${contact.id}</h1> <form class="form-horizontal"> <!-- The rest of the template goes in here --> </form> </section> </template>
In the header, we use the isNew
property to display either a static title telling the user he is creating a new contact or a dynamic title displaying the id
of the contact being edited.
Next, we will add blocks, containing input elements, to edit the firstName
, lastName
, company
, birthday
, and note
of the contact, inside the form
element defined in the previous code snippet:
<div class="form-group"> <label class="col-sm-3 control-label">First name</label> <div class="col-sm-9"> <input type="text" class="form-control" value.bind="contact.firstName"> </div> </div> <div class="form-group"> <label class="col-sm-3 control-label">Last name</label> <div class="col-sm-9"> <input type="text" class="form-control" value.bind="contact.lastName"> </div> </div> <div class="form-group"> <label class="col-sm-3 control-label">Company</label> <div class="col-sm-9"> <input type="text" class="form-control" value.bind="contact.company"> </div> </div> <div class="form-group"> <label class="col-sm-3 control-label">Birthday</label> <div class="col-sm-9"> <input type="date" class="form-control" value.bind="contact.birthday"> </div> </div> <div class="form-group"> <label class="col-sm-3 control-label">Note</label> <div class="col-sm-9"> <textarea class="form-control" value.bind="contact.note"></textarea> </div> </div>
Here, we simply define a form-group
for each property to edit. The first three properties are each bound to a text input
element. Additionally, the birthday
property is bound to a date
input, making it easier to edit a date--for browsers supporting it, of course--and the note
property is bound to a textarea
element.
After this, we need to add editors for lists. Since the data contained in each list is not very complex, we will render inline editors, so the user can edit any field of any item directly, in the minimum number of clicks.
We will discuss more complicated editing models, using dialogs, later in this chapter.
Let's start with the phone numbers:
<hr> <div class="form-group" repeat.for="phoneNumber of contact.phoneNumbers"> <div class="col-sm-2 col-sm-offset-1"> <select value.bind="phoneNumber.type" class="form-control"> <option value="Home">Home</option> <option value="Office">Office</option> <option value="Mobile">Mobile</option> <option value="Other">Other</option> </select> </div> <div class="col-sm-8"> <input type="tel" class="form-control" placeholder="Phone number" value.bind="phoneNumber.number"> </div> <div class="col-sm-1"> <button type="button" class="btn btn-danger" click.delegate="contact.phoneNumbers.splice($index, 1)"> <i class="fa fa-times"></i> </button> </div> </div> <div class="form-group"> <div class="col-sm-9 col-sm-offset-3"> <button type="button" class="btn btn-default" click.delegate="contact.addPhoneNumber()"> <i class="fa fa-plus-square-o"></i> Add a phone number </button> </div> </div>
This phone number list editor can be broken down into parts, the most important of which are highlighted. First, a form-group
is repeated for each phoneNumber
in the contact's phoneNumbers
array.
For each phoneNumber
, we define a select
element, whose value
is bound to the type
property of phoneNumber
, along with a tel input
, whose value
is bound to the number
property of phoneNumber
. Additionally, we define a button
whose click
event splices the phone number out of the phoneNumbers
array of contact
, using the current $index
, which, as you may remember from the previous chapter, is added to the binding context by the repeat
attribute.
Lastly, after the list of phone numbers, we define a button
whose click
event calls the addPhoneNumber
method in contact
.
One of the buttons we added in the previous template calls a method that is not defined yet. Let's add this method to the Contact
class:
src/models.js
//Snippet... export class Contact { //Snippet... addPhoneNumber() { this.phoneNumbers.push(new PhoneNumber()); } }
The first method in this code snippet is used to add an empty phone number to the list, by simply pushing a new PhoneNumber
instance in the phoneNumbers
array.
The templates for the other lists, email addresses, addresses, and social profiles, are pretty similar. Only the fields being edited change, but the main concepts--repeating form groups, having a remove button for each item and an add button at the end of the list--are the same.
Let's start with emailAddresses
:
<hr> <div class="form-group" repeat.for="emailAddress of contact.emailAddresses"> <div class="col-sm-2 col-sm-offset-1"> <select value.bind="emailAddress.type" class="form-control"> <option value="Home">Home</option> <option value="Office">Office</option> <option value="Other">Other</option> </select> </div> <div class="col-sm-8"> <input type="email" class="form-control" placeholder="Email address" value.bind="emailAddress.address"> </div> <div class="col-sm-1"> <button type="button" class="btn btn-danger" click.delegate="contact.emailAddresses.splice($index, 1)"> <i class="fa fa-times"></i> </button> </div> </div> <div class="form-group"> <div class="col-sm-9 col-sm-offset-3"> <button type="button" class="btn btn-primary" click.delegate="contact.addEmailAddress()"> <i class="fa fa-plus-square-o"></i> Add an email address </button> </div> </div>
This template is very similar to the phone numbers' template. The main difference is that the available types are not exactly the same, and the input
type
is email
.
As you can imagine, the editor for addresses will be a little bigger:
<hr> <div class="form-group" repeat.for="address of contact.addresses"> <div class="col-sm-2 col-sm-offset-1"> <select value.bind="address.type" class="form-control"> <option value="Home">Home</option> <option value="Office">Office</option> <option value="Other">Other</option> </select> </div> <div class="col-sm-8"> <div class="row"> <div class="col-sm-4"> <input type="text" class="form-control" placeholder="Number" value.bind="address.number"> </div> <div class="col-sm-8"> <input type="text" class="form-control" placeholder="Street" value.bind="address.street"> </div> </div> <div class="row"> <div class="col-sm-4"> <input type="text" class="form-control" placeholder="Postal code" value.bind="address.postalCode"> </div> <div class="col-sm-8"> <input type="text" class="form-control" placeholder="City" value.bind="address.city"> </div> </div> <div class="row"> <div class="col-sm-4"> <input type="text" class="form-control" placeholder="State" value.bind="address.state"> </div> <div class="col-sm-8"> <input type="text" class="form-control" placeholder="Country" value.bind="address.country"> </div> </div> </div> <div class="col-sm-1"> <button type="button" class="btn btn-danger" click.delegate="contact.addresses.splice($index, 1)"> <i class="fa fa-times"></i> </button> </div> </div> <div class="form-group"> <div class="col-sm-9 col-sm-offset-3"> <button type="button" class="btn btn-primary" click.delegate="contact.addAddress()"> <i class="fa fa-plus-square-o"></i> Add an address </button> </div> </div>
Here, the left part contains six different inputs, allowing us to edit the various text properties that an address has.
At this point, you probably have some idea what the template for the social profiles looks like:
<hr> <div class="form-group" repeat.for="profile of contact.socialProfiles"> <div class="col-sm-2 col-sm-offset-1"> <select value.bind="profile.type" class="form-control"> <option value="GitHub">GitHub</option> <option value="Twitter">Twitter</option> </select> </div> <div class="col-sm-8"> <input type="text" class="form-control" placeholder="Username" value.bind="profile.username"> </div> <div class="col-sm-1"> <button type="button" class="btn btn-danger" click.delegate="contact.socialProfiles.splice($index, 1)"> <i class="fa fa-times"></i> </button> </div> </div> <div class="form-group"> <div class="col-sm-9 col-sm-offset-3"> <button type="button" class="btn btn-primary" click.delegate="contact.addSocialProfile()"> <i class="fa fa-plus-square-o"></i> Add a social profile </button> </div> </div>
Of course, the methods to add items for each list need to be added to the Contact
class:
src/models.js
//Omitted snippet... export class Contact { //Omitted snippet... addEmailAddress() { this.emailAddresses.push(new EmailAddress()); } addAddress() { this. addresses.push(new Address()); } addSocialProfile() { this.socialProfiles.push(new SocialProfile()); } }
As you can see, those methods are almost identical to the ones we wrote previously for the phone numbers. Additionally, the template snippets for each list are also mostly identical to each other. All this redundancy screams for refactoring. We will see in Chapter 5, Making Reusable Components, how we can extract common behaviors and template snippets into a component, which we will reuse to manage each list.
The last thing missing for our form to be (at least visually) complete is a Save and a Cancel button at the end of the enclosing form
element:
//Omitted snippet...
<form class="form-horizontal" submit.delegate="save()">
//Omitted snippet...
<div class="form-group">
<div class="col-sm-9 col-sm-offset-3">
<button type="submit" class="btn btn-success">Save</button>
<a if.bind="isNew" class="btn btn-danger"
route-href="route: contacts">Cancel</a>
<a if.bind="!isNew" class="btn btn-danger"
route-href="route: contact-details;
params.bind: { id: contact.id }">Cancel</a>
</div>
</div>
</form>
First, we bind a call to the save
method to the form
element's submit
event, then we add a last form-group
containing a submit
button named Save.
Next, we add two Cancel
links: one displayed when creating a new contact to navigate back to the contacts list, and another displayed when editing an existing contact to navigate back to the contact's details.
We also need to add the save
method to the view-model. This method will ultimately delegate to the ContactGateway
but, in order to test that everything we've done upto this point, works, let's just write a dummy version of the method:
save() { alert(JSON.stringify(this.contact)); }
At this point, you should be able to run the application and try to create or edit a contact. When clicking the Save button, you should see an alert displaying the contact, serialized as JSON.
We can now add methods to create and update contacts to the ContactGateway
class:
src/contact-gateway.js
//Omitted snippet...
import {HttpClient, json} from 'aurelia-fetch-client';
//Omitted snippet...
export class ContactGateway {
//Omitted snippet...
create(contact) {
return this.httpClient.fetch('contacts',
{ method: 'POST', body: json(contact) });
}
update(id, contact) {
return this.httpClient.fetch(`contacts/${id}`,
{ method: 'PUT', body: json(contact) });
}
}
The first thing to do is import
the json
function from fetch-client
. This function takes any JS value as an argument and returns a Blob
object containing the received parameter serialized as JSON.
Next, we add a create
method, which takes a contact
as a parameter and calls the HTTP client's fetch
method, passing to it the relative URL to call, followed by a configuration object. This object contains properties that will be assigned to the underlying Request
object. Here, we specify a method
property, telling the client to perform a POST
request, and we indicate that the body
of the request will be the contact
serialized as JSON. Finally, the fetch
method returns a Promise
, which is returned by our new create
method, so callers can react when the request completes.
The update
method is very similar. The first difference is the parameters: the contact's id
is first expected, followed by the contact
object itself. Secondly, the fetch
call is slightly different; it sends a request to a different URL, using the PUT
method, but its body is the same.
The body
of a Fetch Request
is expected to be either a Blob
, a BufferSource
, a FormData
, an URLSearchParams
, or an USVString
object. Documentation about this can be found on the Mozilla Developer Network, at https://developer.mozilla.org/en-US/docs/Web/API/Request/Request.
In order to test that our new methods work, let's replace our dummy save
method in the view-model of the contact-edition
component with the real deal:
//Omitted snippet... import {Router} from 'aurelia-router'; @inject(ContactGateway, Router) export class ContactEdition { constructor(contactGateway, router) { this.contactGateway = contactGateway; this.router = router; } // Omitted snippet... save() { if (this.isNew) { this.contactGateway.create(this.contact) .then(() => this.router.navigateToRoute('contacts')); } else { this.contactGateway.update(this.contact.id, this.contact) .then(() => this.router.navigateToRoute('contact-details', { id: this.contact.id })); } } }
Here, we first import the Router
and inject an instance of it in the view-model. Next, we change the body of the save
method: if the component is creating a new contact, we first call the create
method from ContactGateway
, passing the contact
object to it, then navigate back to the contacts
route when the Promise
resolves; otherwise, when the component is editing an existing contact, we first call the update
method of ContactGateway
, passing the contact's id
and the contact
object to it, then navigate back to the contact's details when the Promise
resolves.
At this point, you should be able to create or update a contact. However, some create or update requests may return responses with a 400 bad Request status. Don't be alarmed; since the HTTP endpoint performs some validation, and our form does not at this point, this is expected to happen-if you leave some field empty, for example. We will add validation to our form later in this chapter, which will prevent this class of error.
Now that we can create and edit a contact, let's add a component to upload its photo. This component will be named contact-photo
, and will be activated by the route with the same name, which we already added earlier in this chapter.
This component will use a file input
element to let the user select an image file on his file system, and will leverage the HTML5 File API along with the Fetch client to send a PUT request containing the selected image file to our HTTP end-point.
The template for this component simply reuses a couple of the concepts we already covered:
src/contact-photo.html
<template> <section class="container"> <h1>${contact.fullName}</h1> <form class="form-horizontal" submit.delegate="save()"> <div class="form-group"> <label class="col-sm-3 control-label" for="photo">Photo</label> <div class="col-sm-9"> <input type="file" id="photo" accept="image/*" files.bind="photo"> </div> </div> <div class="form-group"> <div class="col-sm-9 col-sm-offset-3"> <button type="submit" class="btn btn-success">Save</button> <a class="btn btn-danger" route-href="route: contact-details; params.bind: { id: contact.id }">Cancel</a> </div> </div> </form> </section> </template>
Here, we first display the fullName
of the contact as the page title. Then, inside a form
element whose submit
event triggers a save
method, we add a file input
and buttons to Save or Cancel the photo upload. The file input
has an accept
attribute, forcing the browser's file selection dialog to display only image files, and its files
attribute is bound to the photo
property.
The view-model looks a lot like the contact-edition
view-model, at least when comparing imports, constructors, and activate
methods:
src/contact-photo.js
import {inject} from 'aurelia-framework'; import {Router} from 'aurelia-router'; import {ContactGateway} from './contact-gateway'; @inject(ContactGateway, Router) export class ContactPhoto { constructor(contactGateway, router) { this.contactGateway = contactGateway; this.router = router; } activate(params, config) { return this.contactGateway.getById(params.id).then(contact => { this.contact = contact; config.navModel.setTitle(this.contact.fullName); }); } save() { if (this.photo && this.photo.length > 0) { this.contactGateway.updatePhoto( this.contact.id, this.photo.item(0) ).then(() => { this.router.navigateToRoute( 'contact-details', { id: this.contact.id }); }); } } }
This view-model expects both an instance of ContactGateway
and a Router
instance to be injected in its constructor. In its activate
method, it then loads a Contact
instance using its id
parameter, and initializes the document title using the fullName
for the contact
. This is all pretty similar to the contact-edition
view-model.
The save
method is a little different however. It first checks if a file has been selected; if not, it does nothing for now. Otherwise, it calls the updatePhoto
method of the ContactGateway
method, passing the contact's id
and the selected file to it, and navigates back to the contact's details when the Promise
resolves.
The last step in getting our photo upload feature working is the uploadPhoto
method in the ContactGateway
class:
src/contact-gateway.js
//Omitted snippet... export class ContactGateway { //Omitted snippet... updatePhoto(id, file) { return this.httpClient.fetch(`contacts/${id}/photo`, { method: 'PUT', headers: { 'Content-Type': file.type }, body: file }); } }
Our HTTP backend's contacts/{id}/photo
endpoint expects a PUT request, with the proper Content-Type
header and the image binary as its body. This is exactly what the call to fetch
does here: it uses the file
argument, which is expected to be an instance of the File
class, and sets the Content-Type
header using its type
property, then sends the file
itself as the request body.
As mentioned earlier, the File
class is part of the HTML5 File API. The Mozilla Developer Network has extensive documentation about this API. Details about the File
class can be found at https://developer.mozilla.org/en-US/docs/Web/API/File.
As usual, the updatePhoto
method returns the Promise
resolved by the HTTP request, so the caller can act when the operation is completed.
At this point, you should be able to run the application and update the photo of a contact by uploading a new image file.
At this point, our application allows us to create, read, and update contacts. Obviously, one of the letters from create, read, update, delete (CRUD) is missing here: we can't delete a contact yet. Let's quickly implement this feature.
First, let's add a Delete button to the contact's details
component:
src/contact-details.html
<template> <section class="container"> <div class="row"> <div class="col-sm-2"> <!-- Omitted snippet... --> </div> <div class="col-sm-10"> <!-- Omitted snippet... --> <a class="btn btn-default" route-href="route: contact-edition; params.bind: { id: contact.id }"> <i class="fa fa-pencil-square-o"></i> Modify </a> <button class="btn btn-danger" click.delegate="tryDelete()"> <i class="fa fa-trash-o"></i> Delete </button> </div> </div> <!-- Rest of template omitted for brevity --> </section> </template>
The new Delete button will call the tryDelete
method when clicked:
src/contact-details.js
//Omitted snippet... export class ContactDetails { //Omitted snippet... tryDelete() { if (confirm('Do you want to delete this contact?')) { this.contactGateway.delete(this.contact.id) .then(() => { this.router.navigateToRoute('contacts'); }); } } }
The tryDelete
method first asks the user to confirm
deletion, then calls the gateway's delete
method with the contact's id
. When the returned Promise
resolves, it navigates back to the contacts list.
Lastly, the ContactGateway
class' delete
method just performs a Fetch call to the backend's proper path, using the DELETE
HTTP method:
src/contact-gateway.js
//Omitted snippet... export class ContactGateway { //Omitted snippet... delete(id) { return this.httpClient.fetch(`contacts/${id}`, { method: 'DELETE' }); } }
At this point, if you click on the Delete button for a contact and approve the confirmation dialog, you should be redirected to the contacts list and the contact should be gone.
3.21.166.99