Fork me on GitHub
Note: The Socially 2 tutorial is no longer maintained. Instead, we have a new, better and comprehensive tutorial, integrated with our WhatsApp clone tutorial: WhatsApp clone tutorial with Ionic 2, Angular 2 and Meteor (we just moved all features and steps, and implemented them better!)

Filter and Pagination

Note: If you skipped ahead to this section, click here to download a zip of the tutorial at this point.

In this step we are going to add:

  • parties list pagination
  • sorting by party name
  • lastly, we will move our previously implemented parties location search to the server side.

Pagination simply means delivering and showing parties to the client on a page-by-page basis, where each page has a predefined number of items. Pagination reduces the number of documents to be transferred at one time thus decreasing load time. It also increases the usability of the user interface if there are too many documents in the storage.

Besides client-side logic, it usually includes querying a specific page of parties on the server side to deliver up to the client as well.

Pagination

First off, we'll add pagination on the server side.

Thanks to the simplicity of the Mongo API combined with Meteor's power, we only need to execute Parties.find on the server with some additional parameters. Keep in mind, with Meteor's isomorphic environment, we'll query Parties on the client with the same parameters as on the server.

Mongo Collection query options

Collection.find has a convenient second parameter called options, which takes an object for configuring collection querying. To implement pagination we'll need to provide sort, limit, and skip fields as options.

While limit and skip set boundaries on the result set, sort, at the same time, may not. We'll use sort to guarantee consistency of our pagination across page changes and page loads, since Mongo doesn't guarantee any order of documents if they are queried and not sorted. You can find more information about the find method in Mongo here.

Now, let's go to the parties subscription in the server/imports/publications/parties.ts file, add the options parameter to the subscription method, and then pass it to Parties.find:

13.1 Add options to the parties publication server/imports/publications/parties.ts
1
2
3
4
5
6
7
8
9
10
11
12
import { Meteor } from 'meteor/meteor';
import { Parties } from '../../../both/collections/parties.collection';
 
interface Options {
  [key: string]: any;
}
 
Meteor.publish('parties', function(options: Options) {
  return Parties.find(buildQuery.call(this), options);
});
 
Meteor.publish('party', function(partyId: string) {

On the client side, we are going to define three additional variables in the PartiesList component which our pagination will depend on: page size, current page number and name sort order. Secondly, we'll create a special options object made up of these variables and pass it to the parties subscription:

13.2 Define options and use it in the subscription client/imports/app/parties/parties-list.component.ts
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
 
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
 
import template from './parties-list.component.html';
 
interface Pagination {
  limit: number;
  skip: number;
}
 
interface Options extends Pagination {
  [key: string]: any
}
 
@Component({
  selector: 'parties-list',
  template
...some lines skipped...
export class PartiesListComponent implements OnInit, OnDestroy {
  parties: Observable<Party[]>;
  partiesSub: Subscription;
  pageSize: number = 10;
  curPage: number = 1;
  nameOrder: number = 1;
 
  ngOnInit() {
    const options: Options = {
      limit: this.pageSize,
      skip: (this.curPage - 1) * this.pageSize,
      sort: { name: this.nameOrder }
    };
 
    this.partiesSub = MeteorObservable.subscribe('parties', options).subscribe(() => {
      this.parties = Parties.find({}).zone();
    });
  }
 
  removeParty(party: Party): void {

As was said before, we also need to query Parties on the client side with same parameters and options as we used on the server, i.e., parameters and options we pass to the server side.

In reality, though, we don't need skip and limit options in this case, since the subscription result of the parties collection will always have a maximum page size of documents on the client.

So, we will only add sorting:

13.3 Add sorting by party name to PartiesList client/imports/app/parties/parties-list.component.ts
36
37
38
39
40
41
42
43
44
45
46
    };
 
    this.partiesSub = MeteorObservable.subscribe('parties', options).subscribe(() => {
      this.parties = Parties.find({}, {
        sort: {
          name: this.nameOrder
        }
      }).zone();
    });
  }
 

Reactive Changes

The idea behind Reactive variables and changes - is to update our Meteor subscription according to the user interaction - for example: if the user changes the sort order - we want to drop the old Meteor subscription and replace it with a new one that matches the new parameters.

Because we are using RxJS, we can create variables that are Observables - which means we can register to the changes notification - and act as required - in our case - changed the Meteor subscription.

In order to do so, we will use RxJS Subject - which is an extension for Observable.

A Subject is a sort of bridge or proxy that is available in some implementations of RxJS that acts both as an observer and as an Observable.

Which means we can both register to the updates notifications and trigger the notification!

In our case - when the user changes the parameters of the Meteor subscription, we need to trigger the notification.

So let's do it. We will replace the regular variables with Subjects, and in order to trigger the notification in the first time, we will execute next() for the Subjects:

13.4 Turn primitive values into Subjects client/imports/app/parties/parties-list.component.ts
1
2
3
4
5
6
 
25
26
27
28
29
30
31
32
33
 
43
44
45
46
47
48
49
50
51
52
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { Subject } from 'rxjs/Subject';
import { Subscription } from 'rxjs/Subscription';
import { MeteorObservable } from 'meteor-rxjs';
 
...some lines skipped...
export class PartiesListComponent implements OnInit, OnDestroy {
  parties: Observable<Party[]>;
  partiesSub: Subscription;
  pageSize: Subject<number> = new Subject<number>();
  curPage: Subject<number> = new Subject<number>();
  nameOrder: Subject<number> = new Subject<number>();
 
  ngOnInit() {
    const options: Options = {
...some lines skipped...
        }
      }).zone();
    });
 
    this.pageSize.next(10);
    this.curPage.next(1);
    this.nameOrder.next(1);
  }
 
  removeParty(party: Party): void {

Now we need to register to those changes notifications.

Because we need to register to multiple notifications (page size, current page, sort), we need to use a special RxJS Operator called combineLatest - which combines multiple Observables into one, and trigger a notification when one of them changes!

So let's use it and update the subscription:

13.5 Re-subscribe on current page changes client/imports/app/parties/parties-list.component.ts
4
5
6
7
8
9
10
11
 
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
 
72
73
74
75
76
77
import { Subscription } from 'rxjs/Subscription';
import { MeteorObservable } from 'meteor-rxjs';
 
import 'rxjs/add/operator/combineLatest';
 
import { Parties } from '../../../../both/collections/parties.collection';
import { Party } from '../../../../both/models/party.model';
 
...some lines skipped...
  pageSize: Subject<number> = new Subject<number>();
  curPage: Subject<number> = new Subject<number>();
  nameOrder: Subject<number> = new Subject<number>();
  optionsSub: Subscription;
 
  ngOnInit() {
    this.optionsSub = Observable.combineLatest(
      this.pageSize,
      this.curPage,
      this.nameOrder
    ).subscribe(([pageSize, curPage, nameOrder]) => {
      const options: Options = {
        limit: pageSize as number,
        skip: ((curPage as number) - 1) * (pageSize as number),
        sort: { name: nameOrder as number }
      };
 
      if (this.partiesSub) {
        this.partiesSub.unsubscribe();
      }
      
      this.partiesSub = MeteorObservable.subscribe('parties', options).subscribe(() => {
        this.parties = Parties.find({}, {
          sort: {
            name: nameOrder
          }
        }).zone();
      });
    });
 
    this.pageSize.next(10);
...some lines skipped...
 
  ngOnDestroy() {
    this.partiesSub.unsubscribe();
    this.optionsSub.unsubscribe();
  }
}

Notice that we also removes the Subscription and use unsubscribe because we want to drop the old subscription each time it changes.

Pagination UI

As this paragraph name suggests, the next logical thing to do would be to implement a pagination UI, which consists of, at least, a list of page links at the bottom of every page, so that the user can switch pages by clicking on these links.

Creating a pagination component is not a trivial task and not one of the primary goals of this tutorial, so we are going to make use of an already existing package with Angular 2 pagination components. Run the following line to add this package:

$ meteor npm install ng2-pagination --save

This package's pagination mark-up follows the structure of the Bootstrap pagination component, so you can change its look simply by using proper CSS styles. It's worth noting, though, that this package has been created with the only this tutorial in mind. It misses a lot of features that would be quite useful in the real world, for example, custom templates.

Ng2-Pagination consists of three main parts:

  • pagination controls that render a list of links
  • a pagination service to manipulate logic programmatically
  • a pagination pipe component, which can be added in any component template, with the main goal to transform a list of items according to the current state of the pagination service and show current page of items on UI

First, let's import the pagination module into our NgModule:

13.7 Import Ng2PaginationModule client/imports/app/app.module.ts
3
4
5
6
7
8
9
 
15
16
17
18
19
20
21
22
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { AccountsModule } from 'angular2-meteor-accounts-ui';
import { Ng2PaginationModule } from 'ng2-pagination';
 
import { AppComponent } from './app.component';
import { routes, ROUTES_PROVIDERS } from './app.routes';
...some lines skipped...
    FormsModule,
    ReactiveFormsModule,
    RouterModule.forRoot(routes),
    AccountsModule,
    Ng2PaginationModule
  ],
  declarations: [
    AppComponent,

Because of pagination pipe of ng2-pagination supports only arrays we'll use the PaginationService. Let's define the configuration:

13.8 Register configuration of pagination client/imports/app/parties/parties-list.component.ts
3
4
5
6
7
8
9
 
33
34
35
36
37
38
39
40
41
42
 
62
63
64
65
66
67
68
69
70
71
72
73
74
import { Subject } from 'rxjs/Subject';
import { Subscription } from 'rxjs/Subscription';
import { MeteorObservable } from 'meteor-rxjs';
import { PaginationService } from 'ng2-pagination';
 
import 'rxjs/add/operator/combineLatest';
 
...some lines skipped...
  nameOrder: Subject<number> = new Subject<number>();
  optionsSub: Subscription;
 
  constructor(
    private paginationService: PaginationService
  ) {}
 
  ngOnInit() {
    this.optionsSub = Observable.combineLatest(
      this.pageSize,
...some lines skipped...
      });
    });
 
    this.paginationService.register({
      id: this.paginationService.defaultId,
      itemsPerPage: 10,
      currentPage: 1,
      totalItems: 30,
    });
 
    this.pageSize.next(10);
    this.curPage.next(1);
    this.nameOrder.next(1);

id - this is the identifier of specific pagination, we use the default.

We need to notify the pagination that the current page has been changed, so let's add it to the method where we handle the reactive changes:

13.9 Update current page when options change client/imports/app/parties/parties-list.component.ts
49
50
51
52
53
54
55
56
        sort: { name: nameOrder as number }
      };
 
      this.paginationService.setCurrentPage(this.paginationService.defaultId, curPage as number);
 
      if (this.partiesSub) {
        this.partiesSub.unsubscribe();
      }

Now, add the pagination controls to the parties-list.component.html template:

13
14
15
16
17
18
      <button (click)="removeParty(party)">X</button>
    </li>
  </ul>
 
  <pagination-controls></pagination-controls>
</div>

In the configuration, we provided the current page number, the page size and a new value of total items in the list to paginate.

This total number of items are required to be set in our case, since we don't provide a regular array of elements but instead an Observable, the pagination service simply won't know how to calculate its size.

We'll get back to this in the next paragraph where we'll be setting parties total size reactively.

For now, let's just set it to be 30. We'll see why this default value is needed shortly.

pageChange events

The final part is to handle user clicks on the page links. The pagination controls component fires a special event when the user clicks on a page link, causing the current page to update.

Let's handle this event in the template first and then add a method to the PartiesList component itself:

13.11 Add pageChange event binding client/imports/app/parties/parties-list.component.html
14
15
16
17
18
    </li>
  </ul>
 
  <pagination-controls (pageChange)="onPageChanged($event)"></pagination-controls>
</div>

As you can see, the pagination controls component fires the pageChange event, calling the onPageChanged method with a special event object that contains the new page number to set. Add the onPageChanged method:

13.12 Add event handler for pageChange client/imports/app/parties/parties-list.component.ts
84
85
86
87
88
89
90
91
92
93
    this.parties = Parties.find(value ? { location: value } : {}).zone();
  }
 
  onPageChanged(page: number): void {
    this.curPage.next(page);
  }
 
  ngOnDestroy() {
    this.partiesSub.unsubscribe();
    this.optionsSub.unsubscribe();

At this moment, we have almost everything in place. Let's check if everything is working. We are going to have to add a lot of parties, at least, a couple of pages. But, since we've chosen quite a large default page size (10), it would be tedious to add all parties manually.

Generating Mock Data

In this example, we need to deal with multiple objects and in order to test it and get the best results - we need a lot of Parties objects.

Thankfully, we have a helpful package called anti:fake, which will help us out with the generation of names, locations and other properties of new fake parties.

$ meteor add anti:fake

So, with the following lines of code we are going to have 30 parties in total (given that we already have three):

server/imports/fixtures/parties.ts:

...

for (var i = 0; i < 27; i++) {
  Parties.insert({
    name: Fake.sentence(50),
    location: Fake.sentence(10),
    description: Fake.sentence(100),
    public: true
  });
}

Fake is loaded in Meteor as a global, you may want to declare it for TypeScript.

You can add it to the end of the typings.d.ts file:

declare var Fake: {
    sentence(words: number): string;
}

Now reset the database (meteor reset) and run the app. You should see a list of 10 parties shown initially and 3 pages links just at the bottom.

Play around with the pagination: click on page links to go back and forth, then try to delete parties to check if the current page updates properly.

Getting the Total Number of Parties

The pagination component needs to know how many pages it will create. As such, we need to know the total number of parties in storage and divide it by the number of items per page.

At the same time, our parties collection will always have no more than necessary parties on the client side. This suggests that we have to add a new publication to publish only the current count of parties existing in storage.

This task looks quite common and, thankfully, it's already been implemented. We can use the tmeasday:publish-counts package.

$ meteor add tmeasday:publish-counts

This package is an example for a package that does not provide it's own TypeScript declaration file, so we will have to manually create and add it to the typings.d.ts file according to the package API:

13.15 Add declaration of counts package typings.d.ts
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
declare module '*.sass' {
  const style: string;
  export default style;
}
 
declare module 'meteor/tmeasday:publish-counts' {
  import { Mongo } from 'meteor/mongo';
 
  interface CountsObject {
    get(publicationName: string): number;
    publish(context: any, publicationName: string, cursor: Mongo.Cursor, options: any): number;
  }
 
  export const Counts: CountsObject;
}

This package exports a Counts object with all of the API methods we will need.

Notice that you'll see a TypeScript warning in the terminal saying that "Counts" has no method you want to use, when you start using the API. You can remove this warning by adding a publish-counts type declaration file to your typings.

Let's publish the total number of parties as follows:

13.14 Publish total number of parties server/imports/publications/parties.ts
1
2
3
4
5
6
 
8
9
10
11
12
13
14
15
import { Meteor } from 'meteor/meteor';
import { Counts } from 'meteor/tmeasday:publish-counts';
 
import { Parties } from '../../../both/collections/parties.collection';
 
interface Options {
...some lines skipped...
}
 
Meteor.publish('parties', function(options: Options) {
  Counts.publish(this, 'numberOfParties', Parties.collection.find(buildQuery.call(this)), { noReady: true });
 
  return Parties.find(buildQuery.call(this), options);
});
 

Notice that we are passing { noReady: true } in the last argument so that the publication will be ready only after our main cursor is loaded, instead of waiting for Counts.

We've just created the new numberOfParties publication. Let's get it reactively on the client side using the Counts object, and, at the same time, introduce a new partiesSize property in the PartiesList component:

13.16 Handle reactive updates of the parties total number client/imports/app/parties/parties-list.component.ts
4
5
6
7
8
9
10
 
33
34
35
36
37
38
39
40
 
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
 
99
100
101
102
103
104
import { Subscription } from 'rxjs/Subscription';
import { MeteorObservable } from 'meteor-rxjs';
import { PaginationService } from 'ng2-pagination';
import { Counts } from 'meteor/tmeasday:publish-counts';
 
import 'rxjs/add/operator/combineLatest';
 
...some lines skipped...
  curPage: Subject<number> = new Subject<number>();
  nameOrder: Subject<number> = new Subject<number>();
  optionsSub: Subscription;
  partiesSize: number = 0;
  autorunSub: Subscription;
 
  constructor(
    private paginationService: PaginationService
...some lines skipped...
      id: this.paginationService.defaultId,
      itemsPerPage: 10,
      currentPage: 1,
      totalItems: this.partiesSize
    });
 
    this.pageSize.next(10);
    this.curPage.next(1);
    this.nameOrder.next(1);
 
    this.autorunSub = MeteorObservable.autorun().subscribe(() => {
      this.partiesSize = Counts.get('numberOfParties');
      this.paginationService.setTotalItems(this.paginationService.defaultId, this.partiesSize);
    });
  }
 
  removeParty(party: Party): void {
...some lines skipped...
  ngOnDestroy() {
    this.partiesSub.unsubscribe();
    this.optionsSub.unsubscribe();
    this.autorunSub.unsubscribe();
  }
}

We used MeteorObservable.autorun because we wan't to know when there are changes regarding the data that comes from Meteor - so now every change of data, we will calculate the total number of parties and save it in our Component, then we will set it in the PaginationService.

Let's verify that the app works the same as before. Run the app. There should be same three pages of parties.

What's more interesting is to add a couple of new parties, thus, adding a new 4th page. By this way, we can prove that our new "total number" publication and pagination controls are all working properly.

Changing Sort Order

It's time for a new cool feature Socially users will certainly enjoy - sorting the parties list by party name. At this moment, we know everything we need to implement it.

As previously implements, nameOrder uses one of two values, 1 or -1, to express ascending and descending orders respectively. Then, as you can see, we assign nameOrder to the party property (currently, name) we want to sort.

We'll add a new dropdown UI control with two orders to change, ascending and descending. Let's add it in front of our parties list:

13.17 Add the sort order dropdown client/imports/app/parties/parties-list.component.html
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
  
  <login-buttons></login-buttons>
 
  <h1>Parties:</h1>
 
  <div>
    <select #sort (change)="changeSortOrder(sort.value)">
      <option value="1" selected>Ascending</option>
      <option value="-1">Descending</option>
    </select>
  </div>
 
  <ul>
    <li *ngFor="let party of parties | async">
      <a [routerLink]="['/party', party._id]">{{party.name}}</a>

In the PartiesList component, we change the nameOrder property to be a reactive variable and add a changeSortOrder event handler, where we set the new sort order:

13.18 Re-subscribe when sort order changes client/imports/app/parties/parties-list.component.ts
96
97
98
99
100
101
102
103
104
105
    this.curPage.next(page);
  }
 
  changeSortOrder(nameOrder: string): void {
    this.nameOrder.next(parseInt(nameOrder));
  }
 
  ngOnDestroy() {
    this.partiesSub.unsubscribe();
    this.optionsSub.unsubscribe();

Calling next on nameOrder Subject, will trigger the change notification - and then the Meteor subscription will re-created with the new parameters!

That's just it! Run the app and change the sort order back and forth.

What's important here is that pagination updates properly, i.e. according to a new sort order.

Server Side Search

Before this step we had a nice feature to search parties by location, but with the addition of pagination, location search has partly broken. In its current state, there will always be no more than the current page of parties shown simultaneously on the client side. We would like, of course, to search parties across all storage, not just across the current page.

To fix that, we'll need to patch our "parties" and "total number" publications on the server side to query parties with a new "location" parameter passed down from the client. Having that fixed, it should work properly in accordance with the added pagination.

So, let's add filtering parties by the location with the help of Mongo's regex API. It is going to look like this:

13.19 Add search by the party location using regex server/imports/publications/parties.ts
7
8
9
10
11
12
13
14
15
16
17
18
 
20
21
22
23
24
25
26
 
50
51
52
53
54
55
56
57
58
59
60
61
62
  [key: string]: any;
}
 
Meteor.publish('parties', function(options: Options, location?: string) {
  const selector = buildQuery.call(this, null, location);
 
  Counts.publish(this, 'numberOfParties', Parties.collection.find(selector), { noReady: true });
 
  return Parties.find(selector, options);
});
 
Meteor.publish('party', function(partyId: string) {
...some lines skipped...
});
 
 
function buildQuery(partyId?: string, location?: string): Object {
  const isAvailable = {
    $or: [{
      // party is public
...some lines skipped...
    };
  }
 
  const searchRegEx = { '$regex': '.*' + (location || '') + '.*', '$options': 'i' };
 
  return {
    $and: [{
        location: searchRegEx
      },
      isAvailable
    ]
  };
}

On the client side, we are going to add a new reactive variable and set it to update when a user clicks on the search button:

13.20 Add reactive search by location client/imports/app/parties/parties-list.component.ts
35
36
37
38
39
40
41
 
45
46
47
48
49
50
51
52
53
 
60
61
62
63
64
65
66
 
79
80
81
82
83
84
85
 
92
93
94
95
96
97
98
99
  optionsSub: Subscription;
  partiesSize: number = 0;
  autorunSub: Subscription;
  location: Subject<string> = new Subject<string>();
 
  constructor(
    private paginationService: PaginationService
...some lines skipped...
    this.optionsSub = Observable.combineLatest(
      this.pageSize,
      this.curPage,
      this.nameOrder,
      this.location
    ).subscribe(([pageSize, curPage, nameOrder, location]) => {
      const options: Options = {
        limit: pageSize as number,
        skip: ((curPage as number) - 1) * (pageSize as number),
...some lines skipped...
        this.partiesSub.unsubscribe();
      }
      
      this.partiesSub = MeteorObservable.subscribe('parties', options, location).subscribe(() => {
        this.parties = Parties.find({}, {
          sort: {
            name: nameOrder
...some lines skipped...
    this.pageSize.next(10);
    this.curPage.next(1);
    this.nameOrder.next(1);
    this.location.next('');
 
    this.autorunSub = MeteorObservable.autorun().subscribe(() => {
      this.partiesSize = Counts.get('numberOfParties');
...some lines skipped...
  }
 
  search(value: string): void {
    this.curPage.next(1);
    this.location.next(value);
  }
 
  onPageChanged(page: number): void {

Notice that we don't know what size to expect from the search that's why we are re-setting the current page to 1.

Let's check it out now that everything works properly altogether: pagination, search, sorting, removing and addition of new parties.

For example, you can try to add 30 parties in a way mentioned slightly above; then try to remove all 30 parties; then sort by the descending order; then try to search by Palo Alto — it should find only two, in case if you have not added any other parties rather than used in this tutorial so far; then try to remove one of the found parties and, finally, search with an empty location.

Although this sequence of actions looks quite complicated, it was accomplished with rather few lines of code.

Summary

This step covered a lot. We looked at:

  • Mongo query sort options: sort, limit, skip
  • RxJS Subject for updating variables automatically
  • Handling onChange events in Angular 2
  • Generating fake data with anti:fake
  • Establishing the total number of results with tmeasday:publish-counts
  • Enabling server-side searching across an entire collection

In the next step we'll look at sending out our party invitations and look deeper into pipes.