Angular+Firebase

Building a Web App with Angular 4 and Firebase — Part 4

If you’ve been following along with my previous tutorials, you’ll see that the Angular+Firebase app we’ve been building now allows us to create projects and tasks that belong to them, but putting all this information on a single page leaves little room to grow. In this tutorial, we’ll create a Project Detail page where we can view tasks belonging to a project.

As a reminder, here are the past tutorials to catch you up:

The full source code for this article is available on GitHub.

Setting Up Routing

Routing allows a single-page web app to display differing information based on the URL. So far, all of our work has been done on the home page, but it would be better to have our project listing at a /projects URI or for a specific project’s details to be viewed at /projects/some-project-id.

To use routing, we need to import RouterModule and configure our routes. Open app.module.ts to make the necessary changes.

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { FormsModule } from '@angular/forms';

Now to configure our routes, open the app.config.ts file where we saved our Firebase configuration. We want to add a couple imports to the top and then our route configuration to the bottom.

import { Routes } from '@angular/router';

import { ProjectsComponent } from './projects/projects.component';

// Firebase
export const firebaseConfig = {
  // Your config
};

// Routes
export const appRoutes: Routes = [
  {
    path: 'projects',
    component: ProjectsComponent
  }, {
    path: '',
    redirectTo: 'projects',
    pathMatch: 'full'
  }
];

Routes are read from top to bottom and the first match is executed. So if you were to visit /projects we would load our Projects component onto the page. The second route we configured is for the home page. Since we don’t have a home page yet, we redirect to the projects page.

Now to import our configuration into app.module.ts.

import { AngularFireDatabaseModule } from 'angularfire2/database';
import { AngularFireAuthModule } from 'angularfire2/auth';

import { firebaseConfig, appRoutes } from './app.config';

import { AppComponent } from './app.component';
import { ProjectsComponent } from './projects/projects.component';
@NgModule({
  declarations: [
    AppComponent,
    ProjectsComponent
  ],
  imports: [
    BrowserModule,
    RouterModule.forRoot(appRoutes),
    FormsModule,

Now if you visit http://localhost:4200/, you will be redirected to http://localhost:4200/projects. At this point, everything appears to be working correctly, but the truth is that we are displaying the same content on every page regardless of the URL. In our route configuration, we specified that we wanted to use the Projects component on our projects page, but we don’t want it on every page.

In the app.component.html file, we added an <app-projects> tag at the bottom. Instead, we want to use <router-outlet>.

<h1>
  Welcome to {{title}}!
</h1>

<router-outlet></router-outlet>

The <router-outlet> tag allows our router configuration to determine which component gets loaded for a particular URL.

Next, we want to display our tasks on a separate page. We’ll call this the Project Detail page.

Building the Project Detail Component

The Project Detail component will display the project’s title followed by a list of tasks that belong to it. We’ll also move the form controls for creating new tasks to this component.

Enter the following in your command line to scaffold out the new component.

ng generate component project-detail --spec=false

Before we start working on our component, let’s create a route for it to ensure our routing is working.

In app.config.ts, import the Project Detail component and create a route for it like so.

import { Routes } from '@angular/router';

import { ProjectsComponent } from './projects/projects.component';
import { ProjectDetailComponent }
  from './project-detail/project-detail.component';

// Firebase
export const firebaseConfig = {
  // Your config
};

// Routes
export const appRoutes: Routes = [
  {
    path: 'projects',
    component: ProjectsComponent
  }, {
    path: 'projects/:projectKey',
    component: ProjectDetailComponent
  }, {
    path: '',
    redirectTo: 'projects',
    pathMatch: 'full'
  }
];

Notice the route for the Project Detail component: projects/:projectKey. The colon in front of projectKey indicates that it’s a route parameter and not an exact path. To demonstrate, visit http://localhost:4200/projects/1 in your browser, and you should see your new Project Detail component on the page. We will use the projectKey route parameter later to get the project we want to show. For now, it doesn’t have an effect.

Browser rendering of Project Detail component

Move the New Task Form Fields

The Projects component has a form at the bottom for adding a new task. We want to move this to the Project Detail component instead.

Open projects.component.html and move the last <div> into project-detail.component.html (you can overwrite the current HTML).

<div>
  <h3>Add a New Task</h3>

  <select [(ngModel)]="newProjectKey">
    <option *ngFor="let project of projects"
      [ngValue]="project.key">{{project.title}}</option>
  </select>

  <input type="text" placeholder="Task title"
    [(ngModel)]="newTaskTitle"
    (keyup.enter)="addTask()">

  <button (click)="addTask()">Add Task</button>
</div>

Reorganize the Tasks Code

We also need to move some of the code from projects.component.ts to project-detail.component.ts. Move the newProjectKey and newTaskTitle properties. Then, delete the code in ngOnit() where we set the default project for new tasks. Finally, move the addTask() method.

export class ProjectsComponent implements OnInit {
  projectsObservable: Observable<Project[]>;
  projects: Project[];
  newProjectTitle: string;
  newProjectKey: string;
  newTaskTitle: string;
ngOnInit() {
  // Get projects
  this.projectsObservable = this._dataService.getList('projects');

  this.projectsObservable.subscribe(projects => {
    this.projects = projects;

    // Set default project for new task
    if (projects.length > 0) {
      this.newProjectKey = projects[0].key;
    }
  });
}
/**
 * Adds a new task to the data source.
 */
addTask() {
  ...
}

The project-detail.component.ts file should look list this:

import { Component, OnInit } from '@angular/core';

@Component({
  selector: 'app-project-detail',
  templateUrl: './project-detail.component.html',
  styleUrls: ['./project-detail.component.css']
})
export class ProjectDetailComponent implements OnInit {
  newProjectKey: string;
  newTaskTitle: string;

  constructor() { }

  ngOnInit() {
  }

  /**
   * Adds a new task to the data source.
   */
  addTask() {
    // Add to "tasks" object
    this._dataService.addObject({
      project: this.newProjectKey,
      title: this.newTaskTitle
    }, 'tasks')
      .then(taskRef => {
        // Update project tasks
        const projectWithNewTask = this.projects.find(
          project => project.key === this.newProjectKey
        );

        if (!projectWithNewTask.hasOwnProperty('tasks')) {
          projectWithNewTask.tasks = {};
        }

        projectWithNewTask.tasks[taskRef.key] = true;

        this._dataService.updateObject(
          {
            tasks: projectWithNewTask.tasks
          },
          'projects/' + this.newProjectKey
        );

        // Clear task title text box
        this.newTaskTitle = '';
      });
  }
}

Some revision is needed before we can get this working again. First, we need to import our Data service and make it available through the constructor.

import { DataService } from '../data.service';
constructor(
  private _dataService: DataService
) { }

We need to get the requested project by looking for the key in the URL. The ActivatedRoute module does this. Let’s import it and make it available in the constructor.

import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';

import { DataService } from '../data.service';
constructor(
  private _dataService: DataService,
  private _activatedRoute: ActivatedRoute
) { }

Now, we can get the project key from the route parameters.

ngOnInit() {
  this._activatedRoute.params.subscribe(params => {
    const projectKey = params['projectKey'];
  });
}

With the project key, we can ask our Data service for a project observable. Let’s add those properties first.

import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Observable } from 'rxjs/Observable';

import { Project } from '../projects/project.model';

import { DataService } from '../data.service';
export class ProjectDetailComponent implements OnInit {
  projectObservable: Observable<Project>;
  project: Project;
  newProjectKey: string;
  newTaskTitle: string;

The Data service has a getList() method but to get a single project we need a getObject() method. It will accept a key and a path for its parameters. Add the following to your data.service.ts file.

/**
 * Returns an observale of a database object.
 * @param key The database object's key.
 * @param path The path to the database object's parent.
 */
getObject<T>(key: string, path: string): Observable<T> {
  return this._db.object(`${path}/${key}`).valueChanges();
}
Note: You may have noticed we are using valueChanges() again instead of snapshotChanges(). That’s fine for this case because we already have to know the key in order to get the object.

Head back to project-detail.component.ts to make use of the new getObject() method.

ngOnInit() {
  this._activatedRoute.params.subscribe(params => {
    const projectKey = params['projectKey'];

    this.projectObservable = this._dataService.getObject(
      projectKey,
      'projects'
    );

    this.projectObservable.subscribe(project => {
      this.project = project;
    });
  });
}

The app will still not compile at this time, because the addTask() method is still expecting a projects property. We no longer have a list of projects, so let’s change that.

addTask() {
  // Add to "tasks" object
  this._dataService.addObject({
    project: this.newProjectKey,
    title: this.newTaskTitle
  }, 'tasks')
    .then(taskRef => {
      // Update project tasks
      const projectWithNewTask = this.projects.find(
        project => project.key === this.newProjectKey
      );

      if (!projectWithNewTask.hasOwnProperty('tasks')) {
        projectWithNewTask.tasks = {};
      }
      if (!this.project.hasOwnProperty('tasks')) {
        this.project.tasks = {};
      }

      projectWithNewTask.tasks[taskRef.key] = true;
      this.project.tasks[taskRef.key] = true;

      this._dataService.updateObject(
        {
          tasks: projectWithNewTask.tasks
          tasks: this.project.tasks
        },
        'projects/' + this.newProjectKey
      );

      // Clear task title text box
      this.newTaskTitle = '';
    });
}

Let’s also rename the newProjectKey property to something more appropriate, such as projectKey, and give it a value.

export class ProjectDetailComponent implements OnInit {
  projectObservable: Observable<Project>;
  project: Project;
  newProjectKey: string;
  projectKey: string;
  newTaskTitle: string;
addTask() {
  // Add to "tasks" object
  this._dataService.addObject({
    project: this.newProjectKey,
    project: this.projectKey,
    title: this.newTaskTitle
  }, 'tasks')
    .then(taskRef => {
      // Update project tasks
      if (!this.project.hasOwnProperty('tasks')) {
        this.project.tasks = {};
      }

      this.project.tasks[taskRef.key] = true;

      this._dataService.updateObject(
        {
          tasks: this.project.tasks
        },
        'projects/' + this.newProjectKey
        'projects/' + this.projectKey
      );
ngOnInit() {
    this._activatedRoute.params.subscribe(params => {
      const projectKey = params['projectKey'];
      this.projectKey = params['projectKey'];

      this.projectObservable = this._dataService.getObject(
        projectKey,
        this.projectKey,
        'projects'
      );

Everything should compile again. If not, try running ng serve again in case something got stuck or refer to the code on GitHub to make sure yours is the same.

Browser rendering of page

We still have a project drop-down selector on our project detail page that doesn’t serve a purpose now that we know which project will be used for the new task. Go ahead and remove it in project-detail.component.html.

<div>
  <h3>Add a New Task</h3>

  <select [(ngModel)]="newProjectKey">
    <option *ngFor="let project of projects"
      [ngValue]="project.key">{{project.title}}</option>
  </select>

  <input type="text" placeholder="Task title"
    [(ngModel)]="newTaskTitle"
    (keyup.enter)="addTask()">

  <button (click)="addTask()">Add Task</button>
</div>

Adding new tasks should still work correctly provided that your URL has a valid project key in it.

Now we can finally start displaying our tasks for the project.

Add a Task List

If you haven’t already, go ahead and add some tasks to your projects from the app, and confirm that they are added correctly in Firebase. Here’s how the structure may look if the app is working correctly.

Sample tasks in Firebase

Note: The keys that are automatically generated are not simple numbers like the 1, 2, and 3 keys we created manually. They are GUIDs, and although they may look odd, that still serve the same purpose as a unique number ID.

To display these tasks, we need to pull them in from the database. We already have a project, but it only knows about its tasks’ keys, and we want to show a list of titles. We’ll add a tasks property to our ProjectDetail class and fill it with the tasks for the project. But first, we need a task model.

Create a new folder at /src/app/tasks and add a file to it called task.model.ts.

export interface Task {
  key: string,
  title: string
}

We’ll use this model in project-detail.component.ts to store tasks from the database. Add an import for it, then create a taskObservables property and a tasks property.

import { Project } from '../projects/project.model';
import { Task } from '../tasks/task.model';
export class ProjectDetailComponent implements OnInit {
  projectObservable: Observable<Project>;
  taskObservables: Observable<Task>[];
  project: Project;
  projectKey: string;
  tasks: Task[];
  newTaskTitle: string;

The taskObservables will be an array of observables that will resolve into the individual tasks that belong to the project. We won’t know which tasks belong to the project until after the projectObservable reports back, so we’ll get the tasks then.

this.projectObservable.subscribe(project => {
  this.project = project;

  // Bind tasks
  this.tasks = [];

  if (project.hasOwnProperty('tasks')) {
    this.taskObservables = Object.keys(project.tasks).map(taskKey => {
      return this._dataService.getObject(taskKey, 'tasks');
    });

    Observable.merge(...this.taskObservables).subscribe(task => {
      this.tasks.push(task);
    });
  }
});

You can see that we start off by setting this.tasks to an empty array. We have to do this because the projectObservable could report back more than once, and we want to make sure we start with an empty task list each time.

After that, we check to see if the project has any tasks with hasOwnProperty(‘tasks’). If it does, we set the taskObservables array by mapping each task’s key to the observable returned from our Data service, which will get a task from the database by its key.

Finally, we subscribe to all of the task observables, and as each one reports back, we add it to the tasks array.

Note: We took advantage of a few advanced JavaScript features in the code above. If some of it is unfamiliar to you, feel free to brush up on the array’s map() method,  Object.keys(), Observable.merge(), and the spread (…) operator.

Open up project-detail.component.html and add the following at the top.

<h2>{{project.title}}</h2>

<ul>
  <li *ngFor="let task of tasks">
    {{task.title}}
  </li>
</ul>

This will show the project’s title followed by a list of the task titles for that project. Go ahead and save and take a look.

Browser rendering of task list

While this works, if you open up your browser’s dev tools, you’ll see a nasty collection of errors. The problem is that when the component first initializes, we don’t have a project or any tasks until the Data service reports back. Let’s fix that by setting our project and task properties in the constructor.

constructor(
  private _dataService: DataService,
  private _activatedRoute: ActivatedRoute
) {
  this.project = {} as Project;
  this.tasks = [];
}

That’s better. Before moving on, try adding some new tasks, and watch as they’re added to the list.

Linking the Projects to the Detail Page

We can finally see our project tasks now, but there’s no connection from the projects page to the project detail page. We need to link up our projects. Open projects.component.html and make the following changes.

<h2>Projects</h2>

<ul>
  <li *ngFor="let project of projects">
    <a [routerLink]="['/projects', project.key]">{{project.title}}</a>
  </li>
</ul>

The [routerLink] attribute allows us to easily create dynamic URIs. In this case, the URI would be something like /projects/1 or /projects/2 when it is rendered. Visit http://localhost:4200/projects and confirm that you can click the links to see each project’s detail page.

Browser rendering of linked projects

Wrapping Up

We went through a lot in this tutorial, and hopefully, you’re keeping up and raring to learn more. If things aren’t quite going as expected, please refer to the source code in GitHub to see where things went wrong or leave a comment below.

In our next lesson, we’ll find out how to delete projects and tasks. We’ll also add time records to our tasks so we can keep track of how long they take to finish.

See you soon.

Leave a Reply

Your email address will not be published. Required fields are marked *