Lesson Tuesday

After following along with the last lesson, our application should be able to retrieve Albums from Firebase, create new Album entries in our database, and even locate and retrieve specific entries based on their Firebase keys.

This lesson will further explore how we can manage Firebase databases directly through our applications. We'll add functionality to update Albums both in our application, and in our database. Along the way we'll also discuss how to re-use components in multiple places.

Updating Firebase Entries

Create a Component

Last week we detailed that components should have singular, dedicated jobs. Editing an Album in both our application and database is a distinct responsibility. Therefore, we'll make a dedicated component to manage it.

Let's create a component to edit Albums now. We'll call it edit-album:

$ ng g component edit-album

installing component
  create src/app/edit-album/edit-album.component.css
  create src/app/edit-album/edit-album.component.html
  create src/app/edit-album/edit-album.component.spec.ts
  create src/app/edit-album/edit-album.component.ts
  update src/app/app.module.ts

Next, we'll need to decide where to place the EditAlbumComponent. Our application currently includes a localhost:4200/admin route where new Albums can be created. Since editing Albums should also be an administrator-only capability, we'll place our EditAlbumComponent in the Admin route.

But the admin will need to select which Album to edit. This means we must list all Albums in the Admin route, too. So an administrator may view all Albums at a glance, and select which they'd like to edit.

We could add code to list Albums to our AdminComponent. But we already have a component meant to display our list of Albums: TheMarketplaceComponent.

Reusing a Component

We'll reuse our MarketplaceComponent in the Admin route. This will both keep our project DRY, and provide an opportunity to practice reusing components.

To reuse a component, we just place its selector tags in a second location. The selector property of MarketplaceComponent is app-marketplace. So we'll place <app-marketplace></app-marketplace> tags in our AdminComponent template:

src/app/admin/admin.component.html
<div class="panel panel-default">
  <div class="panel-heading">
    <h3 class="panel-title">Add New Album to Inventory</h3>
  </div>
  <div class="panel-body">
    <div>
      <div>
        <label>Album Title:</label>
        <br>
        <input #newTitle>
      </div>

      <div>
        <label>Album Artist:</label>
        <br>
        <input #newArtist>
      </div>

      <div>
        <label>Album Description:</label>
        <br>
        <textarea #newDescription></textarea>
      </div>
      <button (click)="submitForm(newTitle.value, newArtist.value, newDescription.value); newTitle.value=''; newArtist.value=''; newDescription.value=''">Add</button>
    </div>
  </div>
</div>

<div>
  <app-marketplace></app-marketplace>
</div>

Simple as that, we can serve our application and see all Albums listed in the Admin route:

album-list-in-admin-route

This is a great start; but there's a few things we could do to make this more user-friendly.

For instance, admins will likely want to see Albums' descriptions before deciding whether to edit them. They currently need to navigate to the detail page to view this information. Let's instead display all Album properties in the Admin route.

We also need to provide admins a form to edit Albums here in the Admin route.

But neither of these changes should appear in our localhost:4200/marketplace route. These changes should only be visible in the localhost:4200/admin route. Thankfully, there's an easy trick to hide and show content based on the user's current route.

Determining the Current Route

Any component with access to the router may call this.router.url to receive the current route's path.

Let's try this out. The MarketplaceComponent should already have access to our router. We'll print the current route to the console in its ngOnInit() method:

src/app/marketplace/marketplace.component.ts
...
  ngOnInit(){
    this.albums = this.albumService.getAlbums();
    console.log(this.router.url);
  }
...

If we reload and visit localhost:4200/marketplace, the MarketplaceComponent will render, and "/marketplace" will print to the console:

current-route-printed-to-console

But if we navigate to localhost:4200/admin, the same MarketplaceComponent will render again, but "/admin" will appear in the console:

route-printed-to-console-again

As you can see, we can use this.router.url to determine which route a component is being rendered in. We can use this information to dynamically hide or show content relevant to that route.

Let's remove our console.log() from MarketplaceComponent. Instead, we'll add a property containing the current route:

src/app/marketplace/marketplace.component.ts
...
export class MarketplaceComponent implements OnInit {
  albums: FirebaseListObservable<any[]>;
  currentRoute: string = this.router.url;

  constructor(private router: Router, private albumService: AlbumService){}

  ngOnInit(){
    this.albums = this.albumService.getAlbums();
  }

  goToDetailPage(clickedAlbum) {
    this.router.navigate(['albums', clickedAlbum.$key]);
  };
}

We'll also place an *ngIf directive in the MarketplaceComponent template. This directive will only render the EditAlbumComponent and display the Album's description if the user is located on the Admin route:

src/app/marketplace/marketplace.component.html
<h2>Marketplace</h2>

<div *ngFor="let album of albums | async" (click)="goToDetailPage(album)" class="panel panel-default">
  <div class="panel-body">
    <h3><em>{{album.title}}</em> by {{album.artist}}</h3>
    <div *ngIf="currentRoute === '/admin'">
      {{album.description}}
      <hr>
      <app-edit-album></app-edit-album>
    </div>
  </div>
</div>

If we reload we should see that the MarketplaceComponent looks the same as before on the localhost:4200/marketplace route.

However, when this same component is rendered in the localhost:4200/admin route, it also displays the Album's description, and the "edit-album works!" filler text from the EditAlbumComponent template:

component-altered-depending-on-route

Note on Changing Component Appearance Between Routes

Before we move on, let's address an important best practice. As you can see, It's fairly easy to alter a component's appearance based on the current route. This allows us to easily reuse components between routes, keeping our code DRY.

However, while tweaking a component to reuse it in multiple locations is absolutely fine, largely altering its entire appearance or purpose is a telltale sign you should make a new component instead.

Two-Way Data Binding Form

Now that EditAlbumComponent is rendered in MarketplaceComponent while on our Admin route, we can begin adding code to actually update Albums. First we'll need an edit form:

src/app/edit-album/edit-album.component.html
<h4>Edit Album</h4>

<label>New Album Title:</label>
<input [(ngModel)]="selectedAlbum.title">
<br>

<label>New Album Artist:</label>
<input [(ngModel)]="selectedAlbum.artist">
<br>

<label>New Album Description:</label>
<input [(ngModel)]="selectedAlbum.description">

Here, we use two-way data binding to update properties of an Album called selectedAlbum.

Passing Data Down

EditAlbumComponent doesn't yet know what selectedAlbum is. Remember "Data down, actions up"? We need to pass this data down from the parent MarketplaceComponent into the child EditAlbumComponent.

To do this, EditAlbumComponent needs an @Input to accept data from its parent. We'll import Input from Angular core, and define an @Input named selectedAlbum:

src/app/edit-album/edit-album.component.ts
import { Component, Input, OnInit } from '@angular/core';
import { Album } from '../album.model';

@Component({
  selector: 'app-edit-album',
  templateUrl: './edit-album.component.html',
  styleUrls: ['./edit-album.component.css']
})


export class EditAlbumComponent implements OnInit {
  @Input() selectedAlbum;

  constructor() { }

  ngOnInit() {
  }

}

Next we'll instruct MarketplaceComponent to pass information about a specific Album downward to the EditAlbumComponent:

src/app/marketplace/marketplace.component.html
<h2>Marketplace</h2>

<div *ngFor="let album of albums | async" (click)="goToDetailPage(album)" class="panel panel-default">
  <div class="panel-body">
    <h3><em>{{album.title}}</em> by {{album.artist}}</h3>
    <div *ngIf="currentRoute === '/admin'">
      {{album.description}}
      <hr>
      <app-edit-album [selectedAlbum]="album"></app-edit-album>
    </div>
  </div>
</div>

We add [selectedAlbum]="album" to the existing <app-edit-album></app-edit-album> tags. This tells MarketplaceComponent to pass the current album from the directive loop to the EditAlbumComponent's @Input field named selectedAlbum.

We should now see an edit form next to each Album on the localhost:4200/admin route:

update-form-next-to-album

Updating Event Bindings

Unfortunately, if we click our edit form our application navigates to the Album' detail page! This is because the div where Album info resides has an event binding attached to it. The event binding runs goToDetailPage() when clicked, navigating to our Album Detail route:

src/app/marketplace/marketplace-component.html
<h2>Marketplace</h2>

<div *ngFor="let album of albums | async" (click)="goToDetailPage(album)" class="panel panel-default">
  <div class="panel-body">
...

We'll simply remove this event binding from the div, and place it in the <h3> instead:

src/app/marketplace/marketplace-component.html
<h2>Marketplace</h2>

<div *ngFor="let album of albums | async"  class="panel panel-default">
  <div class="panel-body">
    <h3 (click)="goToDetailPage(album)"><em>{{album.title}}</em> by {{album.artist}}</h3>
    <div *ngIf="currentRoute === '/admin'">
      {{album.description}}
      <hr>
      <app-edit-album [selectedAlbum]="album"></app-edit-album>
    </div>
  </div>
</div>

We should now be able to edit Albums and see changes instantly reflected in the template! However, if we navigate back to localhost:4200/marketplace our changes are no longer present. This is because we're not saving the updated information to Firebase.

Saving Updates to Firebase

We'll need to save changes made in EditAlbumComponent to Firebase too. Because our AlbumService manages the application's relationship with Firebase, we'll also rely upon it to locate and update Album entries.

First, we'll inject AlbumService into the EditAlbumComponent. This will allow the component to invoke the service, and request it alter a database entry when the edit form is submitted:

src/app/edit-album/edit-album.component.ts
import { Component, Input, OnInit } from '@angular/core';
import { AlbumService } from '../album.service';

@Component({
  selector: 'app-edit-album',
  templateUrl: './edit-album.component.html',
  styleUrls: ['./edit-album.component.css'],
  providers: [AlbumService]
})


export class EditAlbumComponent implements OnInit {
  @Input() selectedAlbum;

  constructor(private albumService: AlbumService) { }

  ngOnInit() {
  }

}

The code above injects the AlbumService into the EditAlbumComponent by completing the following steps, as detailed in the Services lesson earlier this week:

  • Imports the AlbumService at the top of the file.

  • Adds a providers property to the component's annotation.

  • Registers AlbumService in the providers array.

  • Declares a new instance of AlbumService in the constructor.

Next, our service needs a method to locate a specific Album's Firebase entry and update it. We'll call this method updateAlbum():

src/app/album.service.ts
...
  getAlbumById(albumId: string){
    return this.database.object('/albums/' + albumId);
  }

  updateAlbum(localUpdatedAlbum){
    var albumEntryInFirebase = this.getAlbumById(localUpdatedAlbum.$key);
    albumEntryInFirebase.update({title: localUpdatedAlbum.title,
                                artist: localUpdatedAlbum.artist,
                                description: localUpdatedAlbum.description});
  }

...
  • updateAlbum() takes the local copy of the Album as an argument. Remember, this local version of Album has been edited with our two-way data binding edit form.

  • It calls the existing getAlbumById() method to locate the Firebase entry corresponding to this Album. We assign this Firebase entry to the variable albumEntryInFirebase.

  • getAlbumById() requires the Firebase-assigned $key as an argument. So we call localUpdatedAlbum.$key within the argument to getAlbumById().

  • After the database entry has been located, we call AngularFire's built in update() method on albumEntryInFirebase.

  • We update() the Album's new properties. These are formatted as key-value pairs. The key in each refers to the property in Firebase we're updating. The value of each contains the Album's local, updated properties.

Finally, we need to trigger our service's new method. Let's add a button to our edit form:

src/app/edit-album/edit-album.component.html
<h4>Edit Album</h4>

<label>New Album Title:</label>
<input [(ngModel)]="selectedAlbum.title">
<br>

<label>New Album Artist:</label>
<input [(ngModel)]="selectedAlbum.artist">
<br>

<label>New Album Description:</label>
<input [(ngModel)]="selectedAlbum.description">

<button (click)="beginUpdatingAlbum(selectedAlbum)">Update</button>

As you can see, this button contains an event binding to invoke a beginUpdatingAlbum() method when clicked. Let's define this method now:

src/app/edit-album/edit-album.component.ts
import { Component, Input, OnInit } from '@angular/core';
import { AlbumService } from '../album.service';

@Component({
  selector: 'app-edit-album',
  templateUrl: './edit-album.component.html',
  styleUrls: ['./edit-album.component.css'],
  providers: [AlbumService]
})
export class EditAlbumComponent implements OnInit {
  @Input() selectedAlbum;

  constructor(private albumService: AlbumService) { }

  ngOnInit() {
  }

  beginUpdatingAlbum(albumToUpdate){
    this.albumService.updateAlbum(albumToUpdate);
  }

}
  • beginUpdatingAlbum() is triggered when the "Update" button registers a click event. It calls the AlbumService's updateAlbum() method, passing in the locally-updated Album.

  • As we discussed, updateAlbum() then locates and updates the Album's Firebase entry.

If we build and serve our application, we can now update our Album. These changes are immediately reflected on the page, thanks to two-way data binding:

updates-on-page

After clicking the "Update" button, we can see the Firebase entry for that object has also been amended:

updates-visible-in-firebase

Additionally, our updates are now visible application-wide. We can see them reflected in the Marketplace route, too:

edits-visible-throughout-app

Excellent! In the next lesson we'll address how to delete an Album from our database entirely.