In this article by Marcelo Reyna, author of the book Meteor Design Patterns, we will cover application-wide patterns that share server- and client- side code. With these patterns, your code will become more secure and easier to manage. You will learn the following topic:
(For more resources related to this topic, see here.)
So far, we have been publishing collections without thinking much about how many documents we are pushing to the client. The more documents we publish, the longer it will take the web page to load. To solve this issue, we are going to learn how to show only a set number of documents and allow the user to navigate through the documents in the collection by either filtering or paging through them.
Filters and pagination are easy to build with Meteor's reactivity.
Routers will always have two types of parameters that they can accept: query parameters, and normal parameters. Query parameters are the objects that you will commonly see in site URLs followed by a question mark (<url-path>?page=1), while normal parameters are the type that you define within the route URL (<url>/<normal-parameter>/named_route/<normal-parameter-2>). It is a common practice to set query parameters on things such as pagination to keep your routes from creating URL conflicts.
A URL conflict happens when two routes look the same but have different parameters. A products route such as /products/:page collides with a product detail route such as /products/:product-id. While both the routes are differently expressed because of the differences in their normal parameter, you arrive at both the routes using the same URL. This means that the only way the router can tell them apart is by routing to them programmatically. So the user would have to know that the FlowRouter.go() command has to be run in the console to reach either one of the products pages instead of simply using the URL.
This is why we are going to use query parameters to keep our filtering and pagination stateful.
Stateful pagination is simply giving the user the option to copy and paste the URL to a different client and see the exact same section of the collection. This is important to make the site easy to share.
Now we are going to understand how to control our subscription reactively so that the user can navigate through the entire collection.
First, we need to set up our router to accept a page number. Then we will take this number and use it on our subscriber to pull in the data that we need. To set up the router, we will use a FlowRouter query parameter (the parameter that places a question mark next to the URL).
Let's set up our query parameter:
# /products/client/products.coffee
Template.created "products", ->
@autorun =>
tags = Session.get "products.tags"
filter =
page: Number(FlowRouter.getQueryParam("page")) or 0
if tags and not _.isEmpty tags
_.extend filter,
tags:tags
order = Session.get "global.order"
if order and not _.isEmpty order
_.extend filter,
order:order
@subscribe "products", filter
Template.products.helpers
...
pages:
current: ->
FlowRouter.getQueryParam("page") or 0
Template.products.events
"click .next-page": ->
FlowRouter.setQueryParams
page: Number(FlowRouter.getQueryParam("page")) + 1
"click .previous-page": ->
if Number(FlowRouter.getQueryParam("page")) - 1 < 0
page = 0
else
page = Number(FlowRouter.getQueryParam("page")) - 1
FlowRouter.setQueryParams
page: page
What we are doing here is straightforward. First, we extend the filter object with a page key that gets the current value of the page query parameter, and if this value does not exist, then it is set to 0. getQueryParam is a reactive data source, the autorun function will resubscribe when the value changes. Then we will create a helper for our view so that we can see what page we are on and the two events that set the page query parameter.
But wait. How do we know when the limit to pagination has been reached? This is where the tmeasday:publish-counts package is very useful. It uses a publisher's special function to count exactly how many documents are being published.
Let's set up our publisher:
# /products/server/products_pub.coffee
Meteor.publish "products", (ops={}) ->
limit = 10
product_options =
skip:ops.page * limit
limit:limit
sort:
name:1
if ops.tags and not _.isEmpty ops.tags
@relations
collection:Tags
...
collection:ProductsTags
...
collection:Products
foreign_key:"product"
options:product_options
mappings:[
...
]
else
Counts.publish this,"products",
Products.find()
noReady:true
@relations
collection:Products
options:product_options
mappings:[
...
]
if ops.order and not _.isEmpty ops.order
...
@ready()
To publish our counts, we used the Counts.publish function. This function takes in a few parameters:
Counts.publish <always this>,<name of count>, <collection to count>, <parameters>
Note that we used the noReady parameter to prevent the ready function from running prematurely. By doing this, we generate a counter that can be accessed on the client side by running Counts.get "products". Now you might be thinking, why not use Products.find().count() instead? In this particular scenario, this would be an excellent idea, but you absolutely have to use the Counts function to make the count reactive, so if any dependencies change, they will be accounted for.
Let's modify our view and helpers to reflect our counter:
# /products/client/products.coffee
...
Template.products.helpers
pages:
current: ->
FlowRouter.getQueryParam("page") or 0
is_last_page: ->
current_page = Number(FlowRouter.getQueryParam("page")) or 0
max_allowed = 10 + current_page * 10
max_products = Counts.get "products"
max_allowed > max_products
//- /products/client/products.jade
template(name="products")
div#products.template
...
section#featured_products
div.container
div.row
br.visible-xs
//- PAGINATION
div.col-xs-4
button.btn.btn-block.btn-primary.previous-page
i.fa.fa-chevron-left
div.col-xs-4
button.btn.btn-block.btn-info {{pages.current}}
div.col-xs-4
unless pages.is_last_page
button.btn.btn-block.btn-primary.next-page
i.fa.fa-chevron-right
div.clearfix
br
//- PRODUCTS
+momentum(plugin="fade-fast")
...
Great! Users can now copy and paste the URL to obtain the same results they had before. This is exactly what we need to make sure our customers can share links. If we had kept our page variable confined to a Session or a ReactiveVar, it would have been impossible to share the state of the webapp.
Filtering and searching, too, are critical aspects of any web app. Filtering works similar to pagination; the publisher takes additional variables that control the filter. We want to make sure that this is stateful, so we need to integrate this into our routes, and we need to program our publishers to react to this. Also, the filter needs to be compatible with the pager. Let's start by modifying the publisher:
# /products/server/products_pub.coffee
Meteor.publish "products", (ops={}) ->
limit = 10
product_options =
skip:ops.page * limit
limit:limit
sort:
name:1
filter = {}
if ops.search and not _.isEmpty ops.search
_.extend filter,
name:
$regex: ops.search
$options:"i"
if ops.tags and not _.isEmpty ops.tags
@relations
collection:Tags
mappings:[
...
collection:ProductsTags
mappings:[
collection:Products
filter:filter
...
]
else
Counts.publish this,"products",
Products.find filter
noReady:true
@relations
collection:Products
filter:filter
...
if ops.order and not _.isEmpty ops.order
...
@ready()
To build any filter, we have to make sure that the property that creates the filter exists and _.extend our filter object based on this. This makes our code easier to maintain. Notice that we can easily add the filter to every section that includes the Products collection. With this, we have ensured that the filter is always used even if tags have filtered the data. By adding the filter to the Counts.publish function, we have ensured that the publisher is compatible with pagination as well.
Let's build our controller:
# /products/client/products.coffee
Template.created "products", ->
@autorun =>
ops =
page: Number(FlowRouter.getQueryParam("page")) or 0
search: FlowRouter.getQueryParam "search"
...
@subscribe "products", ops
Template.products.helpers
...
pages:
search: ->
FlowRouter.getQueryParam "search"
...
Template.products.events
...
"change .search": (event) ->
search = $(event.currentTarget).val()
if _.isEmpty search
search = null
FlowRouter.setQueryParams
search:search
page:null
First, we have renamed our filter object to ops to keep things consistent between the publisher and subscriber. Then we have attached a search key to the ops object that takes the value of the search query parameter. Notice that we can pass an undefined value for search, and our subscriber will not fail, since the publisher already checks whether the value exists or not and extends filters based on this. It is always better to verify variables on the server side to ensure that the client doesn't accidentally break things. Also, we need to make sure that we know the value of that parameter so that we can create a new search helper under the pages helper. Finally, we have built an event for the search bar. Notice that we are setting query parameters to null whenever they do not apply. This makes sure that they do not appear in our URL if we do not need them.
To finish, we need to create the search bar:
//- /products/client/products.jade
template(name="products")
div#products.template
header#promoter
...
div#content
section#features
...
section#featured_products
div.container
div.row
//- SEARCH
div.col-xs-12
div.form-group.has-feedback
input.input-lg.search.form-control(type="text" placeholder="Search products" autocapitalize="off" autocorrect="off" autocomplete="off" value="{{pages.search}}")
span(style="pointer-events:auto; cursor:pointer;").form-control-feedback.fa.fa-search.fa-2x
...
Notice that our search input is somewhat cluttered with special attributes. All these attributes ensure that our input is not doing the things that we do not want it to for iOS Safari. It is important to keep up with nonstandard attributes such as these to ensure that the site is mobile-friendly. You can find an updated list of these attributes here at https://developer.apple.com/library/safari/documentation/AppleApplications/Reference/SafariHTMLRef/Articles/Attributes.html.
This article covered how to control the amount of data that we publish. We also learned a pattern to build pagination that functions with filters as well, along with code examples.
Further resources on this subject: