Angular+Firebase

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

Welcome to Part 3 of a series of tutorials about building web apps with Angular and Firebase. In Part 1, we covered the basic setup of a simple project management app. In Part 2, we wrote code to query a list of projects from the database. In this article, we’ll work on adding projects to Firebase from our app and creating master/detail relationships by adding tasks to our projects.

The source code for this project is available on GitHub.

As always, start off by opening your command line tool and navigating to your project directory. Then run ng serve -o to build the code and start up a local server.

Browser output of project

Creating New Projects

In order for users to add new projects, we’ll add a text box below the project list where a title can be entered. Open projects.component.html and add the following HTML at the bottom.

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

  <input type="text" placeholder="Project title">

  <button>Add Project</button>
</div>

Display of app in current state

Now we have a text box and a button, but they don’t do anything. We need to create a new property in our ProjectComponent class that will serve as the new project title, and we need to create a new method that will actually save the new project to Firebase. Here’s what our projects.component.ts file looks like afterward.

export class ProjectsComponent implements OnInit {
  projectsObservable: Observable<Project[]>;
  projects: Project[];
  newProjectTitle: string;

  constructor(
    private _dataService: DataService
  ) { }

  ngOnInit() {
    // Get projects
    this.projectsObservable = this._dataService.getList('projects');

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

  /**
   * Adds a new project to the data source.
   */
  addProject() {
    // TODO
  }
}

Update the Data Service to Add Objects

In the last article, we created a DataService class, but all it does currently is retrieve a list of objects. We need to update it so we can add objects now. Open data.service.ts and add a new addObject() method.

/**
 * Adds a new object to the database.
 * @param obj The object to add to the database.
 * @param path The path to the new object's parent.
 */
addObject<T>(obj: T, path: string): Promise<any> {
  return new Promise((resolve, reject) => {
    const ref = this._db.database.ref().child(path);

    ref.push(obj)
      .then(
        result => resolve(result),
        error => reject(error)
      );
  });
}

In our new addObject() method, we will pass in an object to add and the path to the database parent object. We will get back a promise that returns the results of a firebase.database.Reference.push() call.

Don’t worry too much about this for now. Just know that we are getting a reference to a Firebase object — such as projects — and we are pushing a new object to it. The push() method will automatically generate a unique key for our projects.

Now we can finish our addProject() method in projects.component.ts.

/**
 * Adds a new project to the data source.
 */
addProject() {
  this._dataService.addObject({ title: this.newProjectTitle }, 'projects');
}

We are simply calling the new method we added to our data service and passing in an object with its title set to the newProjectTitle property.

Finish Up the HTML

Next, we need to set up the text box and button that we added to the HTML. In Angular, we can bind a property to an HTML element to act as its data model. We do this using the ngModel attribute.

<input type="text" placeholder="Project title"
  [(ngModel)]="newProjectTitle">

We enclose it in square brackets and parentheses to indicate two-way data binding. In other words, if newProjectTitle is updated through code, it will update the text box, and if the text box is updated by the user, then the newProjectTitle variable will also update. If we only required one-way data binding, we could omit the parentheses, but in this case, we will be sending the newProjectTitle value to our data service when the user updates it.

If you save and check your browser now, you’ll notice that something isn’t right. The page is blank, and you’ll see an error in your browser’s dev tools. That’s because we need to specifically tell Angular that we are using functionality concerning form elements and will need some additional help. Do this by importing the FormsModule in your app.module.ts file as follows.

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { AngularFireModule } from 'angularfire2';
import { AngularFireDatabaseModule } from 'angularfire2/database';
import { AngularFireAuthModule } from 'angularfire2/auth';

And by adding the FormsModule as an import in the same file.

imports: [
  BrowserModule,
  FormsModule,
  AngularFireModule.initializeApp(firebaseConfig),
  AngularFireDatabaseModule,
  AngularFireAuthModule
]

Now your page should be working again.

Let’s update the button now so that it actually creates our new project when clicked.

<button (click)="addProject()">Add Project</button>

We do this by adding a click event listener that calls our addProject() function. Go ahead and give it a try. Save your changes, then enter a project title in the text box. Click the button, and it should immediately show up in your project list.

A Couple Accessibility Improvements

Before finishing up our functionality for adding projects, let’s make a couple of small accessibility improvements. When we click the Add Project button, it would be nice if the text box cleared itself. In projects.component.ts, we can do this by setting the newProjectTitle property to an empty string. Because of two-way data binding, the text box value will be set to an empty string as well.

addProject() {
  this._dataService.addObject({ title: this.newProjectTitle }, 'projects');
  this.newProjectTitle = '';
}

It would also be nice if we could press Enter after typing the new project title instead of having to click the button. This is done with the keyup.enter attribute.

<input type="text" placeholder="Project title"
  [(ngModel)]="newProjectTitle"
  (keyup.enter)="addProject()">
Note: Just like the click attribute on our button, the keyup.enter attribute is enclosed in parentheses. Attributes in parenthesis indicate event handlers in Angular.

Structuring Firebase for Master/Detail Relationships

Next, we’ll want the ability to show and edit tasks for each of our projects. This is known as a master/detail relationship — we have a master list of projects and each project has details.

In relational databases, this is a one-to-many relationship which requires the use of foreign key constraints. In Firebase, we don’t have foreign keys, and your first instinct may be to nest the tasks for each project below the project itself. For example:

Incorrect nesting of data

Unfortunately, this will create performance issues as we continue. Any time we want to get a task by its key, we will have to look through each project first.

You will often hear developers talk of “flattening” the database. It means the opposite of nesting your data. Here is a better structure:

Recommended Firebase structure

It may seem redundant to have tasks listed in two places, but it’s necessary. With the above structure, we can easily find tasks belonging to a project (because we have the task keys under projects), and we can also find tasks individually (under the tasks object).

Note: If you’re wondering why the task keys under the projects object have a value of true, it’s because Firebase requires every object to either have a child object or a value. We set the value to true to meet that requirement.

Adding Tasks from the App

When we worked on our list of projects, we started by adding some sample data to Firebase. We could do the same with tasks, but let’s start with a programmatic approach this time and create a form for adding a task.

Create the HTML Form

Because tasks belong to a project, we need users to be able to select the project and then enter a title for the task. As we build out the app, tasks will have more than just titles, but we want to keep things simple for now. Add the following HTML to the bottom of projects.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>

This should look familiar to the HTML we created for adding a new project. The major difference is the <select> tag which is used for selecting the project. We use ngModel just as we did on the <input> tag to tie it to a property that we’ll create in a moment.

To create the <option> tags, we use *ngFor to loop through the projects, and we set the value of each option to the project’s key. The key doesn’t exist yet on our projects model, so let’s add it now in project.model.ts.

export interface Project {
  key: string;
  title: string;
}

While we’re at it, let’s add a tasks property as well. We’ll need it later to reference the tasks that belong to each project.

export interface Project {
  key: string;
  title: string;
  tasks: {};
}
Note: Sometimes Angular doesn’t rebuild correctly when you make an update to a model. If you get a compile error later complaining about the key property we added, just ctrl+c and run ng serve to restart it.

Now, we need to add our new properties in projects.components.ts.

export class ProjectsComponent implements OnInit {
  projectsObservable: Observable<Project[]>;
  projects: Project[];
  newProjectTitle: string;
  newProjectKey: string;
  newTaskTitle: string;

We should also stub out the addTask() method.

/**
 * Adds a new task to the data source.
 */
addTask() {
  // TODO
}

Display of current browser rendering

If you save and take a look at the drop-down selector, you’ll see that it indeed shows the available projects, but there’s a problem. The actual <option> values are undefined. You can confirm this by looking in your browser’s dev tools. The problem is our data service is not providing the keys for referencing the projects.

Open data.service.ts, and take a look at our getList() method.

getList<T>(path: string): Observable<T[]> {
  return this._db.list(path).valueChanges();
}

Notice the valueChanges() method. If we look at its documentation, it tells us that “Snapshot metadata is stripped” and that you should use it when “you just need a list of data.” In other words, it does not give us the key for each of our projects.

To me, this is a big oversight given how useful it is to have access to each object’s key. Because of this, we need to use the far more complex snapshotChanges() method. Here is our revised getList() method.

getList<T>(path: string): Observable<T[]> {
  return this._db.list(path).snapshotChanges().map(actions => {
    const list: T[] = actions.map(action => {
      const obj: T = action.payload.val() as T;

      obj['key'] = action.key;

      return obj;
    });

    return list;
  });
}

The snapshotChanges() method returns an observable of an array of AngularFireActions of Firebase DataSnapshots. If you’re new to Angular or Firebase, then that is some confusing jargon, but it does give us access to each project’s key. Feel free to comment if you know of a better, more intuitive way.

The <option> elements should have values now, but we need to set a default value. Right now, newProjectKey is undefined, so the drop-down box shows an empty value at the start. We’ll set the default value to the first project we get.

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;
    }
  });
}

Code the addTask() Method

Our data service already has an addObject() method that we used for creating new projects. We can use it again for adding tasks in projects.component.ts.

addTask() {
    // Add to "tasks" object
    this._dataService.addObject({
      project: this.newProjectKey,
      title: this.newTaskTitle
    }, 'tasks')

Nothing surprising here. We are adding a new object that has a project and a title to a tasks object in the database. However, remember our discussion about the database structure earlier? In addition to the new tasks object in the database, we also need to add the new task’s key to the projects object.

Fortunately, our addObject() method returns a promise that will resolve to a reference of the newly created task. We can grab the new task key from that reference and add it to the Firebase projects object.

This time, we aren’t creating a new object but updating an existing project object. Time to create an updateObject() method in our data service. Add the following to your data.service.ts file.

/**
 * Updates an object in the database.
 * @param obj An object with updated values.
 * @param path The path of the object to update.
 */
updateObject<T>(obj: T, path: string): Promise<any> {
  return new Promise((resolve, reject) => {
    const ref = this._db.object(path);

    ref.update(obj)
      .then(
        result => resolve(result),
        error => reject(error)
      );
  });
}

All we’re doing here is getting a reference to a database object — such as one of our projects — and calling update() on it. The update() method only updates the properties that are provided to it, which is nice, because it means we don’t have to fully reconstruct all the project properties just to update the tasks.

Now, back to projects.component.ts where we will call our new updateObject() method.

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
      );
    });
}

Let’s break that down. We use then() to get a reference to our new task we added to the database. Next, we find the project that will be assigned the new task, and if it doesn’t already have tasks assigned to it, we assign it an empty object before assigning the task we just created.

Finally, we call our new updateObject() method with the updated tasks and tell it the path to the object that should be updated in the database.

Test it Out

Try adding some tasks to your projects. You won’t see them appear in the browser yet, but you can check the database to ensure that they are being added correctly.

Before we wrap up, let’s clear out that new task text box like we did with the new project text box by updating the last part of our addTask() method.

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

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

Wrapping Up

We’re making good progress, but there’s still a lot to do. Next time, we’ll work on routing and displaying the tasks on a separate page from the projects.

Make sure to subscribe to our newsletter to get the latest updates.

Continue to Part 4

1 Comment

  1. isabelle diez says:

    very good! thank you so much! can’t wait for part 4 🙂

Leave a Reply

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