CouchDB, PouchDB, and Ionic 2: Adding Comments

Datetime:2017-04-06 05:41:43         Topic: CouchDB          Share        Original >>
Here to See The Original Article!!!

Over the past few weeks, we have been building out a simple blogging application that is built with Ionic 2, CouchDB, and PouchDB. We have made quite a lot of progress so far, and in this tutorial, we will be adding the finishing touches on the core functionality. We already have the ability to add and view posts, and in this tutorial, we will be adding the ability to add and view comments for specific posts.

The approach we use for comments will be similar to the approach for posts, but there are some slight differences. When dealing with posts we could just grab every post, but when dealing with comments we only want to retrieve a specific set of comments from the database.

Before We Get Started

Before you go through this tutorial, you should have at least a basic understanding of Ionic 2 concepts. You must also already have Ionic 2 set up on your machine.

If you’re not familiar with Ionic 2 already, I’d recommend reading my Ionic 2 Beginners Guide first to get up and running and understand the basic concepts. If you want a much more detailed guide for learning Ionic 2, then take a look at Building Mobile Apps with Ionic 2 .

You should also have already completed the previous CouchDB tutorial in this series.

1. Add Additional Pages and Providers

Run the following commands to generate the required components:

We are going to need to add a couple more dependencies before we get started – we will be adding a new page to add comments, and a new provider to handle adding and retrieving comments.

Run the following commands:

ionic g page AddComment
ionic g provider Comments

Modify src/app/app.module.ts to reflect the following:

import { NgModule, ErrorHandler } from '@angular/core';
import { IonicApp, IonicModule, IonicErrorHandler } from 'ionic-angular';
import { MyApp } from './app.component';
import { HomePage } from '../pages/home/home';
import { ViewPostPage } from '../pages/view-post/view-post';
import { AddPostPage } from '../pages/add-post/add-post';
import { AddCommentPage } from '../pages/add-comment/add-comment';
import { Data } from '../providers/data';
import { Posts } from '../providers/posts';
import { Comments } from '../providers/comments';

@NgModule({
  declarations: [
    MyApp,
    HomePage,
    ViewPostPage,
    AddPostPage,
    AddCommentPage
  ],
  imports: [
    IonicModule.forRoot(MyApp)
  ],
  bootstrap: [IonicApp],
  entryComponents: [
    MyApp,
    HomePage,
    ViewPostPage,
    AddPostPage,
    AddCommentPage
  ],
  providers: [{provide: ErrorHandler, useClass: IonicErrorHandler}, Data, Posts, Comments]
})
export class AppModule {}

2. Handling Changes (smarter)

In the last tutorial, we added some functionality that would live update the data. If the data changed in the database, then it would be instantly reflected inside of the application without requiring a refresh. What we did was listen for a change from the Change API, and then just refetched the post data.

This approach is simple and will work, but the Change API provides us with enough data that it isn’t necessary for us to refetch the view every time there is a change. The data we get here:

this.dataService.db.changes({live: true, since: 'now', include_docs: true}).on('change', (change) => {
            console.log(change);
        });

contains enough information to tell us what document has been changed, and how it has been changed (is it a new document? has an existing document been updated? deleted?). We can use that information to modify the data we already have stored locally in our application, rather than making another HTTP request to reload the data.

Let’s modify our Posts provider to make updates in place, rather than refetching the data from the database again.

Modify src/providers/posts.ts to reflect the following:

import { Injectable, NgZone } from '@angular/core';
import { Data } from './data';
import { Subject } from 'rxjs/Subject';

@Injectable()
export class Posts {

    posts: any;
    postSubject: any = new Subject();   

    constructor(public dataService: Data, public zone: NgZone) {

        this.dataService.db.changes({live: true, since: 'now', include_docs: true}).on('change', (change) => {
            if(change.doc.type === 'post'){
                this.changePost(change);
            }
        });

    }

    getPosts(){

        this.emitPosts();

        return this.postSubject;

    }

    addPost(post): void {
        this.dataService.db.put(post);
    }

    emitPosts(): void {

        this.zone.run(() => {

            this.dataService.db.query('posts/by_date_published').then((data) => {

                let posts = data.rows.map(row => {
                    return row.value;
                });

                this.posts = posts;
                this.postSubject.next(this.posts);

            });

        });

    }

    changePost(change): void {

        let changedDoc = null;
        let changedIndex = null;

        // Find the affected document (if any)
        this.posts.forEach((doc, index) => {

            if(doc._id === change.id){
                changedDoc = doc;
                changedIndex = index;
            }

        });

        //A document was deleted - remove it
        if(change.deleted){
            this.posts.splice(changedIndex, 1);
        } else {

            //A document was updated - change it
            if(changedDoc){
                this.posts[changedIndex] = change.doc;
            } 

            //A document was added - add it
            else {
                this.posts.push(change.doc); 
            }

        }

        this.postSubject.next(this.posts);

    }

}

Instead of triggering the emitPosts function when a change is detected, we instead pass the change information on to the changePost function. The change information will contain the documents id, so we search through our existing posts array for a document that matches that id.

We then check the deleted property of the change to see if the document has been deleted, if it has been we remove it from our array. If it has not been deleted then the document must have either been modified, or it is an entirely new document. So if the document already exists in our array we update it with the new document, but if it does not exist in our array then it must be a new document so we add it to the array.

Once we have made the appropriate changes, we trigger the next method on the Subject so that anything that is subscribed to it will be notified of the change.

The only downside to our approach is that if the datePublished were to be modified then our data will be out of order. We could also handle this case on the client side if we wanted (i.e. manually resort the array by date), but we’re not going to be implementing this scenario (yet, at least).

3. Creating a Comments View

In order to retrieve posts we created a “design document” with a “view” that would allow us to retrieve all of the posts by the date they were published by using a “map” function. This is the primary way to query data with a CouchDB database, so if you have forgotten what “views” are all about it might be worthwhile to go back and read Querying Data with MapReduce again.

We are going to create another view with a map function for our comments, but rather than creating a view that will use the date the comment was published as the key, we will be using the id of the post that the comment belongs to as they key. This will allow us to easily grab every comment for a particular post.

Add the following document to your CouchDB database

{
    "_id": "_design/comments",
    "language": "javascript",
    "views": {
        "by_post_id": {
            "map": "function(doc){ if(doc.type == \"comment\"){emit(doc.post, doc);} }"
        }
    }
}

In creating this view, the map function will loop through every document in the database and if it finds one with a type of comment it will emit a row of data with the post value (which is the id of the post it belongs to) as the key, and the document itself as the value.

We will now be able to query this view to retrieve our comment data.

4. Adding Comments

We have our view set up for grabbing the comments data, now we just need to implement our Comments provider which will handle retrieving that data.

Modify src/providers/comments.ts

import { Injectable, NgZone } from '@angular/core';
import { Data } from './data';
import { Subject } from 'rxjs/Subject';

@Injectable()
export class Comments {

    comments: any = [];
    commentSubject: any = new Subject();    

    constructor(public dataService: Data, public zone: NgZone) {

        this.dataService.db.changes({live: true, since: 'now', include_docs: true}).on('change', (change) => {
            if(change.doc.type === 'comment' && this.comments.length > 0){
                if(change.doc.post === this.comments[0].post){
                    this.changeComment(change);
                }
            }
        });

    }

    getComments(postId){

        this.emitComments(postId);

        return this.commentSubject;

    }

    addComment(comment): void {
        this.dataService.db.post(comment);
    }

    emitComments(postId): void {

        this.zone.run(() => {

            this.dataService.db.query('comments/by_post_id', {key: postId}).then((data) => {

                let comments = data.rows.map(row => {
                    return row.value;
                });

                this.comments = comments;
                this.commentSubject.next(this.comments);

            });

        });

    }

    changeComment(change): void {

        let changedDoc = null;
        let changedIndex = null;

        // Find the affected document (if any)
        this.comments.forEach((doc, index) => {

            if(doc._id === change.id){
                changedDoc = doc;
                changedIndex = index;
            }

        });

        //A document was deleted - remove it
        if(change.deleted){
            this.comments.splice(changedIndex, 1);
        } else {

            //A document was updated - change it
            if(changedDoc){
                this.comments[changedIndex] = change.doc;
            } 

            //A document was added - add it
            else {
                this.comments.push(change.doc); 
            }

        }

    }

}

This provider is very similar to the posts provider, but there are a few key differences. In our emitComments function we take in a parameter of postId , which will allow us to get comments for a specific post. This postId is then passed in as the key property when querying the view:

this.dataService.db.query('comments/by_post_id', {key: postId})

This will now only retrieve documents in that view that match the key. We’ve also made an important change to our change listener:

this.dataService.db.changes({live: true, since: 'now', include_docs: true}).on('change', (change) => {
            if(change.doc.type === 'comment' && this.comments.length > 0){
                if(change.doc.post === this.comments[0].post){
                    this.changeComment(change);
                }
            }
        });

The posts provider stores all of the posts at all times, but the comments provider only stores the comments for the post currently being viewed. When we detect a change, we see if it is related to a comment and if there are any comments currently loaded. If there are comments loaded, then we check if the change is related to a comment that is currently loaded (not some other post we don’t currently care about), and if it is related then we trigger the changeComment function.

5. Displaying Comments

We’ve got the backend stuff out of the way, now we just need to make use of the provider we’ve created to grab the comments and then display them in our application.

Modify src/pages/view-post/view-post.ts to reflect the following:

import { Component } from '@angular/core';
import { NavController, NavParams } from 'ionic-angular';
import { AddCommentPage } from '../add-comment/add-comment';
import { Comments } from '../../providers/comments';

@Component({
  selector: 'page-view-post',
  templateUrl: 'view-post.html'
})
export class ViewPostPage {

    post: any;
    comments: any;

    constructor(public navCtrl: NavController, public navParams: NavParams, public commentService: Comments) {}

    ionViewDidLoad() {

        this.post = this.navParams.get('post');

        this.commentService.getComments(this.post._id).subscribe((comments) => {
            this.comments = comments;
        });
    }

}

We’ve added a comments member variable here for storing the comments, we inject the comments provider, and we subscribe to the observable that the getComments function returns. We also pass in the id of the current post to this function so that it returns us the correct comments.

Modify src/pages/view-post/view-post.html to reflect the following:

<ion-header>

  <ion-navbar>
    <ion-title>{{post?.title}}</ion-title>
  </ion-navbar>

</ion-header>


<ion-content padding>

    <h2>{{post?.title}}</h2>

    <p>{{post?.content}}</p>

    <h2>Comments</h2>

    <ion-card *ngFor="let comment of comments">
        <ion-card-header>
            {{comment.author}}
        </ion-card-header>
        <ion-card-content>
            {{comment.content}}
        </ion-card-content>
    </ion-card>

</ion-content>

All we have done here is add a comments section where we loop over the comments data that we loaded in, and we create a new <ion-card> for each comment (along with the data for that comment).

6. Adding Comments

We can load in and view comments in the application, now all we need is a way to add them. Our provider already supports adding a new comment, so all we need is an interface to trigger that function.

We already generated a page for adding comments, so we will need a way to launch that.

Modify src/pages/view-post.ts to reflect the following:

import { Component } from '@angular/core';
import { NavController, NavParams } from 'ionic-angular';
import { AddCommentPage } from '../add-comment/add-comment';
import { Comments } from '../../providers/comments';

@Component({
  selector: 'page-view-post',
  templateUrl: 'view-post.html'
})
export class ViewPostPage {

    post: any;
    comments: any;

    constructor(public navCtrl: NavController, public navParams: NavParams, public commentService: Comments) {}

    ionViewDidLoad() {

        this.post = this.navParams.get('post');

        this.commentService.getComments(this.post._id).subscribe((comments) => {
            this.comments = comments;
        });
    }

    pushAddCommentPage(){

        this.navCtrl.push(AddCommentPage, {
            post: this.post
        });

    }

}

Notice that we will also be sending the data for the post along to the comment page. This is important because we will need to add the id of the post to the comment we are creating.

Modify src/pages/view-post.html to reflect the following:

<ion-header>

  <ion-navbar>
    <ion-title>{{post?.title}}</ion-title>
    <ion-buttons end>
        <button ion-button icon-only (click)="pushAddCommentPage()"><ion-icon name="chatbubbles"></ion-icon></button>
    </ion-buttons>
  </ion-navbar>

</ion-header>


<ion-content padding>

    <h2>{{post?.title}}</h2>

    <p>{{post?.content}}</p>

    <h2>Comments</h2>

    <ion-card *ngFor="let comment of comments">
        <ion-card-header>
            {{comment.author}}
        </ion-card-header>
        <ion-card-content>
            {{comment.content}}
        </ion-card-content>
    </ion-card>

</ion-content>

All we have done here is add a button to the header that will trigger the function to launch the comments page.

Modify src/pages/add-comment/add-comment.ts to reflect the following:

import { Component } from '@angular/core';
import { NavController, NavParams } from 'ionic-angular';
import { Comments } from '../../providers/comments';

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

    comment: any = {
        author: 'Josh Morony',
        content: '',
        datePublished: '',
        type: 'comment',
        post: null
    };

    constructor(public navCtrl: NavController, public navParams: NavParams, public commentService: Comments) {}

    ionViewDidLoad() {

        this.comment.post = this.navParams.get('post')._id;

    }

    save(){

        // Generate computed fields
        this.comment.datePublished = new Date().toISOString();

        this.commentService.addComment(this.comment);

        this.navCtrl.pop();

    }

}

This is basically the same idea as what we did for adding a post, except the data for the comment is slightly different. It is also important that we add the id of the post to the data for the comment, which we do inside of the ionViewDidLoad function.

Modify src/page/add-comment/add-comment.html to reflect the following:

<ion-header>

  <ion-navbar>

  </ion-navbar>

</ion-header>

<ion-content>

    <ion-list>

      <ion-item>
        <ion-label floating>Comment</ion-label>
        <ion-input [(ngModel)]="comment.content" type="text"></ion-input>
      </ion-item>

    </ion-list>   

</ion-content>

<ion-footer>

    <button (click)="save()" color="light" ion-button full>Save</button>

</ion-footer>

This just adds a simple input that will allow you to modify the content property of the comment data and then trigger the save function that will send the comment off to the provider to be added to the database.

You should now be able to add a comment through the application and then view it!

Summary

There are a lot of features missing at this point, but we do have the basic core functionality for the application done. We can add posts, we can view posts, we can add comments, and we can view comments for a specific post. If the data for any posts or comments are modified locally or in the remote database, then the changes will immediately be reflected in our application (assuming there is a connection). If the user is not able to access the database, then they will be able to continue using the existing data in the application and when they come back online the data will automatically sync.

I will likely add more functionality to this application (let me know what kind of functionality you would like to see next), but I will also be releasing a video later this week where I add a bit of styling to the application to make it less… ugly.








New