In this step we are going to add:
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.
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.
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
:
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:
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:
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();
});
}
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 Observable
s - 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 Subject
s, and in order to trigger the notification in the first time, we will execute next()
for the Subject
s:
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 Observable
s into one, and trigger a notification when one of them changes!
So let's use it and update the subscription:
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.
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:
First, let's import the pagination module into our NgModule
:
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:
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:
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.
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:
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:
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.
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.
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:
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:
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:
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.
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:
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:
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
onnameOrder
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.
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:
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:
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.
This step covered a lot. We looked at:
sort
, limit
, skip
Subject
for updating variables automaticallyanti:fake
tmeasday:publish-counts
In the next step we'll look at sending out our party invitations and look deeper into pipes.