Using PouchDB with Ionic - part 2

December 11, 2016, 6:08 pm Categories:

Categories

A quick recap

** UPDATED FOR IONIC 3 **

Okay, so I'm assuming you've read through part 1 of this tutorial (if you haven't done so then go there now as you won't have the background material and application code to continue) and, as a result of doing so, have successfully completed the following steps:

  • Created a blank Ionic app named comics
  • Installed the required Apache Cordova plugins
  • Created the AddPage component (which we'll cover during this part of the tutorial)
  • Created a Database and Image service (and added the required application logic to both of these files)

If you answered yes to all of the above then great - you've laid the initial foundations for integrating PouchDB with Ionic, which we'll progress to completion in this half of the tutorial.

Over the following sections we'll cover:

  • Adding the HomePage logic, styling and templating
  • Adding the AddPage logic, styling and templating

As usual there's going to be a fair amount of code involved and, by the end of the tutorial, you should have a fully functioning app that implements the following features:

  • Ability to access device camera and photo library
  • Ionic UI components (I.e. ion-input, ion-textarea, ion-range, ToastController etc)
  • Create and amend database entries
  • Remove database entries
  • List database entries

All things being well when launching your app, barring code typos and bugs, you should see something akin to the following:

Ionic app using PouchDB to store data about 80's comic characters

Ready? Let's go...

Filling out the HomePage Component

What better place to start than with the application landing page?

Open the comics/src/pages/home/home.ts file and implement the following code:

import { Component } from '@angular/core';
import { NavController } from 'ionic-angular';
import { Database } from '../../providers/database';

@Component({
  selector: 'page-home',
  templateUrl: 'home.html'
})
export class HomePage {

   public hasComics     : boolean = false;
   public comics        : any;

   constructor( public navCtrl    : NavController, 
              public DB       : Database) 
   {
    
   }



   ionViewWillEnter()
   {
      this.displayComics();
   }



   displayComics()
   {
      this.DB.retrieveComics().then((data)=>
      {
         let existingData = Object.keys(data).length; 
         if(existingData !== 0)
   {
            this.hasComics  = true;
            this.comics   = data;
   }
   else
   {
      console.log("we get nada!");
   }
      });
   }



   addCharacter()
   {
      this.navCtrl.push('Add');
   }



   viewCharacter(param)
   {
      this.navCtrl.push('Add', param);
   }

}

As you can see there's only a few methods in this class:

  • displayComics
  • addCharacter
  • viewCharacter

The displayComics method simply retrieves all saved entries from the database, supplying them back to the HTML template through the comics property.

This method is called from within the ionViewWillEnter navigation lifecycle event, which is executed every time the HomePage component view is about to be entered. Adopting this approach ensures that the on screen listings are always refreshed with the latest data retrieved from the database whenever the application landing screen is loaded.

The addCharacter method simply navigates to the Add page view so that data for a specific comic character can be entered into the database whereas the viewCharacter method allows the details for an already existing entry to be viewed instead.

Nothing terribly challenging or complex with this class as the heavy lifting has been delegated to the Database service that we created in part 1 of this tutorial.

Now we need to craft the HTML for the HomePage component.

Open the comics/src/pages/home/home.html template and add the following HTML code:

<ion-header>
  <ion-navbar>
    <ion-title>
      80's Comic Faves
    </ion-title>
  </ion-navbar>
</ion-header>

<ion-content padding>
  

   <ion-item>
      <button 
         class="add"
         ion-button 
         item-right
         icon-right
         margin-bottom
         color="primary"
         (click)="addCharacter()">
            Add a character
            <ion-icon name="add"></ion-icon>
      </button>
   </ion-item>



   <div *ngIf="hasComics">
      <ion-list>

         <ion-item *ngFor="let comic of comics">
            <ion-thumbnail item-left>
               <img [src]="comic.image">
            </ion-thumbnail>
            <h2>{{ comic.character }} </h2>
            <button 
               ion-button 
               clear 
               item-right
               (click)="viewCharacter({'key':comic.id, 'rev':comic.rev })">
                  View
            </button>
         </ion-item>

      </ion-list>
   </div>


</ion-content>

The UI for the landing page is fairly simple - an Add Character button at the top right of the page (that, unsurprisingly, calls the addCharacter method of the HomePage class) followed, underneath, by a listing of all the retrieved entries from the application database.

Once there are entries saved within the database the HTML code will display these as listings using the following UI:

Comic character entries

These listings are wrapped within an ngIf conditional directive which is used to determine whether or not the block of HTML should be displayed based on the value of the hasComics property. If you remember this property was set in the HomePage class to an initial value of false, only being changed to true when data was able to be retrieved through the displayComics method.

Each listed entry is then assigned a click event that calls the viewCharacter method, passing in the associated _id and _rev values for that entry so that it's data can be accessed in full when the app navigates to the AddPage component.

At this point if you were to publish the app to your device you'd notice the Add Character button looks 'skinny' with text that's difficult to read.

Let's quickly amend that.

Add a very quick and simple style rule for the Add Character button to the comics/src/pages/home/home.scss file:

page-home {
   .add {
      font-size: 1.2em;
      padding: 1.5em 1em !important;
   }
}

You'll also notice that apart from the Add Character button the screen is blank.

This is because we have no entries available for display yet.

As an exercise for the reader why not add some logic/HTML here to output a message informing the user that there are no entries stored in the database?

Now that we've implemented the logic, styling and templating for the landing page of the Comics application we need to do the same for the AddPage component.

Adding and editing records

To simplify development and reduce any duplication of code it makes sense to use the same screen that we add entries to the app database with to also double up for editing those entries too.

Additionally we'll also need to ensure that our data entry form is validated prior to submitting data to the database.

With these in mind it makes sense to use Angular 2's ngModel directive and FormBuilder class functionality.

The AddPage component then will involve a little more coding than that of the landing page but the majority of the heavy lifting will be handled by our Database and Image services as well as additional Ionic/Angular packages where required.

Let's make a start on this by opening the comics/src/pages/add/add.ts file and implementing the following code:

import { Component } from '@angular/core';
import { IonicPage, NavController, NavParams, ToastController } from 'ionic-angular';
import { FormGroup, FormControl, Validators, FormBuilder } from '@angular/forms';
import { Image } from '../../providers/image';
import { Database } from '../../providers/database';


@IonicPage()
@Component({
  selector: 'page-add',
  templateUrl: 'add.html'
})
export class Add {

   public form          : FormGroup;
   public comicCharacter  : any;
   public comicTitle    : any;
   public comicRating       : any;
   public comicNote       : any;
   public comicImage      : any;
   public characterImage    : any;
   public recordId        : any;
   public revisionId    : any;
   public isEdited        : boolean = false;
   public hideForm        : boolean = false;
   public pageTitle         : string;
    

   constructor( public navCtrl    : NavController,
            public NP       : NavParams,
            public fb       : FormBuilder,
              public IMAGE        : Image,
              public DB         : Database,
              public toastCtrl    : ToastController) 
   {
      this.form = fb.group({
         "character"            : ["", Validators.required],
         "title"                : ["", Validators.required],
         "rating"               : ["", Validators.required],
         "image"                : ["", Validators.required],
         "note"                 : ["", Validators.required]
      });

      this.resetFields();


      if(NP.get("key") && NP.get("rev"))
      {
         this.recordId      = NP.get("key");
         this.revisionId    = NP.get("rev");
         this.isEdited      = true;
         this.selectComic(this.recordId);
         this.pageTitle     = 'Amend entry';
      }
      else
      {
         this.recordId      = '';
         this.revisionId    = '';
         this.isEdited      = false;
         this.pageTitle     = 'Create entry';
      }
   }



   selectComic(id)
   {
      this.DB.retrieveComic(id)
      .then((doc)=>
      {
         this.comicCharacter    = doc[0].character;
         this.comicTitle      = doc[0].title;
         this.comicRating       = doc[0].rating;
         this.comicNote       = doc[0].note;
         this.comicImage      = doc[0].image;
         this.characterImage    = doc[0].image;
         this.recordId        = doc[0].id;
         this.revisionId      = doc[0].rev;
      });
   }
  


   saveComic()
   {
      let character : string     = this.form.controls["character"].value,
        title   : string       = this.form.controls["title"].value,
          rating    : number     = this.form.controls["rating"].value,
          image     : string     = this.form.controls["image"].value,
          note      : string     = this.form.controls["note"].value,
          revision  : string     = this.revisionId,
          id      : any        = this.recordId;

      if(this.recordId !== '')
      {
         this.DB.updateComic(id, title, character, rating, note, image, revision)
     .then((data) =>
     {
        this.hideForm     = true;
        this.sendNotification(`${character} was updated in your comic characters list`);          
     });
      }
      else
      {
         this.DB.addComic(title, character, rating, note, image)
         .then((data) =>
         {
            this.hideForm     = true;
            this.resetFields();
            this.sendNotification(`${character} was added to your comic characters list`);          
         });
      }
   }



   takePhotograph()
   {
      this.IMAGE.takePhotograph()
      .then((image)=>
      {
         this.characterImage = image.toString();
          this.comicImage    = image.toString();
      })
      .catch((err)=>
      {
         console.log(err);
      });
   }



   selectImage()
   {
      this.IMAGE.selectPhotograph()
      .then((image)=>
      {
         this.characterImage = image.toString();
         this.comicImage   = image.toString();
      })
      .catch((err)=>
      {
         console.log(err);
      });
   }



   deleteComic()
   {
      let character;

      this.DB.retrieveComic(this.recordId)
      .then((doc)=>
      {
         character = doc[0].character;
         return this.DB.removeComic(this.recordId, this.revisionId);
      })
      .then((data) =>
      {
         this.hideForm  = true;
         this.sendNotification(`${character} was successfully removed from your comic characters list`);          
      })
      .catch((err)=>
      {
         console.log(err);
      });
   }



   resetFields() : void
   {
      this.comicTitle         = "";
      this.comicRating      = "";
      this.comicCharacter       = "";
      this.comicNote      = "";
      this.comicImage     = ""; 
      this.characterImage   = "";  
   }



   sendNotification(message)  : void
   {
      let notification = this.toastCtrl.create({
             message    : message,
             duration     : 3000
        });
      notification.present();
   }

}

As with all Ionic page components we began by importing select modules that will be used to supply functionality required by the class, specifically the following:

import { IonicPage, NavController, NavParams, ToastController } from 'ionic-angular';
import { FormGroup, FormControl, Validators, FormBuilder } from '@angular/forms';
import { Image } from '../../providers/image';
import { Database } from '../../providers/database';

In the Add page class we initially define a series of publicly available properties which will be used as models and form controls for the HTML form as well as contextually setting the title for the page (depending on whether we are creating or editing an entry):

public form      : FormGroup;
public comicCharacter   : any;
public comicTitle   : any;
public comicRating      : any;
public comicNote    : any;
public comicImage     : any;
public characterImage   : any;
public recordId     : any;
public revisionId     : any;
public isEdited     : boolean = false;
public hideForm     : boolean = false;
public pageTitle        : string;

The constructor function then sets up form validation rules using Angular's FormBuilder class, calls the resetFields method to 'reset' the HTML form to a blank state and finally uses a conditional statement to determine whether navigation parameters have been supplied or not.

If the key and rev parameters have been supplied to the page then the script 'knows' that we want to edit a record from the database matching those supplied parameters otherwise we are looking to simply add a new entry to the database instead:

constructor( public navCtrl  : NavController,
           public NP      : NavParams,
           public fb      : FormBuilder,
           public IMAGE       : Image,
           public DB        : Database,
           public toastCtrl   : ToastController) 
{
   this.form = fb.group({
      "character"            : ["", Validators.required],
      "title"                : ["", Validators.required],
      "rating"               : ["", Validators.required],
      "image"                : ["", Validators.required],
      "note"                 : ["", Validators.required]
   });

   this.resetFields();


   if(NP.get("key") && NP.get("rev"))
   {
      this.recordId     = NP.get("key");
      this.revisionId     = NP.get("rev");
      this.isEdited     = true;
      this.selectComic(this.recordId);
      this.pageTitle    = 'Amend entry';
   }
   else
   {
      this.recordId       = '';
      this.revisionId         = '';
      this.isEdited       = false;
      this.pageTitle      = 'Create entry';
      }
   }

The first of our methods - selectComic - is used to retrieve a database entry matching that of a supplied record id. Where a match is found individual fields from the returned entry are mapped to their respective keys which will be used as ngModel directives in the HTML form.

This allows the record to be amended, if and where required:

selectComic(id)
{
   this.DB.retrieveComic(id)
   .then((doc)=>
   {
      this.comicCharacter     = doc[0].character;
      this.comicTitle     = doc[0].title;
      this.comicRating      = doc[0].rating;
      this.comicNote      = doc[0].note;
      this.comicImage       = doc[0].image;
      this.characterImage     = doc[0].image;
      this.recordId       = doc[0].id;
      this.revisionId       = doc[0].rev;
   });
}

Next up is the saveComic method, used for adding/editing entries.

Here we retrieve the values to be added to the database from their respective form controls and, using the recordId property, determine whether we are adding a new entry or updating an existing record:

saveComic()
{
   let character  : string    = this.form.controls["character"].value,
       title    : string    = this.form.controls["title"].value,
       rating       : number  = this.form.controls["rating"].value,
       image      : string  = this.form.controls["image"].value,
       note         : string  = this.form.controls["note"].value,
       revision     : string  = this.revisionId,
       id           : any     = this.recordId;

   if(this.recordId !== '')
   {
      this.DB.updateComic(id, title, character, rating, note, image, revision)
      .then((data) =>
      {
         this.hideForm    = true;
         this.sendNotification(`${character} was updated in your comic characters list`);         
      });
   }
   else
   {
      this.DB.addComic(title, character, rating, note, image)
      .then((data) =>
      {
         this.hideForm    = true;
         this.resetFields();
         this.sendNotification(`${character} was added to your comic characters list`);         
      });
   }
}

Following from this are 2 methods: takePhotograph and selectImage which handle capturing images through the HTML form, courtesy of the Image service that we created in the first part of this tutorial.

You'll notice in both methods we return the captured image data in a promise, assigning that to public properties which will be used by ngModel directives on our HTML form.

We also convert the returned data to a string so that it can be embedded as a Data URL in the page:

takePhotograph()
{
   this.IMAGE.takePhotograph()
   .then((image)=>
   {
      this.characterImage   = image.toString();
      this.comicImage       = image.toString();
   })
   .catch((err)=>
   {
      console.log(err);
   });
}



selectImage()
{
   this.IMAGE.selectPhotograph()
   .then((image)=>
   {
      this.characterImage   = image.toString();
      this.comicImage       = image.toString();
   })
   .catch((err)=>
   {
      console.log(err);
   });
}

When called from the buttons in the HTML form the takePhotograph method will access the device camera (as shown on the left of the following image) or, in the case of the selectImage method, access the device Photo Library (as shown on the right of the following image).

Either way (and make of my choice of characters what you will!) adding an image to your form is a piece of cake:

Demonstrating the Camera and Photo Library functionality on an iPhone

To handle removing entries from the database we have a deleteComic method.

This simply retrieves the character name from the record we want to delete, calls the Database service removeComic method and, within the returned promise (assuming the operation was successful), supplies a message to the user informing them that the record for that character has been removed while also updating a property to hide the HTML form on the page:

deleteComic()
{
   let character;

   this.DB.retrieveComic(this.recordId)
   .then((doc)=>
   {
      character = doc[0].character;
      return this.DB.removeComic(this.recordId, this.revisionId);
   })
   .then((data) =>
   {
      this.hideForm   = true;
      this.sendNotification(`${character} was successfully removed from your comic characters list`);         
   })
   .catch((err)=>
   {
      console.log(err);
   });
}

Finally we complete the class with the following methods:

  • resetFields which handles clearing the HTML form input fields a notification to the user
  • sendNotification which returns messages back to the user in the form of the ToastController component

Nothing too mind boggling logic wise here:

resetFields() : void
{
   this.comicTitle        = "";
   this.comicRating     = "";
   this.comicCharacter      = "";
   this.comicNote       = "";
   this.comicImage      = ""; 
   this.characterImage    = "";  
}



sendNotification(message)  : void
{
   let notification = this.toastCtrl.create({
          message     : message,
          duration    : 3000
       });
   notification.present();
}

With the logic for the AboutPage class in place we can now turn our attention towards implementing the required HTML.

Data entry

The HTML form for the Add page component isn't particularly complex but will make heavy use of Angular 2's ngModel directives and Form Controls (which we've already defined the properties for in the add.ts file from the previous section).

The ngModel directives will come in handy where we want to use the form to edit an existing record as their two-way data binding capabilities will allow data to be matched to selected fields on the form in addition to being able to clear the form of data too.

The Form Controls will also allow the form to be validated, prior to submission, by disallowing the form button to be used until each field has data entered or selected.

Be aware this is very basic validation only as we are simply checking to see if the field actually has data. We won't be analysing the data entered in any way, shape or form to see if it matches certain criteria - we just want to know that the form has data entered into it, that's all.

As for the fields themselves they'll each use specific Ionic UI components and consist of the following types:

  • Publication Name
  • Character Name
  • Character Image
  • Character Rating
  • Character Description

So, now we know what to expect, let's edit the comics/src/pages/add/add.html template to match the following code:

<ion-header>
   <ion-navbar>
      <ion-title>{{ pageTitle }}</ion-title>
   </ion-navbar>
</ion-header>


<ion-content padding>

   <div>
      <ion-item *ngIf="isEdited && !hideForm">
         <button 
            ion-button 
      item-right
      color="secondary" 
      text-center 
      block 
      (click)="deleteComic()">Remove this Entry?</button>
      </ion-item>


      <div *ngIf="hideForm">
         <ion-item class="post-entry-message" text-wrap>
            <h2>Success!</h2>
       <p>Maybe you'd like to edit an existing entry or add a new record?</p>
       <p>Simply go back to the home page and select the option you want to pursue.</p>
         </ion-item>
      </div>



      <div *ngIf="!hideForm">
         <form [formGroup]="form" (ngSubmit)="saveComic()">

            <ion-list>
               <ion-item-group>
                  <ion-item-divider color="light">Publication Name</ion-item-divider>
                  <ion-item>
                     <ion-label>Please select: </ion-label>
                     <ion-select 
                        class="select"
      interface="action-sheet"
      formControlName="title" 
            block
      [(ngModel)]="comicTitle">
      <ion-option value="Battle/Action Force">Battle/Action Force</ion-option>
      <ion-option value="Eagle">Eagle</ion-option>
      <ion-option value="2000AD">2000AD</ion-option>
      <ion-option value="Scream">Scream</ion-option>
      <ion-option value="Other">Other</ion-option>
                     </ion-select>
                  </ion-item>
               </ion-item-group>


               <ion-item-group>
                  <ion-item-divider color="light">Character Name</ion-item-divider>
                  <ion-item>
                     <ion-input 
                        type="text" 
                        placeholder="Enter a name..." 
                        formControlName="character" 
                        [(ngModel)]="comicCharacter"></ion-input>
                  </ion-item>
               </ion-item-group>


               <ion-item-group>
                  <ion-item-divider color="light">Character Image</ion-item-divider>
                  <ion-item>
                     <a 
                        ion-button 
      block
      margin-bottom
      color="primary"
      (click)="takePhotograph()">
         Take a photograph
                     </a>
                  </ion-item>

                  <ion-item>
                     <a 
                        ion-button 
      block
      margin-bottom
      color="secondary"
      (click)="selectImage()">
         Select an existing image
                     </a>
                  </ion-item>

                  <ion-item>
                     <img [src]="characterImage">
          <input type="hidden" name="image" formControlName="image" [(ngModel)]="comicImage">
                  </ion-item>
               </ion-item-group>



               <ion-item-group>
                  <ion-item-divider color="light">Character Rating</ion-item-divider>
                  <ion-item>
                     <ion-label text-left>Rating for this character?</ion-label>
                     <ion-range 
                        class="textarea"
      formControlName="rating" 
      min="1" 
      max="5" 
      step="1" 
      snaps="true" 
      secondary 
      [(ngModel)]="comicRating">
                        <ion-label range-left>1</ion-label>
      <ion-label range-right>5</ion-label>
                     </ion-range>
                  </ion-item>
               </ion-item-group>


               <ion-item-group>
                  <ion-item-divider color="light">Character Description</ion-item-divider>
                  <ion-item>
                     <ion-textarea 
                        placeholder="Additional notes..." 
      formControlName="note" 
      rows="6"
      [(ngModel)]="comicNote"></ion-textarea>
                  </ion-item>
               </ion-item-group>


               <ion-item>
                  <button 
                     ion-button 
         color="primary" 
         text-center 
         block 
         [disabled]="!form.valid">Save Entry</button>
               </ion-item>



            </ion-list>



         </form>
      </div>
   </div>


</ion-content>

Which, when viewed in the app, gives us the following UI:

Add comic character entry form UI

As you can see we've encapsulated each form element within an and used an component to clearly delineate each section.

We've also used the pageTitle property from our class to add the text for the page heading. The value for this text is determined based on whether or not we are creating a new entry or amending an existing entry.

One important point to notice with the HTML centers around our use of the tag for selecting comic publications.

In the above code we've added an interface attribute with a value of action-sheet. This allows us to display the select menu as an action sheet component rather than the standard modal UI that we would usually see when interacting with the component:

Action sheet behaviour implemented for <ion-select> menu component

Which looks pretty cool right?

One important point to note here is that you can't have more than 6 options available when implementing a select menu in the form of an action sheet UI.

Next we need to add the following style rules to the comics/src/pages/add/add.scss file so that certain items in the form are a little more aesthetically pleasing as well as formatting our post data entry message too:

page-add {
   
   ion-item-divider {
      margin: 2em 0 0 0;
   }

   a,
   button {
      font-size: 1.2em;
      padding: 2.5em 1.5em !important;
   }

   .post-entry-message {
      border-bottom: none;
      
      h2 {
         padding: 0 0 0.5em 0;
      }

      p {
         padding: 0 0 1.5em 0;
         color: rgb(68, 68, 68);
      }
   }

}

Tweaking the component module

Finally there may be some changes we need to make in the application root module - /comics/src/pages/add/add.module.ts.

I say might need to be made as, hopefully, forthcoming iterations of the Ionic framework will automatically import the IonicPageModule instead of the IonicModule.

For now though change all references from IonicModule to IonicPageModule - so that your application root module appears like the one displayed below: 

import { NgModule } from '@angular/core';
import { IonicPageModule } from 'ionic-angular';
import { Add } from './add';

@NgModule({
  declarations: [
    Add,
  ],
  imports: [
    IonicPageModule.forChild(Add),
  ],
  exports: [
    Add
  ]
})
export class AddModule {}

If these changes aren't made the Add page component can't be bootstrapped and made available for navigation which will cause your build to fail.

Not what you want to see happen!

With those amendments in place that now concludes all of the coding required for the app.

If you were to attach a mobile device to your computer and execute the following commands (assuming there were no build/run errors):

ionic build ios
ionic run ios --device

You should be able to run the app on your device and add/edit/remove and view your comic entries with ease!

Ionic app using PouchDB to store data about 80's comic characters

Wrapping up

Here we conclude the second, and final, part of this tutorial on using PouchDB with Ionic to create a comic characters app.

We've made use of npm packages, Apache Cordova/Ionic Native plugins, Ionic UI components and Angular modules in addition to generating services and pages using the Ionic CLI tool.

As a result of combining these with our efforts we've managed to create an application that allow us to capture images, input data and manage entries in a database.

Not bad going methinks!

We could, of course, extend our application with features such as validating our forms to ensure duplicate data isn't entered or is restricted to a certain format (I.e. a maximum number of allowed characters), amongst other features, but I'll leave this as an exercise for the more adventurous amongst you to embark on.

I'd love to know what my readers think of this tutorial so please feel free to leave any comments, suggestions or thoughts on this tutorial in the comments section below.

If you enjoyed what you've read here then please consider signing up to my mailing list and, if you haven't done so already, take a look at my e-book: Mastering Ionic for information about working with alternative data storage methods in Ionic.

Tags

Categories

Post a comment

All comments are welcome and the rules are simple - be nice and do NOT engage in trolling, spamming, abusiveness or illegal behaviour. If you fail to observe these rules you will be permanently banned from being able to comment.

Top