Discover Meteor

Building Real-Time JavaScript Web Apps

Introduction

1

আসুন আপনি একটি ঘটনা চিন্তা করুন। ভাবুন যে আপনি আপনার কম্পিউটারে দুটি আলাদা উইন্ডো তে একই ফোল্ডার খুলছেন। এখন আপনি দুটো উইন্ডোর ভিতরে যে কোন একটিতে ক্লিক করে, এবং একটি ফাইল ডিলিট করুন । এখন একটু খেয়াল করুন তো যে আরেকটি উইন্ডো থেকে কি ফাইলটি ডিলিট হয়েছে কিনা? আসলে এই কাজটির ফলাফল বুঝতে আপনাকে এই কাজ গুলো করতে হবে না । আপনি যখন আপনার লোকাল ফাইল সিস্টেম এ কিছু পরিবর্তন করেন,এই পরিবর্তনটি সব জায়গায় হয়ে যায় , কোন ধরনের কলব্যাক বা রিফ্রেশ ছাড়াই। এটি অনেকটি স্বাভাবিক ভাবেই হয়ে যায়। আবার , আসুন ভাবা যাক , ঠিক একই ধরনের একটি ঘটনা কিন্তু এবার ওয়েব এর জন্য। উদাহরণ স্বরূপ , ধরি আপনি একটি একই ওয়ার্ড-প্রেস সাইট এর এডমিন দুটি আলাদা ব্রাউসারে খুলেছেন এবং একটি ব্রাউসারে আপনি একটি পোস্ট লিখেছেন। কিন্তু ডেস্কটপ এর মতন এখানে হয় না, আপনি যতক্ষণই অপেক্ষা করুন না কেন, আরেকটি উইন্ডো এই পরিবর্তনটিকে গ্রহণ করে না ব্রাউসার উইন্ডোটি রিফ্রেশ না করা পর্যন্ত।সময়ের সাথে , আমাদের ভিতর একটি ধারণা শক্ত ভাবে জন্মে গেছে একটি ওয়েব সাইট এ শুধু মাত্র কিছু ছোট এবং আলাদা আলাদা কাজের মাধ্যমে কাজ করা সম্ভব। কিন্তু মেটেওর একটি নতুন ধারনার ফ্রেম ওয়ার্ক ও প্রযুক্তির ফল যা কিনা এই বাঁধাধরা নিয়মকে ভেঙ্গে ওয়েবকে বানাতে চায় Real-Time & Reactive ।

What is Meteor?

Meteor এমন একটি প্লাটফর্ম যা কিনা তৈরি করা হয়েছে Node.js উপর ভিত্তি করে যাতে করে Real-Time Web App তৈরি করা যায়।এটি আসলে আপনার App এর ডাটাবেস ও ঐ একই App এর ইউজার ইন্টারফেস এর মাঝে অবস্থান করে পুরো প্রক্রিয়াটাকে সব সময় নতুন রাখে ও হালনাগাদ করে ।

যেহেতু এটি Node.js এর উপর ভিত্তি করে তৈরি তাই স্বাভাবিক ভাবেই বলা যায় , Meteor ক্লায়েন্ট ও সার্ভার এর কাজ গুলোর জন্য জাভাস্ক্রিপ্ট ব্যাবহার করে। শুধু তাই নয়, Meteor এই দুটি মাধ্যমেই তাদের কোড গুলো শেয়ার করবার ক্ষমতা দেয়।

এই সব কিছুর ফলাফল হল এমন একটি প্ল্যাটফর্ম যা কিনা খুবই শক্তিশালী এবং খুবই সরল প্রকৃতির Web App তৈরি করা , যার সব কঠিন ও বাজে ব্যাপার গুলোকে নির্ধারণ করে দূর করে দেয়া হয় ।

Why Meteor?

তো আপনি ভাবতে পারেন যে আপনি কেন অন্য কোন ফ্রেম ওয়ার্ক না শিখে Meteor কেন শিখবেন ? তাহলে সকল অত্যাধুনিক সুবিধা আর মজার সব বিষয় গুলো দূরে রেখে বলতে হবে যে , এটি শিখার একটি অন্যতম কারন “ Meteor শিখা খুবই সহজ"।

তার উপর বলতে হয় , আর যে কোন ফ্রেম ওয়ার্ক এর তুলনায় , Meteor আপনাকে খুব সহজে আর কম সময়ে Real-Time App তৈরি করবার সুজগ করে দেয়। আর শুধু তাই নয় , আপনি যদি এর আগে কখনো Front End Develop করে থাকেন তাহলে আপনি Javascript এর সাথে বেশ পরিচিত , যার মানে হল আপনাকে নতুন করে কোন ভাষা শিখতে হচ্ছে না ।

এমনটা মনে হতে পারে , Meteor আপনার কাজের জন্য আসলে প্রয়োজন নেই , ধরে নেই হয়তোবা তাই। কিন্তু যেহেতু আপনি কিছু সন্ধার অল্প সময় বা, শুধু ছুটির কিছু দিনে চেষ্টা করেই পুরোটা শিখে নিতে পারবেন , তাহলে কেন নয়?

Why This Book?

গত কিছু বছর ধরে আমরা প্রতিনিয়ত আমরা অসংখ্য Meteor Project নিয়ে কাজ করছি ,এবং এর বিস্তৃতি করছি ওয়েব থেকে মোবাইল প্ল্যাটফর্ম-এ , এমনকি ব্যাবসায়িক থেকে বিভিন্ন ওপেণ-সোর্স ক্ষেত্রে ।

আমরা অনেক কিছু জেনেছি আর শিখেছি , কিন্তু আমাদের সমস্যা গুলোর সমাধান সবসময় পাওয়া সম্ভব হয় না । আমাদের অনেক জায়গা থেকে তথ্য যোগার করতে হয়, আবার অনেক সময় কিছু সমস্যার সমাধান নিজেদের তৈরি করে নিতে হয়। এই বই এর সাহায্যে আমরা এই অভিজ্ঞতা গুলোকে সবার সাথে শেয়ার করতে চাই,এবং ধাপে ধাপে সহ্জ কিছু নিয়মে একটি পরিপূর্ণ Meteor App বানাতে চাই।

যে App আমরা তৈরি করতে চাইছি, সেটি আসলে Hacker News বা Reddit এর মতন সোশ্যাল সাইট এর একধরনের ছোট রূপ , যার নাম আমরা দিব, Microscope ( এই নামটা এই কাজের বড় রূপ Telescope এর একটি ছোট সংস্করণ )

যখন আমরা এই Meteor App টি নিয়ে কাজটি করব, তখন আমরা প্রায় সব ধরনের উপায় নিয়ে কাজ করব যেমন উল্লেখ্য হল, User Accounts , Meteor Collections , routing আরও অনেক কিছু।

About the Authors

আপনি হয়তোবা ভাবছেন আমরা আসলে কে অথবা আপনারা আমাদের কথাতে বিশ্বাস করবেনই বা কেন, আসুন তাহলে আমাদের পরিচয় আরেক্তু ভাল ভাবে দেয়া যাক।

Tom Coleman একজন প্রধান ব্যাক্তিতু Percolate Studio এর , এই প্রতিষ্ঠান মূলত ওয়েব ডেভেলপমেন্ট এবং ইউজার এক্সপেরিয়েন্স নিয়ে কাজ করে। তিনি Atomsphere প্যাকেজ এর একজন তত্ত্বাবধায়নকারী , এবং আরও বলতে হয় এটি অন্যতম উৎস Meteor এর ওপেন - সোর্স প্রোজেক্ট এর।

Sacha Gerif তিনি কাজ করছেন বিভিন্ন স্টার্ট আপ এর সাথে যেমন Hipmunk এবং RubyMotion এ একজন প্রোডাক্ট ও ওয়েব ডিজাইনার হিসেবে। তিনি Telescope এবং Sidebar ( যা কিনা তৈরি করা হয়েছে Telescope অনুসরন করে ) এর প্রস্তুতকারী । এবং শুধু তাই নয় , তিনি Floyo এর ও প্রতিষ্ঠাতা ।

Chapters & Sidebars

আমরা চাই এই বইটি নতুন Meteor ব্যাবহারকারি থেকে শুরু করে অভিজ্ঞ প্রোগ্রামার সবার কাজে আসুক। তাই এই বিষয়টি মাথায় রেখে আমরা চ্যাপ্টার গুলোকে দুই ভাগে ভাগ করেছি, সাধারণ চ্যাপ্টার ( Numberd 1 through 14 ) এবং সাইডবারস ( .5 Numbers) ।

রেগুলার চ্যাপ্টার গুলো আপনাকে App তৈরির কাজ গুলো শুরু করিয়ে দিবে এবং আপনিও খুব বেশি বিস্তারিত না জেনেই পুরো কাজে হাত দিতে পারবেন ।

আরেক দিকে , সাইড বার গুলো আরও বিস্তারিত আলচোনা করে আপনাকে পুরো বিষয়টার একটি ভাল বিবরণ তুলে ধরবে।

তাই বলতে হয় আপনি যদি মাত্র শুরু করে থাকেন , তাহলে বলতে চাই শুরুতেই সাইড বার গুলো পড়বার দরকার নেই এগুলো আপনি পুরো কাজটা সহজ ভাবে বুঝতে পারবার পরে দেখলেই হবে।

Commits & Live Instances

আসলে এর থেকে খারাপ কিছুই হয় না যখন দেখা যায় আপনি একটি প্রোগ্রামিং বই নিয়ে কাজ করে যাচ্ছেন ও পড়ে যাচ্ছেন কিন্তু একটা সময় পরে আপনার কোড গুলো সব এলোমেলো হয়ে গেছে এবং ঠিক জেভাবে কাজ করবার কথা সেভাবে কাজ করছে না।

এধরনের ঘটনা এড়াতে , আমরা একটি রিপসেরটোরি তৈরি করেছি Github এ শুধু Microsope এর জন্য, এবং আমরা চাই আপনাদের দেয়া ডিরেক্ট লিঙ্ক গুলো ব্যাবহার করে আপনারা আপনাদের কোড গুলোর চেঞ্জ এখানে দেয়া কোড গুলোর সাথে মিলিয়ে নিবেন এবং কাজের অগ্রগতি ঠিক রাখবেন।

Commit 11-2

Display notifications in the header.

তার মানে এই নয় যে আপনি এই কাজ গুলো শুধু দেখে নিবেন এবং git checkout করে পরের অংশে যাবেন । সবচাইতে ভাল হবে যদি আপনি এই পুরো কাজগুলো হাতে হাতে টাইপ করে করেন ।

A Few Other Resources

যদি আপনার মনে হয় যে আপনার আরও বিস্তারিত কিছু জানবার দরকার তাহলে অফিসিয়াল Meteor Documentaion হবে সবচাইতে ভাল স্থান।

আমরা আরও বলতে চাই Stack Overflow অনেক ভাল জায়গা আপনার সমস্যা আর প্রশ্নের জন্য , তাছাড়া #meteor IRC Chanel সবসময় তো আছেই।

Do I Need Git?

যদিওবা Git version control এই কাজের জন্য অব্যশিক নয় তবুও আমরা চাই যে আপনি এটি ব্যাবহার করুন।

আপনি যদি খুব জলদি কাজ শুরু করতে চান তাহলে , আমরা বলব আপনি Nick Farina’s Git Is Simpler Than You Think এর উপর চোখ বুলিয়ে নিন।

যদি এই ব্যাপারে একে বারেই অপারগ হয়ে থাকেন , তাহলে বলতে চাই আপনি Github For Mac ব্যাবহার করুন, যা কিনা আপনাকে কমান্ড লাইন এর ব্যাবহার ছাড়াই রিপো সমন্বয় আর ক্লোন করতে দিবে।

Getting in Touch

  • যদি আপনি আমাদের সাথে যোগাযোগ করতে চান , তাহলে আপনি আমাদের মেইল করতে পারেন , hello@discovermeteor.com
  • এছাড়া যদি আপনার এই বই এ কোন ত্রুটি বা সংশোধন এর প্রয়োজন দেখেন তাহলে দয়া করে আপনারা আমাদের জানাতে পারেন গিটহাব এ একটি বাগ রিপোর্ট করে
  • যদি আপনার চোখে Macroscope এর কোড এর কোন সমস্যা পরে , তাহলে আপনি একিভাবে বাগ রিপোর্ট করতে পারেন Microscope Repo তে
  • এছারাও আপনি যেকোনো প্রশ্ন এর জন্য আপনার মতামত দিতে পারেন। Comment Section এ।

Getting Started

2

////

////

$ curl https://install.meteor.com | sh

////

Not Installing Meteor

////

////

////

Meteorite

////

////

Installing Meteorite

////

////

$ npm install -g meteorite

Permission errors?

////

$ sudo -H npm install -g meteorite

////

////

////

### mrt vs meteor

////

Creating a Simple App

////

$ mrt create microscope

////

microscope.css  
microscope.html 
microscope.js   
smart.json 

////

////

$ cd microscope
$ meteor

////

Meteor's Hello World.
Meteor’s Hello World.

Commit 2-1

Created basic microscope project.

////

Adding a Package

////

$ mrt add bootstrap

Commit 2-2

Added bootstrap package.

A Note on Packages

////

  • ////
  • ////
  • ////
  • ////
  • ////

The File Structure of a Meteor App

////

////

////

  • ////
  • ////
  • ////
  • ////
  • ////
  • ////

////

////

Is Meteor MVC?

////

////

No public?

////

////

Underscores vs CamelCase

////

////

////

Taking Care of CSS

////

////

.grid-block, .main, .post, .comments li, .comment-form {
    background: #fff;
    border-radius: 3px;
    padding: 10px;
    margin-bottom: 10px;
    box-shadow: 0 1px 1px rgba(0, 0, 0, 0.15);
}
body {
    background: #eee;
    color: #666666;
}
.navbar { margin-bottom: 10px }
.navbar .navbar-inner {
    border-radius: 0px 0px 3px 3px;
}
#spinner { height: 300px }
.post {
    *zoom: 1;
    -webkit-transition: all 300ms 0ms;
    -webkit-transition-delay: ease-in;
    -moz-transition: all 300ms 0ms ease-in;
    -o-transition: all 300ms 0ms ease-in;
    transition: all 300ms 0ms ease-in;
    position: relative;
    opacity: 1;
}
.post:before, .post:after {
    content: "";
    display: table;
}
.post:after { clear: both }
.post.invisible { opacity: 0 }
.post .upvote {
    display: block;
    margin: 7px 12px 0 0;
    float: left;
}
.post .post-content { float: left }
.post .post-content h3 {
    margin: 0;
    line-height: 1.4;
    font-size: 18px;
}
.post .post-content h3 a {
    display: inline-block;
    margin-right: 5px;
}
.post .post-content h3 span {
    font-weight: normal;
    font-size: 14px;
    display: inline-block;
    color: #aaaaaa;
}
.post .post-content p { margin: 0 }
.post .discuss {
    display: block;
    float: right;
    margin-top: 7px;
}
.comments {
    list-style-type: none;
    margin: 0;
}
.comments li h4 {
    font-size: 16px;
    margin: 0;
}
.comments li h4 .date {
    font-size: 12px;
    font-weight: normal;
}
.comments li h4 a { font-size: 12px }
.comments li p:last-child { margin-bottom: 0 }
.dropdown-menu span {
    display: block;
    padding: 3px 20px;
    clear: both;
    line-height: 20px;
    color: #bbb;
    white-space: nowrap;
}
.load-more {
    display: block;
    border-radius: 3px;
    background: rgba(0, 0, 0, 0.05);
    text-align: center;
    height: 60px;
    line-height: 60px;
    margin-bottom: 10px;
}
.load-more:hover {
    text-decoration: none;
    background: rgba(0, 0, 0, 0.1);
}
client/stylesheets/style.css

Commit 2-3

Re-arranged file structure.

A Note on CoffeeScript

////

mrt add coffeescript

Deployment

Sidebar 2.5

////

////

////

Introducing Sidebars

////

////

Deploying On Meteor

////

////

$ meteor deploy myapp.meteor.com

////

////

Password Protection

////

$ meteor deploy myapp.meteor.com -p

////

////

Deploying On Modulus

////

Demeteorizer

////

////

$ npm install -g modulus

////

$ modulus login

////

$ modulus project create

////

////

$ modulus env set MONGO_URL "mongodb://<user>:<pass>@mongo.onmodulus.net:27017/<database_name>"

////

$ modulus deploy

////

Meteor Up

////

////

////

////

Initializing Meteor Up

////

$ npm install -g mup

////

////

////

$ mkdir ~/microscope-deploy
$ cd ~/microscope-deploy
$ mup init

Sharing with Dropbox

////

Meteor Up Configuration

////

////

////

{
  //server authentication info
  "servers": [{
    "host": "hostname",
    "username": "root",
    "password": "password"
    //or pem file (ssh based authentication)
    //"pem": "~/.ssh/id_rsa"
  }],

  //install MongoDB in the server
  "setupMongo": true,

  //location of app (local directory)
  "app": "/path/to/the/app",

  //configure environmental
  "env": {
    "ROOT_URL": "http://supersite.com"
  }
}
mup.json

////

Server Authentication

////

////

MongoDB Configuration

////

////

Meteor App Path

////

Environment Variables

////

Setting Up and Deploying

////

$ mup setup

////

$ mup deploy

////

Displaying Logs

////

$ mup logs -f

////

////

Templates

3

////

////

////

<head>
  <title>Microscope</title>
</head>
<body>
  <div class="container">
    <header class="navbar">
      <div class="navbar-inner">
        <a class="brand" href="/">Microscope</a>
      </div>
    </header>
    <div id="main" class="row-fluid">
      {{> postsList}}
    </div>
  </div>
</body>
client/main.html

////

Meteor Templates

////

////

Finding Files

////

////

////

<template name="postsList">
  <div class="posts">
    {{#each posts}}
      {{> postItem}}
    {{/each}}
  </div>
</template>
client/views/posts/posts_list.html

////

<template name="postItem">
  <div class="post">
    <div class="post-content">
      <h3><a href="{{url}}">{{title}}</a><span>{{domain}}</span></h3>
    </div>
  </div>
</template>
client/views/posts/post_item.html

////

////

////

////

////

Going Further

////

////

////

////

////

////

Template Managers

////

////

////

Managers?

////

////

////

////

var postsData = [
  {
    title: 'Introducing Telescope',
    author: 'Sacha Greif',
    url: 'http://sachagreif.com/introducing-telescope/'
  }, 
  {
    title: 'Meteor',
    author: 'Tom Coleman',
    url: 'http://meteor.com'
  }, 
  {
    title: 'The Meteor Book',
    author: 'Tom Coleman',
    url: 'http://themeteorbook.com'
  }
];
Template.postsList.helpers({
  posts: postsData
});
client/views/posts/posts_list.js

////

Our first templates with static data
Our first templates with static data

Commit 3-1

Added basic posts list template and static data.

////

////

////

<template name="postsList">
  <div class="posts">
    {{#each posts}}
      {{> postItem}}
    {{/each}}
  </div>
</template>
client/views/posts/posts_list.html

////

The Value of “this”

////

Template.postItem.helpers({
  domain: function() {
    var a = document.createElement('a');
    a.href = this.url;
    return a.hostname;
  }
});
client/views/posts/post_item.js

Commit 3-2

Setup a `domain` helper on the `postItem`.

////

Displaying domains for each links.
Displaying domains for each links.

////

////

////

////

JavaScript Magic

////

////

////

////

Hot Code Reload

////

////

////

Using Git & GitHub

Sidebar 3.5

////

////

Being Committed

////

////

////

A Git commit as shown on GitHub.
A Git commit as shown on GitHub.

////

////

Modifying code.
Modifying code.

////

////

Deleting code.
Deleting code.

////

Browsing A Commit’s Code

////

////

The Browse code button.
The Browse code button.

////

The repository at commit 3-2.
The repository at commit 3-2.

////

The repository at commit 14-2.
The repository at commit 14-2.

Accessing A Commit Locally

////

////

$ git clone git@github.com:DiscoverMeteor/Microscope.git github_microscope

////

////

$ cd github_microscope

////

////

$ git checkout chapter3-1
Note: checking out 'chapter3-1'.

You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by performing another checkout.

If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -b with the checkout command again. Example:

  git checkout -b new_branch_name

HEAD is now at a004b56... Added basic posts list template and static data.

////

////

////

////

Finding a commit hash.
Finding a commit hash.

////

$ git checkout c7af59e425cd4e17c20cf99e51c8cd78f82c9932
Previous HEAD position was a004b56... Added basic posts list template and static data.
HEAD is now at c7af59e... Augmented the postsList route to take a limit

////

$ git checkout master

Historical Perspective

////

////

GitHub's History button.
GitHub’s History button.

////

Displaying a file's history.
Displaying a file’s history.

The Blame Game

////

GitHub's Blame button.
GitHub’s Blame button.

////

GitHub's Blame view.
GitHub’s Blame view.

////

Collections

4

////

////

////

////

////

////

////

Posts = new Meteor.Collection('posts');
collections/posts.js

Commit 4-1

Added a posts collection

////

To Var Or Not To Var?

////

////

Console vs Console vs Console

////

Terminal

The Terminal
The Terminal
  • ////
  • ////
  • ////
  • ////

Browser Console

The Browser Console
The Browser Console
  • ////
  • ////
  • ////
  • ////

Mongo Shell

The Mongo Shell
The Mongo Shell
  • ////
  • ////
  • ////
  • ////

////

Server-Side Collections

////

////

> db.posts.insert({title: "A new post"});

> db.posts.find();
{ "_id": ObjectId(".."), "title" : "A new post"};
The Mongo Shell

Mongo on Meteor.com

////

////

////

Client-Side Collections

////

////

////

Introducing MiniMongo

////

Client-Server Communication

////

////

////

> db.posts.find();
{title: "A new post", _id: ObjectId("..")};
The Mongo Shell
 Posts.findOne();
{title: "A new post", _id: LocalCollection._ObjectID};
First browser console

////

 Posts.find().count();
1
 Posts.insert({title: "A second post"});
'xxx'
 Posts.find().count();
2
First browser console

////

❯ db.posts.find();
{title: "A new post", _id: ObjectId("..")};
{title: "A second post", _id: 'yyy'};
The Mongo Shell

////

////

 Posts.find().count();
2
Second browser console

////

////

////

Keeping it Real-time

////

////

Populating the Database

////

////

////

$ meteor reset

////

////

if (Posts.find().count() === 0) {
  Posts.insert({
    title: 'Introducing Telescope',
    author: 'Sacha Greif',
    url: 'http://sachagreif.com/introducing-telescope/'
  });

  Posts.insert({
    title: 'Meteor',
    author: 'Tom Coleman',
    url: 'http://meteor.com'
  });

  Posts.insert({
    title: 'The Meteor Book',
    author: 'Tom Coleman',
    url: 'http://themeteorbook.com'
  });
}
server/fixtures.js

Commit 4-2

Added data to the posts collection.

////

////

Wiring the data to our HTML with helpers

////

 Posts.find().fetch();
Browser console

////

////

Template.postsList.helpers({
  posts: function() {
    return Posts.find();
  }
});
client/views/posts/posts_list.js

Commit 4-3

Wired collection into `postsList` template.

Find & Fetch

////

////

////

Using live data
Using live data

////

////

 Posts.insert({
  title: 'Meteor Docs', 
  author: 'Tom Coleman', 
  url: 'http://docs.meteor.com'
});
Browser console

////

Adding posts via the console
Adding posts via the console

////

Inspecting DOM Changes

////

////

Connecting Collections: Publications and Subscriptions

////

////

$ meteor remove autopublish

////

////

////

Meteor.publish('posts', function() {
  return Posts.find();
});
server/publications.js

////

Meteor.subscribe('posts');
client/main.js

Commit 4-4

Removed `autopublish` and set up a basic publication.

////

Conclusion

////

Publications and Subscriptions

Sidebar 4.5

////

////

////

The Olden Days

////

////

////

////

////

The Meteor Way

////

Pushing a subset of the database to the client.
Pushing a subset of the database to the client.

////

////

////

Publishing

////

////

////

All the posts contained in our database.
All the posts contained in our database.

////

////

Excluding flagged posts.
Excluding flagged posts.

////

// on the server
Meteor.publish('posts', function() {
  return Posts.find({flagged: false}); 
});

////

DDP

////

////

Subscribing

////

////

////

Subscribing to Bob's posts will mirror them on the client.
Subscribing to Bob’s posts will mirror them on the client.

////

// on the server
Meteor.publish('posts', function(author) {
  return Posts.find({flagged: false, author: author});
});

////

// on the client
Meteor.subscribe('posts', 'bob-smith');

////

Finding

////

Selecting a subset of documents on the client.
Selecting a subset of documents on the client.

////

// on the client
Template.posts.helpers({
  posts: function(){
    return Posts.find(author: 'bob-smith', category: 'JavaScript');
  }
});

////

Autopublish

////

////

Autopublish
Autopublish

////

////

////

Publishing Full Collections

////

Meteor.publish('allPosts', function(){
  return Posts.find();
});
Publishing a full collection
Publishing a full collection

////

Publishing Partial Collections

////

Meteor.publish('somePosts', function(){
  return Posts.find({'author':'Tom'});
});
Publishing a partial collection
Publishing a partial collection

Behind The Scenes

////

////

////

////

  • ////
  • ////
  • ////

////

Publishing Partial Properties

////

////

Meteor.publish('allPosts', function(){
  return Posts.find({}, {fields: {
    date: false
  }});
});
Publishing partial properties
Publishing partial properties

////

Meteor.publish('allPosts', function(){
  return Posts.find({'author':'Tom'}, {fields: {
    date: false
  }});
});

Summing Up

////

////

////

Routing

5

////

////

////

Adding the Iron Router Package

////

////

////

$ mrt add iron-router
Terminal

////

////

Router Vocabulary

////

  • ////
  • ////
  • ////
  • ////
  • ////
  • ////
  • ////
  • ////

////

Routing: Mapping URLs To Templates

////

////

////

Layouts and templates.
Layouts and templates.

////

////

<head>
  <title>Microscope</title>
</head>
client/main.html

////

<template name="layout">
  <div class="container">
  <header class="navbar">
    <div class="navbar-inner">
      <a class="brand" href="/">Microscope</a>
    </div>
  </header>
  <div id="main" class="row-fluid">
    {{yield}}
  </div>
  </div>
</template>
client/views/application/layout.html

////

////

Router.configure({
  layoutTemplate: 'layout'
});

Router.map(function() {
  this.route('postsList', {path: '/'});
});
lib/router.js

////

The /lib folder

////

////

Named Routes

////

////

////

////

<header class="navbar">
  <div class="navbar-inner">
    <a class="brand" href="{{pathFor 'postsList'}}">Microscope</a>
  </div>
</header>

//...
client/views/application/layout.html

Commit 5-1

Very basic routing.

Waiting on Data

////

////

////

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  waitOn: function() { return Meteor.subscribe('posts'); }
});

Router.map(function() {
  this.route('postsList', {path: '/'});
});
lib/router.js

////

////

////

////

////

////

<template name="loading">
  {{>spinner}}
</template>
client/views/includes/loading.html

////

Commit 5-2

Wait on the post subscription.

A First Glance At Reactivity

////

////

////

Routing To A Specific Post

////

////

////

<template name="postPage">
  {{> postItem}}
</template>
client/views/posts/post_page.html

////

////

Router.map(function() {
  this.route('postsList', {path: '/'});

  this.route('postPage', {
    path: '/posts/:_id'
  });
});

lib/router.js

////

////

////

////

The data context.
The data context.

////

Router.map(function() {
  this.route('postsList', {path: '/'});

  this.route('postPage', {
    path: '/posts/:_id',
    data: function() { return Posts.findOne(this.params._id); }
  });
});

lib/router.js

////

////

More About Data Contexts

////

////

{{#each widgets}}
  {{> widgetItem}}
{{/each}}

////

{{#with myWidget}}
  {{> widgetPage}}
{{/with}}

////

{{> widgetPage myWidget}}

Using a Dynamic Named Route Helper

////

////

<template name="postItem">
  <div class="post">
    <div class="post-content">
      <h3><a href="{{url}}">{{title}}</a><span>{{domain}}</span></h3>
    </div>
    <a href="{{pathFor 'postPage'}}" class="discuss btn">Discuss</a>
  </div>
</template>
client/views/posts/post_item.html

Commit 5-3

Routing to a single post page.

////

////

////

////

////

A single post page.
A single post page.

HTML5 pushState

////

////

////

The Session

Sidebar 5.5

////

////

////

The Meteor Session

////

////

////

Changing the Session

////

 Session.set('pageTitle', 'A different title');
Browser console

////

////

<header class="navbar">
  <div class="navbar-inner">
    <a class="brand" href="{{pathFor 'postsList'}}">{{pageTitle}}</a>
  </div>
</header>
client/views/application/layout.html
Template.layout.helpers({
  pageTitle: function() { return Session.get('pageTitle'); }
});
client/views/application/layout.js

////

////

 Session.set('pageTitle', 'A brand new title');
Browser console

////

Identical Changes

////

Introducing Autorun

////

////

helloWorld = function() {
  alert(Session.get('message'));
}

////

////

////

 Tracker.autorun( function() { console.log('Value is: ' + Session.get('pageTitle')); } );
Value is: A brand new title
Browser console

////

 Session.set('pageTitle', 'Yet another value');
Value is: Yet another value
Browser console

////

////

Tracker.autorun(function() {
  alert(Session.get('message'));
});

////

Hot Code Reload

////

////

////

 Session.set('pageTitle', 'A brand new title');
 Session.get('pageTitle');
'A brand new title'
Browser console

////

 Session.get('pageTitle');
'A brand new title'
Browser console

////

////

////

 Session.get('pageTitle');
null
Browser console

////

////

  1. ////
  2. ////

Adding Users

6

////

////

////

Accounts: users made simple

////

////

////

$ mrt add accounts-ui-bootstrap-dropdown
$ mrt add accounts-password
Terminal

////

////

<template name="layout">
  <div class="container">
    {{>header}}
    <div id="main" class="row-fluid">
      {{yield}}
    </div>
  </div>
</template>
client/views/application/layout.html
<template name="header">
  <header class="navbar">
    <div class="navbar-inner">
      <a class="btn btn-navbar" data-toggle="collapse" data-target=".nav-collapse">
        <span class="icon-bar"></span>
        <span class="icon-bar"></span>
        <span class="icon-bar"></span>
      </a>
      <a class="brand" href="{{pathFor 'postsList'}}">Microscope</a>
      <div class="nav-collapse collapse">
        <ul class="nav pull-right">
          <li>{{loginButtons}}</li>
        </ul>
      </div>
    </div>
  </header>
</template>
client/views/includes/header.html

////

Meteor's built-in accounts UI
Meteor’s built-in accounts UI

////

////

Accounts.ui.config({
  passwordSignupFields: 'USERNAME_ONLY'
});
client/helpers/config.js

Commit 6-1

Added accounts and added template to the header

Creating Our First User

////

////

 Meteor.users.findOne();
Browser console

////

////

 Meteor.users.find().count();
1
Browser console

////

////

> db.users.count()
2
Mongo console

////

A Mystery Publication!

////

////

////

////

////

////

> db.users.findOne()
{
  "createdAt" : 1365649830922,
  "_id" : "kYdBd9hr3fWPGPcii",
  "services" : {
    "password" : {
      "srp" : {
        "identity" : "qyFCnw4MmRbmGyBdN",
        "salt" : "YcBjRa7ArXn5tdCdE",
        "verifier" : "df2c001edadf4e475e703fa8cd093abd4b63afccbca48fad1d2a0986ff2bcfba920d3f122d358c4af0c287f8eaf9690a2c7e376d701ab2fe1acd53a5bc3e843905d5dcaf2f1c47c25bf5dd87764d1f58c8c01e4539872a9765d2b27c700dcdedadf5ac82521467356d3f91dbeaf9848158987c6d359c5423e6b9cabf34fa0b45"
      }
    },
    "resume" : {
      "loginTokens" : [
        {
          "token" : "BMHipQqjfLoPz7gru",
          "when" : 1365649830922
        }
      ]
    }
  },
  "username" : "tmeasday"
}
Mongo console

////

 Meteor.users.findOne();
Object {_id: "kYdBd9hr3fWPGPcii", username: "tmeasday"}
Browser console

////

////

Reactivity

Sidebar 6.5

////

////

////

////

Posts.find().observe({
  added: function(post) {
    // when 'added' callback fires, add HTML element
    $('ul').append('<li id="' + post._id + '">' + post.title + '</li>');
  },
  changed: function(post) {
    // when 'changed' callback fires, modify HTML element's text
    $('ul li#' + post._id).text(post.title);
  },
  removed: function(post) {
    // when 'removed' callback fires, remove HTML element
    $('ul li#' + post._id).remove();
  }
});

////

When Should We Use observe()?

////

////

A Declarative Approach

////

////

////

<template name="postsList">
  <ul>
    {{#each posts}}
      <li>{{title}}</li>
    {{/each}}
  </ul>
</template>

////

Template.postsList.helpers({
  posts: function() {
    return Posts.find();
  }
});

////

Dependency Tracking in Meteor: Computations

////

////

////

////

////

Setting Up a Computation

////

Deps.autorun(function() {
  console.log('There are ' + Posts.find().count() + ' posts');
});

////

> Posts.insert({title: 'New Post'});
There are 4 posts.

////

Creating Posts

7

////

////

Building The New Post Page

////

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  waitOn: function() { return Meteor.subscribe('posts'); }
});

Router.map(function() {
  this.route('postsList', {path: '/'});

  this.route('postPage', {
    path: '/posts/:_id',
    data: function() { return Posts.findOne(this.params._id); }
  });

  this.route('postSubmit', {
    path: '/submit'
  });
});
lib/router.js

////

Adding A Link To The Header

////

<template name="header">
  <header class="navbar">
    <div class="navbar-inner">
      <a class="btn btn-navbar" data-toggle="collapse" data-target=".nav-collapse">
        <span class="icon-bar"></span>
        <span class="icon-bar"></span>
        <span class="icon-bar"></span>
      </a>
      <a class="brand" href="{{pathFor 'postsList'}}">Microscope</a>
      <div class="nav-collapse collapse">
        <ul class="nav">
          <li><a href="{{pathFor 'postSubmit'}}">New</a></li>
        </ul>
        <ul class="nav pull-right">
          <li>{{loginButtons}}</li>
        </ul>
      </div>
    </div>
  </header>
</template>
client/views/includes/header.html

////

<template name="postSubmit">
  <form class="main">
    <div class="control-group">
        <label class="control-label" for="url">URL</label>
        <div class="controls">
            <input name="url" type="text" value="" placeholder="Your URL"/>
        </div>
    </div>

    <div class="control-group">
        <label class="control-label" for="title">Title</label>
        <div class="controls">
            <input name="title" type="text" value="" placeholder="Name your post"/>
        </div>
    </div>

    <div class="control-group">
        <label class="control-label" for="message">Message</label>
        <div class="controls">
            <textarea name="message" type="text" value=""/>
        </div>
    </div> 

    <div class="control-group">
        <div class="controls">
            <input type="submit" value="Submit" class="btn btn-primary"/>
        </div>
    </div>
  </form>
</template>

client/views/posts/post_submit.html

////

The post submit form
The post submit form

////

Creating Posts

////

Template.postSubmit.events({
  'submit form': function(e) {
    e.preventDefault();

    var post = {
      url: $(e.target).find('[name=url]').val(),
      title: $(e.target).find('[name=title]').val(),
      message: $(e.target).find('[name=message]').val()
    }

    post._id = Posts.insert(post);
    Router.go('postPage', post);
  }
});
client/views/posts/post_submit.js

Commit 7-1

Added a submit post page and linked to it in the header.

////

////

////

Adding Some Security

////

////

////

$ meteor remove insecure
Terminal

////

Allowing Post Inserts

////

Posts = new Meteor.Collection('posts');

Posts.allow({
  insert: function(userId, doc) {
    // only allow posting if you are logged in
    return !! userId;
  }
});
collections/posts.js

Commit 7-2

Removed insecure, and allowed certain writes to posts.

////

////

////

Insert failed: Access denied
Insert failed: Access denied

////

  • ////
  • ////
  • ////

////

Securing Access To The New Post Form

////

////

////

Router.configure({
  layoutTemplate: 'layout'
});

Router.map(function() {
  this.route('postsList', {path: '/'});

  this.route('postPage', {
    path: '/posts/:_id',
    data: function() { return Posts.findOne(this.params._id); }
  });

  this.route('postSubmit', {
    path: '/submit'
  });
});

var requireLogin = function() {
  if (! Meteor.user()) {
    this.render('accessDenied');
    this.stop();
  }
}

Router.before(requireLogin, {only: 'postSubmit'});
lib/router.js

////

<template name="accessDenied">
  <div class="alert alert-error">You can't get here! Please log in.</div>
</template>
client/views/includes/access_denied.html

Commit 7-3

Denied access to new posts page when not logged in.

////

The access denied template
The access denied template

////

////

////

////

////

Router.map(function() {
  this.route('postsList', {path: '/'});

  this.route('postPage', {
    path: '/posts/:_id',
    data: function() { return Posts.findOne(this.params._id); }
  });

  this.route('postSubmit', {
    path: '/submit'
  });
});

var requireLogin = function() {
  if (! Meteor.user()) {
    if (Meteor.loggingIn())
      this.render(this.loadingTemplate);
    else
      this.render('accessDenied');

    this.stop();
  }
}

Router.before(requireLogin, {only: 'postSubmit'});
lib/router.js

Commit 7-4

Show a loading screen while waiting to login.

Hiding the Link

////

<ul class="nav">
  {{#if currentUser}}<li><a href="{{pathFor 'postSubmit'}}">Submit Post</a></li>{{/if}}
</ul>
client/views/includes/header.html

Commit 7-5

Only show submit post link if logged in.

////

Meteor Method: Better Abstraction and Security

////

  • ////
  • ////
  • ////

////

  • ////
  • ////
  • ////

////

////

////

Template.postSubmit.events({
  'submit form': function(e) {
    e.preventDefault();

    var post = {
      url: $(e.target).find('[name=url]').val(),
      title: $(e.target).find('[name=title]').val(),
      message: $(e.target).find('[name=message]').val()
    }

    Meteor.call('post', post, function(error, id) {
      if (error)
        return alert(error.reason);

      Router.go('postPage', {_id: id});
    });
  }
});
client/views/posts/post_submit.js

////

////

Posts = new Meteor.Collection('posts');

Meteor.methods({
  post: function(postAttributes) {
    var user = Meteor.user(),
      postWithSameLink = Posts.findOne({url: postAttributes.url});

    // ensure the user is logged in
    if (!user)
      throw new Meteor.Error(401, "You need to login to post new stories");

    // ensure the post has a title
    if (!postAttributes.title)
      throw new Meteor.Error(422, 'Please fill in a headline');

    // check that there are no previous posts with the same link
    if (postAttributes.url && postWithSameLink) {
      throw new Meteor.Error(302, 
        'This link has already been posted', 
        postWithSameLink._id);
    }

    // pick out the whitelisted keys
    var post = _.extend(_.pick(postAttributes, 'url', 'title', 'message'), {
      userId: user._id, 
      author: user.username, 
      submitted: new Date().getTime()
    });

    var postId = Posts.insert(post);

    return postId;
  }
});
collections/posts.js

Commit 7-6

Use a method to submit the post.

////

////

////

////

////

////

////

Sorting Posts

////

Template.postsList.helpers({
  posts: function() {
    return Posts.find({}, {sort: {submitted: -1}});
  }
});
client/views/posts/posts_list.js

Commit 7-7

Sort posts by submitted timestamp.

////

////

Latency Compensation

Sidebar 7.5

////

Without latency compensation
Without latency compensation

////

////

  • +0ms: ////
  • +200ms: ////
  • +500ms: ////

If this were the way Meteor operated, then there’d be a short lag between performing such actions and seeing the results (that lag being more or less noticeable depending on how close you were to the server). We can’t have that in a modern web application!

Latency Compensation

With latency compensation
With latency compensation

////

////

  • +0ms: ////
  • +0ms: ////
  • +200ms: ////
  • +500ms: ////

////

Observing Latency Compensation

////

////

////

Meteor.methods({
  post: function(postAttributes) {
    // […]

    // pick out the whitelisted keys
    var post = _.extend(_.pick(postAttributes, 'url', 'message'), {
      title: postAttributes.title + (this.isSimulation ? '(client)' : '(server)'),
      userId: user._id, 
      author: user.username, 
      submitted: new Date().getTime()
    });

    // wait for 5 seconds
    if (! this.isSimulation) {
      var Future = Npm.require('fibers/future');
      var future = new Future();
      Meteor.setTimeout(function() {
        future.return();
      }, 5 * 1000);
      future.wait();
    }

    var postId = Posts.insert(post);

    return postId;
  }
});
collections/posts.js

////

////

////

Template.postSubmit.events({
  'submit form': function(event) {
    event.preventDefault();

    var post = {
      url: $(event.target).find('[name=url]').val(),
      title: $(event.target).find('[name=title]').val(),
      message: $(event.target).find('[name=message]').val()
    }

    Meteor.call('post', post, function(error, id) {
      if (error)
        return alert(error.reason);
    });
    Router.go('postsList');
  }
});
client/views/posts/post_submit.js

Commit 7-5-1

Demonstrate the order that posts appear using a sleep.

////

Our post as first stored in the client collection
Our post as first stored in the client collection

////

Our post once the client receives the update from the server collection
Our post once the client receives the update from the server collection

Client Collection Methods

////

////

  1. ////
  2. ////

Methods Calling Methods

////

////

////

Editing Posts

8

////

////

Router.configure({
  layoutTemplate: 'layout'
});

Router.map(function() {
  this.route('postsList', {path: '/'});

  this.route('postPage', {
    path: '/posts/:_id',
    data: function() { return Posts.findOne(this.params._id); }
  });

  this.route('postEdit', {
    path: '/posts/:_id/edit',
    data: function() { return Posts.findOne(this.params._id); }
  });

  this.route('postSubmit', {
    path: '/submit'
  });
});

var requireLogin = function() {
  if (! Meteor.user()) {
    if (Meteor.loggingIn())
      this.render('loading')
    else
      this.render('accessDenied');

    this.stop();
  }
}

Router.before(requireLogin, {only: 'postSubmit'});
lib/router.js

The Post Edit Template

////

<template name="postEdit">
  <form class="main">
    <div class="control-group">
        <label class="control-label" for="url">URL</label>
        <div class="controls">
            <input name="url" type="text" value="{{url}}" placeholder="Your URL"/>
        </div>
    </div>

    <div class="control-group">
        <label class="control-label" for="title">Title</label>
        <div class="controls">
            <input name="title" type="text" value="{{title}}" placeholder="Name your post"/>
        </div>
    </div>

    <div class="control-group">
        <div class="controls">
            <input type="submit" value="Submit" class="btn btn-primary submit"/>
        </div>
    </div>
    <hr/>
    <div class="control-group">
        <div class="controls">
            <a class="btn btn-danger delete" href="#">Delete post</a>
        </div>
    </div>
  </form>
</template>
client/views/posts/post_edit.html

////

Template.postEdit.events({
  'submit form': function(e) {
    e.preventDefault();

    var currentPostId = this._id;

    var postProperties = {
      url: $(e.target).find('[name=url]').val(),
      title: $(e.target).find('[name=title]').val()
    }

    Posts.update(currentPostId, {$set: postProperties}, function(error) {
      if (error) {
        // display the error to the user
        alert(error.reason);
      } else {
        Router.go('postPage', {_id: currentPostId});
      }
    });
  },

  'click .delete': function(e) {
    e.preventDefault();

    if (confirm("Delete this post?")) {
      var currentPostId = this._id;
      Posts.remove(currentPostId);
      Router.go('postsList');
    }
  }
});
client/views/posts/post_edit.js

////

////

////

////

////

Adding Links

////

<template name="postItem">
  <div class="post">
    <div class="post-content">
      <h3><a href="{{url}}">{{title}}</a><span>{{domain}}</span></h3>
      <p>
        submitted by {{author}}
        {{#if ownPost}}<a href="{{pathFor 'postEdit'}}">Edit</a>{{/if}}
      </p>
    </div>
    <a href="{{pathFor 'postPage'}}" class="discuss btn">Discuss</a>
  </div>
</template>
client/views/posts/post_item.html

////

Template.postItem.helpers({
  ownPost: function() {
    return this.userId == Meteor.userId();
  },
  domain: function() {
    var a = document.createElement('a');
    a.href = this.url;
    return a.hostname;
  }
});
client/views/posts/post_item.js
Post edit form.
Post edit form.

Commit 8-1

Added edit posts form.

////

Setting Up Permissions

////

////

// check that the userId specified owns the documents
ownsDocument = function(userId, doc) {
  return doc && doc.userId === userId;
}
lib/permissions.js

////

////

Posts = new Meteor.Collection('posts');

Posts.allow({
  update: ownsDocument,
  remove: ownsDocument
});

Meteor.methods({
  ...
collections/posts.js

Commit 8-2

Added basic permission to check the post’s owner.

Limiting Edits

////

////

Posts = new Meteor.Collection('posts');

Posts.allow({
  update: ownsDocument,
  remove: ownsDocument
});

Posts.deny({
  update: function(userId, post, fieldNames) {
    // may only edit the following two fields:
    return (_.without(fieldNames, 'url', 'title').length > 0);
  }
});
collections/posts.js

Commit 8-3

Only allow changing certain fields of posts.

////

////

Method Calls vs Client-side Data Manipulation

////

////

////

////

////

////

  • ////
  • ////
  • ////

Allow and Deny

Sidebar 8.5

////

////

////

////

Multiple callbacks

////

////

Note: n/e stands for Not Executed
Note: n/e stands for Not Executed

////

////

Latency Compensation

////

////

////

Server-side permissions

////

////

Using deny as a callback

////

Posts.deny({
  update: function(userId, doc, fields, modifier) {
    doc.lastModified = +(new Date());
    return false;
  },
  transform: null
});

////

////

Errors

9

////

////

Introducing Local Collections

////

////

////

////

// Local (client-only) collection
Errors = new Meteor.Collection(null);
client/helpers/errors.js

////

throwError = function(message) {
  Errors.insert({message: message})
}
client/helpers/errors.js

////

Displaying errors

////

<template name="layout">
  <div class="container">
    {{> header}}
    {{> errors}}
    <div id="main" class="row-fluid">
      {{yield}}
    </div>
  </div>
</template>
client/views/application/layout.html

////

<template name="errors">
  <div class="errors row-fluid">
    {{#each errors}}
      {{> error}}
    {{/each}}
  </div>
</template>

<template name="error">
  <div class="alert alert-error">
    <button type="button" class="close" data-dismiss="alert">&times;</button>
    {{message}}
  </div>
</template>
client/views/includes/errors.html

Twin Templates

////

////

////

Template.errors.helpers({
  errors: function() {
    return Errors.find();
  }
});
client/views/includes/errors.js

Commit 9-1

Basic error reporting.

Creating errors

////

////

Template.postSubmit.events({
  'submit form': function(e) {
    e.preventDefault();

    var post = {
      url: $(e.target).find('[name=url]').val(),
      title: $(e.target).find('[name=title]').val(),
      message: $(e.target).find('[name=message]').val()
    }

    Meteor.call('post', post, function(error, id) {
      if (error) {
        // display the error to the user
        throwError(error.reason);

        if (error.error === 302)
          Router.go('postPage', {_id: error.details})
      } else {
        Router.go('postPage', {_id: id});
      }
    });
  }
});
client/views/posts/post_submit.js

Commit 9-2

Actually use the error reporting.

////

Triggering an error
Triggering an error

Clearing Errors

////

////

////

////

////

// Local (client-only) collection
Errors = new Meteor.Collection(null);

throwError = function(message) {
  Errors.insert({message: message, seen: false})
}

clearErrors = function() {
  Errors.remove({seen: true});
}
client/helpers/errors.js

////

// ...

Router.before(requireLogin, {only: 'postSubmit'})
Router.before(function() { clearErrors() });
lib/router.js

////

////

////

////

////

Template.errors.helpers({
  errors: function() {
    return Errors.find();
  }
});

Template.error.rendered = function() {
  var error = this.data;
  Meteor.defer(function() {
    Errors.update(error._id, {$set: {seen: true}});
  });
};
client/views/includes/errors.js

Commit 9-3

Monitor which errors have been seen, and clear on routing.

////

////

The rendered callback

////

////

Creating a Meteorite Package

Sidebar 9.5

////

////

////

Package.describe({
  summary: "A pattern to display application errors to the user"
});

Package.on_use(function (api, where) {
  api.use(['minimongo', 'mongo-livedata', 'templating'], 'client');

  api.add_files(['errors.js', 'errors_list.html', 'errors_list.js'], 'client');

  if (api.export) 
    api.export('Errors');
});
packages/errors/package.js

////

Errors = {
  // Local (client-only) collection
  collection: new Meteor.Collection(null),

  throw: function(message) {
    Errors.collection.insert({message: message, seen: false})
  },
  clearSeen: function() {
    Errors.collection.remove({seen: true});
  }
};

packages/errors/errors.js
<template name="meteorErrors">
  {{#each errors}}
    {{> meteorError}}
  {{/each}}
</template>

<template name="meteorError">
  <div class="alert alert-error">
    <button type="button" class="close" data-dismiss="alert">&times;</button>
    {{message}}
  </div>
</template>
packages/errors/errors_list.html
Template.meteorErrors.helpers({
  errors: function() {
    return Errors.collection.find();
  }
});

Template.meteorError.rendered = function() {
  var error = this.data;
  Meteor.defer(function() {
    Errors.collection.update(error._id, {$set: {seen: true}});
  });
};
packages/errors/errors_list.js

Testing the package out with Microscope

////

$ rm client/helpers/errors.js
$ rm client/views/includes/errors.html
$ rm client/views/includes/errors.js
removing old files on the bash console

////

Router.before(function() { Errors.clearSeen(); });
lib/router.js
  {{> header}}
  {{> meteorErrors}}
client/views/application/layout.html
Meteor.call('post', post, function(error, id) {
  if (error) {
    // display the error to the user
    Errors.throw(error.reason);

client/views/posts/post_submit.js
Posts.update(currentPostId, {$set: postProperties}, function(error) {
  if (error) {
    // display the error to the user
    Errors.throw(error.reason);
client/views/posts/post_edit.js

Commit 9-5-1

Created basic errors package and linked it in.

////

Writing tests

////

////

Tinytest.add("Errors collection works", function(test) {
  test.equal(Errors.collection.find({}).count(), 0);

  Errors.throw('A new error!');
  test.equal(Errors.collection.find({}).count(), 1);

  Errors.collection.remove({});
});

Tinytest.addAsync("Errors template works", function(test, done) {  
  Errors.throw('A new error!');
  test.equal(Errors.collection.find({seen: false}).count(), 1);

  // render the template
  OnscreenDiv(Spark.render(function() {
    return Template.meteorErrors();
  }));

  // wait a few milliseconds
  Meteor.setTimeout(function() {
    test.equal(Errors.collection.find({seen: false}).count(), 0);
    test.equal(Errors.collection.find({}).count(), 1);
    Errors.clearSeen();

    test.equal(Errors.collection.find({seen: true}).count(), 0);
    done();
  }, 500);
});
packages/errors/errors_tests.js

////

////

////

Package.on_test(function(api) {
  api.use('errors', 'client');
  api.use(['tinytest', 'test-helpers'], 'client');  

  api.add_files('errors_tests.js', 'client');
});
packages/errors/package.js

Commit 9-5-2

Added tests to the package.

////

$ meteor test-packages errors
Terminal
Passing all tests
Passing all tests

Releasing the package

////

////

{
  "name": "errors",
  "description": "A pattern to display application errors to the user",
  "homepage": "https://github.com/tmeasday/meteor-errors",
  "author": "Tom Coleman <tom@thesnail.org>",
  "version": "0.1.0",
  "git": "https://github.com/tmeasday/meteor-errors.git",
  "packages": {
  }
}
packages/errors/smart.json

Commit 9-5-3

Added a smart.json

////

////

////

$ git init
$ git add -A
$ git commit -m "Created Errors Package"
$ git remote add origin https://github.com/tmeasday/meteor-errors.git
$ git push origin master
$ mrt release .
Done!
Terminal (run from within `packages/errors`)

////

////

////

$ rm -r packages/errors
$ mrt add errors
Terminal (run from the top level of the app)

Commit 9-5-4

Removed package from development tree.

////

Comments

10

////

////

Comments = new Meteor.Collection('comments');
collections/comments.js
// Fixture data 
if (Posts.find().count() === 0) {
  var now = new Date().getTime();

  // create two users
  var tomId = Meteor.users.insert({
    profile: { name: 'Tom Coleman' }
  });
  var tom = Meteor.users.findOne(tomId);
  var sachaId = Meteor.users.insert({
    profile: { name: 'Sacha Greif' }
  });
  var sacha = Meteor.users.findOne(sachaId);

  var telescopeId = Posts.insert({
    title: 'Introducing Telescope',
    userId: sacha._id,
    author: sacha.profile.name,
    url: 'http://sachagreif.com/introducing-telescope/',
    submitted: now - 7 * 3600 * 1000
  });

  Comments.insert({
    postId: telescopeId,
    userId: tom._id,
    author: tom.profile.name,
    submitted: now - 5 * 3600 * 1000,
    body: 'Interesting project Sacha, can I get involved?'
  });

  Comments.insert({
    postId: telescopeId,
    userId: sacha._id,
    author: sacha.profile.name,
    submitted: now - 3 * 3600 * 1000,
    body: 'You sure can Tom!'
  });

  Posts.insert({
    title: 'Meteor',
    userId: tom._id,
    author: tom.profile.name,
    url: 'http://meteor.com',
    submitted: now - 10 * 3600 * 1000
  });

  Posts.insert({
    title: 'The Meteor Book',
    userId: tom._id,
    author: tom.profile.name,
    url: 'http://themeteorbook.com',
    submitted: now - 12 * 3600 * 1000
  });
}
server/fixtures.js

////

Meteor.publish('posts', function() {
  return Posts.find();
});

Meteor.publish('comments', function() {
  return Comments.find();
});
server/publications.js
Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  waitOn: function() { 
    return [Meteor.subscribe('posts'), Meteor.subscribe('comments')];
  }
});
lib/router.js

Commit 10-1

Added comments collection, pub/sub and fixtures.

////

////

////

Displaying comments

////

<template name="postPage">
  {{> postItem}}

  <ul class="comments">
    {{#each comments}}
      {{> comment}}
    {{/each}}
  </ul>
</template>
client/views/posts/post_page.html
Template.postPage.helpers({
  comments: function() {
    return Comments.find({postId: this._id});
  }
});
client/views/posts/post_page.js

////

////

<template name="comment">
  <li>
    <h4>
      <span class="author">{{author}}</span>
      <span class="date">on {{submittedText}}</span>
    </h4>
    <p>{{body}}</p>
  </li>
</template>
client/views/comments/comment.html

////

Template.comment.helpers({
  submittedText: function() {
    return new Date(this.submitted).toString();
  }
});
client/views/comments/comment.js

////

<template name="postItem">
  <div class="post">
    <div class="post-content">
      <h3><a href="{{url}}">{{title}}</a><span>{{domain}}</span></h3>
      <p>
        submitted by {{author}},
        <a href="{{pathFor 'postPage'}}">{{commentsCount}} comments</a>
        {{#if ownPost}}<a href="{{pathFor 'postEdit'}}">Edit</a>{{/if}}
      </p>
    </div>
    <a href="{{pathFor 'postPage'}}" class="discuss btn">Discuss</a>
  </div>
</template>
client/views/posts/post_item.html

////

Template.postItem.helpers({
  ownPost: function() {
    return this.userId == Meteor.userId();
  },
  domain: function() {
    var a = document.createElement('a');
    a.href = this.url;
    return a.hostname;
  },
  commentsCount: function() {
    return Comments.find({postId: this._id}).count();
  }
});
client/views/posts/post_item.js

Commit 10-2

Display comments on `postPage`.

////

Displaying comments
Displaying comments

Submitting Comments

////

////

<template name="postPage">
  {{> postItem}}

  <ul class="comments">
    {{#each comments}}
      {{> comment}}
    {{/each}}
  </ul>

  {{#if currentUser}}
    {{> commentSubmit}}
  {{else}}
    <p>Please log in to leave a comment.</p>
  {{/if}}
</template>
client/views/posts/post_page.html

////

<template name="commentSubmit">
  <form name="comment" class="comment-form">
    <div class="control-group">
        <div class="controls">
            <label for="body">Comment on this post</label>
            <textarea name="body"></textarea>
        </div>
    </div>
    <div class="control-group">
        <div class="controls">
            <button type="submit" class="btn">Add Comment</button>
        </div>
    </div>
  </form>
</template>
client/views/comments/comment_submit.html
The comment submit form
The comment submit form

////

Template.commentSubmit.events({
  'submit form': function(e, template) {
    e.preventDefault();

    var $body = $(e.target).find('[name=body]');
    var comment = {
      body: $body.val(),
      postId: template.data._id
    };

    Meteor.call('comment', comment, function(error, commentId) {
      if (error){
        throwError(error.reason);
      } else {
        $body.val('');
      }
    });
  }
});
client/views/comments/comment_submit.js

////

Comments = new Meteor.Collection('comments');

Meteor.methods({
  comment: function(commentAttributes) {
    var user = Meteor.user();
    var post = Posts.findOne(commentAttributes.postId);
    // ensure the user is logged in
    if (!user)
      throw new Meteor.Error(401, "You need to login to make comments");

    if (!commentAttributes.body)
      throw new Meteor.Error(422, 'Please write some content');

    if (!post)
      throw new Meteor.Error(422, 'You must comment on a post');

    comment = _.extend(_.pick(commentAttributes, 'postId', 'body'), {
      userId: user._id,
      author: user.username,
      submitted: new Date().getTime()
    });

    return Comments.insert(comment);
  }
});
collections/comments.js

Commit 10-3

Created a form to submit comments.

////

Controlling the Comments Subscription

////

////

////

////

////

////

Router.map(function() {

  //...

  this.route('postPage', {
    path: '/posts/:_id',
    waitOn: function() {
      return Meteor.subscribe('comments', this.params._id);
    },
    data: function() { return Posts.findOne(this.params._id); }
  });

  //...

});
lib/router.js

////

Meteor.publish('posts', function() {
  return Posts.find();
});

Meteor.publish('comments', function(postId) {
  return Comments.find({postId: postId});
});
server/publications.js

Commit 10-4

Made a simple publication/subscription for comments.

////

Our comments are gone!
Our comments are gone!

Counting Comments

////

////

////

var telescopeId = Posts.insert({
  title: 'Introducing Telescope',
  ..
  commentsCount: 2
});

Posts.insert({
  title: 'Meteor',
  ...
  commentsCount: 0
});

Posts.insert({
  title: 'The Meteor Book',
  ...
  commentsCount: 0
});
server/fixtures.js

////

// pick out the whitelisted keys
var post = _.extend(_.pick(postAttributes, 'url', 'title', 'message'), {
  userId: user._id, 
  author: user.username, 
  submitted: new Date().getTime(),
  commentsCount: 0
});

var postId = Posts.insert(post);
collections/posts.js

////

// update the post with the number of comments
Posts.update(comment.postId, {$inc: {commentsCount: 1}});

return Comments.insert(comment);
collections/comments.js

////

Commit 10-5

Denormalized the number of comments into the post.

////

Denormalization

Sidebar 10.5

////

////

////

////

A Special Publication

////

////

////

Embedding Documents or Using Multiple Collections

////

////

  1. ////
  2. ////
  3. ////
  4. ////

////

The Downsides of Denormalization

////

Notifications

11

////

////

////

Creating notifications

////

////

Notifications = new Meteor.Collection('notifications');

Notifications.allow({
  update: ownsDocument
});

createCommentNotification = function(comment) {
  var post = Posts.findOne(comment.postId);
  if (comment.userId !== post.userId) {
    Notifications.insert({
      userId: post.userId,
      postId: post._id,
      commentId: comment._id,
      commenterName: comment.author,
      read: false
    });
  }
};
collections/notifications.js

////

////

////

Comments = new Meteor.Collection('comments');

Meteor.methods({
  comment: function(commentAttributes) {

    // [...]

    // create the comment, save the id
    comment._id = Comments.insert(comment);

    // now create a notification, informing the user that there's been a comment
    createCommentNotification(comment);

    return comment._id;
  }
});
collections/comments.js

////

// [...]

Meteor.publish('notifications', function() {
  return Notifications.find();
});
server/publications.js
Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  waitOn: function() { 
    return [Meteor.subscribe('posts'), Meteor.subscribe('notifications')]
  }
});
lib/router.js

Commit 11-1

Added basic notifications collection.

Displaying Notifications

////

<template name="header">
  <header class="navbar">
    <div class="navbar-inner">
      <a class="btn btn-navbar" data-toggle="collapse" data-target=".nav-collapse">
        <span class="icon-bar"></span>
        <span class="icon-bar"></span>
        <span class="icon-bar"></span>
      </a>
      <a class="brand" href="{{pathFor 'postsList'}}">Microscope</a>
      <div class="nav-collapse collapse">
        <ul class="nav">
          {{#if currentUser}}
            <li>
              <a href="{{pathFor 'postSubmit'}}">Submit Post</a>
            </li>
            <li class="dropdown">
              {{> notifications}}
            </li>
          {{/if}}
        </ul>
        <ul class="nav pull-right">
          <li>{{loginButtons}}</li>
        </ul>
      </div>
    </div>
  </header>
</template>
client/views/includes/header.html

////

<template name="notifications">
  <a href="#" class="dropdown-toggle" data-toggle="dropdown">
    Notifications
    {{#if notificationCount}}
      <span class="badge badge-inverse">{{notificationCount}}</span>
    {{/if}}
    <b class="caret"></b>
  </a>
  <ul class="notification dropdown-menu">
    {{#if notificationCount}}
      {{#each notifications}}
        {{> notification}}
      {{/each}}
    {{else}}
      <li><span>No Notifications</span></li>
    {{/if}}
  </ul>
</template>

<template name="notification">
  <li>
    <a href="{{notificationPostPath}}">
      <strong>{{commenterName}}</strong> commented on your post
    </a>
  </li>
</template>
client/views/notifications/notifications.html

////

////

Template.notifications.helpers({
  notifications: function() {
    return Notifications.find({userId: Meteor.userId(), read: false});
  },
  notificationCount: function(){
    return Notifications.find({userId: Meteor.userId(), read: false}).count();
  }
});

Template.notification.helpers({
  notificationPostPath: function() {
    return Router.routes.postPage.path({_id: this.postId});
  }
})

Template.notification.events({
  'click a': function() {
    Notifications.update(this._id, {$set: {read: true}});
  }
})
client/views/notifications/notifications.js

Commit 11-2

Display notifications in the header.

////

////

Displaying notifications.
Displaying notifications.

Controlling access to notifications

////

////

 Notifications.find().count();
1
Browser console

////

////

////

////

////

Meteor.publish('notifications', function() {
  return Notifications.find({userId: this.userId});
});
server/publications.js

Commit 11-3

Only sync notifications that are relevant to the user.

////

 Notifications.find().count();
1
Browser console (user 1)
 Notifications.find().count();
0
Browser console (user 2)

////

////

Advanced Reactivity

Sidebar 11.5

////

////

////

////

currentLikeCount = 0;
Meteor.setInterval(function() {
  var postId;
  if (Meteor.user() && postId = Session.get('currentPostId')) {
    getFacebookLikeCount(Meteor.user(), Posts.find(postId), 
      function(err, count) {
        if (!err)
          currentLikeCount = count;
      });
  }
}, 5 * 1000);

////

Template.postItem.likeCount = function() {
  return currentLikeCount;
}

////

Tracking Reactivity: Computations

////

////

////

Turning a Variable Into a Reactive Function

////

var _currentLikeCount = 0;
var _currentLikeCountListeners = new Deps.Dependency();

currentLikeCount = function() {
  _currentLikeCountListeners.depend();
  return _currentLikeCount;
}

Meteor.setInterval(function() {
  var postId;
  if (Meteor.user() && postId = Session.get('currentPostId')) {
    getFacebookLikeCount(Meteor.user(), Posts.find(postId), 
      function(err, count) {
        if (!err && count !== _currentLikeCount) {
          _currentLikeCount = count;
          _currentLikeCountListeners.changed();
        }
      });
  }
}, 5 * 1000);

////

////

Template Computation and Controlling Redraws

////

////

////

////

////

////

Comparing Deps to Angular

////

////

////

////

////

////

$rootScope.$watch('currentLikeCount', function(likeCount) {
  console.log('Current like count is ' + likeCount);
});

////

////

////

////

Meteor.setInterval(function() {
  getFacebookLikeCount(Meteor.user(), Posts.find(postId), 
    function(err, count) {
      if (!err) {
        $rootScope.currentLikeCount = count;
        $rootScope.$apply();
      }
    });
}, 5 * 1000);

////

Pagination

12

////

////

////

////

Adding More Posts

////

// Fixture data 
if (Posts.find().count() === 0) {

  //...

  Posts.insert({
    title: 'The Meteor Book',
    userId: tom._id,
    author: tom.profile.name,
    url: 'http://themeteorbook.com',
    submitted: now - 12 * 3600 * 1000,
    commentsCount: 0
  });

  for (var i = 0; i < 10; i++) {
    Posts.insert({
      title: 'Test post #' + i,
      author: sacha.profile.name,
      userId: sacha._id,
      url: 'http://google.com/?q=test-' + i,
      submitted: now - i * 3600 * 1000,
      commentsCount: 0
    });
  }
}
server/fixtures.js

////

Displaying dummy data.
Displaying dummy data.

Commit 12-1

Added enough posts that pagination is necessary.

Infinite Pagination

////

////

////

////

////

////

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  waitOn: function() { 
    return [Meteor.subscribe('notifications')]
  }
});
lib/router.js

////

Router.map(function() {
  //...

  this.route('postsList', {
    path: '/:postsLimit?'
  });
});
lib/router.js

////

////

////

Router.map(function() {
  //..

  this.route('postsList', {
    path: '/:postsLimit?',
    waitOn: function() {
      var postsLimit = parseInt(this.params.postsLimit) || 5; 
      return Meteor.subscribe('posts', {sort: {submitted: -1}, limit: postsLimit});
    }
  });
});
lib/router.js

////

Meteor.publish('posts', function(options) {
  return Posts.find({}, options);
});

Meteor.publish('comments', function(postId) {
  return Comments.find({postId: postId});
});

Meteor.publish('notifications', function() {
  return Notifications.find({userId: this.userId});
});
server/publications.js

Passing Parameters

////

////

////

////

Meteor.publish('posts', function(sort, limit) {
  return Posts.find({}, {sort: sort, limit: limit});
});

////

////

Router.map(function() {
  this.route('postsList', {
    path: '/:postsLimit?',
    waitOn: function() {
      var limit = parseInt(this.params.postsLimit) || 5; 
      return Meteor.subscribe('posts', {sort: {submitted: -1}, limit: limit});
    },
    data: function() {
      var limit = parseInt(this.params.postsLimit) || 5; 
      return {
        posts: Posts.find({}, {sort: {submitted: -1}, limit: limit})
      };
    }
  });

  //..
});
lib/router.js

////

////

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  waitOn: function() { 
    return [Meteor.subscribe('notifications')]
  }
});

Router.map(function() {
  //...

  this.route('postsList', {
    path: '/:postsLimit?',
    waitOn: function() {
      var limit = parseInt(this.params.postsLimit) || 5; 
      return Meteor.subscribe('posts', {sort: {submitted: -1}, limit: limit});
    },
    data: function() {
      var limit = parseInt(this.params.postsLimit) || 5; 
      return {
        posts: Posts.find({}, {sort: {submitted: -1}, limit: limit})
      };
    }
  });
});
lib/router.js

Commit 12-2

Augmented the postsList route to take a limit.

////

Controlling the number of posts on the homepage.
Controlling the number of posts on the homepage.

Why Not Pages?

////

////

////

////

////

////

////

////

////

Creating a Route Controller

////

////

PostsListController = RouteController.extend({
  template: 'postsList',
  increment: 5, 
  limit: function() { 
    return parseInt(this.params.postsLimit) || this.increment; 
  },
  findOptions: function() {
    return {sort: {submitted: -1}, limit: this.limit()};
  },
  waitOn: function() {
    return Meteor.subscribe('posts', this.findOptions());
  },
  data: function() {
    return {posts: Posts.find({}, this.findOptions())};
  }
});

Router.map(function() {
  //...

  this.route('postsList', {
    path: '/:postsLimit?',
    controller: PostsListController
  });
});
lib/router.js

////

////

////

////

Commit 12-3

Refactored postsLists route into a RouteController.

Adding A Load More Link

////

////

////

PostsListController = RouteController.extend({
  template: 'postsList',
  increment: 5, 
  limit: function() { 
    return parseInt(this.params.postsLimit) || this.increment; 
  },
  findOptions: function() {
    return {sort: {submitted: -1}, limit: this.limit()};
  },
  waitOn: function() {
    return Meteor.subscribe('posts', this.findOptions());
  },
  posts: function() {
    return Posts.find({}, this.findOptions());
  },
  data: function() {
    var hasMore = this.posts().fetch().length === this.limit();
    var nextPath = this.route.path({postsLimit: this.limit() + this.increment});
    return {
      posts: this.posts(),
      nextPath: hasMore ? nextPath : null
    };
  }
});
lib/router.js

////

////

////

////

////

////

////

////

////

////

<template name="postsList">
  <div class="posts">
    {{#each posts}}
      {{> postItem}}
    {{/each}}

    {{#if nextPath}}
      <a class="load-more" href="{{nextPath}}">Load more</a>
    {{/if}}
  </div>
</template>
client/views/posts/posts_list.html

////

The “load more” button.
The “load more” button.

Commit 12-4

Added nextPath() to the controller and use it to step thr…

Count vs Length

////

A Better Progress Bar

////

////

////

mrt add iron-router-progress
bash console

////

////

Router.map(function() {

  //...

  this.route('postSubmit', {
    path: '/submit',
    disableProgress: true
  });
});
lib/router.js

Commit 12-5

Use the iron-router-progress package to make pagination n…

Accessing Any Post

////

An empty template.
An empty template.

////

////

////

Meteor.publish('posts', function(options) {
  return Posts.find({}, options);
});

Meteor.publish('singlePost', function(id) {
  return id && Posts.find(id);
});
server/publications.js

////

Router.map(function() {

  //...

  this.route('postPage', {
    path: '/posts/:_id',
    waitOn: function() {
      return [
        Meteor.subscribe('singlePost', this.params._id),
        Meteor.subscribe('comments', this.params._id)
      ];
    },
    data: function() { return Posts.findOne(this.params._id); }
  });

  this.route('postEdit', {
    path: '/posts/:_id/edit',
    waitOn: function() { 
      return Meteor.subscribe('singlePost', this.params._id);
    },
    data: function() { return Posts.findOne(this.params._id); }    
  });

  /...

});
lib/router.js

Commit 12-6

Use a single post subscription to ensure that we can alwa…

////

Voting

13

////

////

////

Data Model

////

Data Privacy & Publications

////

////

////

////

// Fixture data 
if (Posts.find().count() === 0) {
  var now = new Date().getTime();

  // create two users
  var tomId = Meteor.users.insert({
    profile: { name: 'Tom Coleman' }
  });
  var tom = Meteor.users.findOne(tomId);
  var sachaId = Meteor.users.insert({
    profile: { name: 'Sacha Greif' }
  });
  var sacha = Meteor.users.findOne(sachaId);

  var telescopeId = Posts.insert({
    title: 'Introducing Telescope',
    userId: sacha._id,
    author: sacha.profile.name,
    url: 'http://sachagreif.com/introducing-telescope/',
    submitted: now - 7 * 3600 * 1000,
    commentsCount: 2,
    upvoters: [], votes: 0
  });

  Comments.insert({
    postId: telescopeId,
    userId: tom._id,
    author: tom.profile.name,
    submitted: now - 5 * 3600 * 1000,
    body: 'Interesting project Sacha, can I get involved?'
  });

  Comments.insert({
    postId: telescopeId,
    userId: sacha._id,
    author: sacha.profile.name,
    submitted: now - 3 * 3600 * 1000,
    body: 'You sure can Tom!'
  });

  Posts.insert({
    title: 'Meteor',
    userId: tom._id,
    author: tom.profile.name,
    url: 'http://meteor.com',
    submitted: now - 10 * 3600 * 1000,
    commentsCount: 0,
    upvoters: [], votes: 0
  });

  Posts.insert({
    title: 'The Meteor Book',
    userId: tom._id,
    author: tom.profile.name,
    url: 'http://themeteorbook.com',
    submitted: now - 12 * 3600 * 1000,
    commentsCount: 0,
    upvoters: [], votes: 0
  });

  for (var i = 0; i < 10; i++) {
    Posts.insert({
      title: 'Test post #' + i,
      author: sacha.profile.name,
      userId: sacha._id,
      url: 'http://google.com/?q=test-' + i,
      submitted: now - i * 3600 * 1000,
      commentsCount: 0,
      upvoters: [], votes: 0
    });
  }
}
server/fixtures.js

////

//...

// check that there are no previous posts with the same link
if (postAttributes.url && postWithSameLink) {
  throw new Meteor.Error(302, 
    'This link has already been posted', 
    postWithSameLink._id);
}

// pick out the whitelisted keys
var post = _.extend(_.pick(postAttributes, 'url', 'title', 'message'), {
  userId: user._id, 
  author: user.username, 
  submitted: new Date().getTime(),
  commentsCount: 0,
  upvoters: [], 
  votes: 0
});

var postId = Posts.insert(post);

return postId;

//...
collections/posts.js

Building our Voting Templates

////

<template name="postItem">
  <div class="post">
    <a href="#" class="upvote btn"></a>
    <div class="post-content">
      <h3><a href="{{url}}">{{title}}</a><span>{{domain}}</span></h3>
      <p>
        {{votes}} Votes,
        submitted by {{author}},
        <a href="{{pathFor 'postPage'}}">{{commentsCount}} comments</a>
        {{#if ownPost}}<a href="{{pathFor 'postEdit'}}">Edit</a>{{/if}}
      </p>
    </div>
    <a href="{{pathFor 'postPage'}}" class="discuss btn">Discuss</a>
  </div>
</template>
client/views/posts/post_item.html
The upvote button
The upvote button

////

//...

Template.postItem.events({
  'click .upvote': function(e) {
    e.preventDefault();
    Meteor.call('upvote', this._id);
  }
});
client/views/posts/post_item.js

////

Meteor.methods({
  post: function(postAttributes) {
    //...
  },

  upvote: function(postId) {
    var user = Meteor.user();
    // ensure the user is logged in
    if (!user)
      throw new Meteor.Error(401, "You need to login to upvote");

    var post = Posts.findOne(postId);
    if (!post)
      throw new Meteor.Error(422, 'Post not found');

    if (_.include(post.upvoters, user._id))
      throw new Meteor.Error(422, 'Already upvoted this post');

    Posts.update(post._id, {
      $addToSet: {upvoters: user._id},
      $inc: {votes: 1}
    });
  }
});
collections/posts.js

Commit 13-1

Added basic upvoting algorithm.

////

////

User Interface Tweaks

////

<template name="postItem">
  <div class="post">
    <a href="#" class="upvote btn {{upvotedClass}}"></a>
    <div class="post-content">
      //...
  </div>
</template>
client/views/posts/post_item.html
Template.postItem.helpers({
  ownPost: function() {
    //...
  },
  domain: function() {
    //...
  },
  upvotedClass: function() {
    var userId = Meteor.userId();
    if (userId && !_.include(this.upvoters, userId)) {
      return 'btn-primary upvotable';
    } else {
      return 'disabled';
    }
  }
});

Template.postItem.events({
  'click .upvotable': function(e) {
    e.preventDefault();
    Meteor.call('upvote', this._id);
  }
});
client/views/posts/post_item.js

////

Greying out upvote buttons.
Greying out upvote buttons.

Commit 13-2

Grey out upvote link when not logged in / already voted.

////

Handlebars.registerHelper('pluralize', function(n, thing) {
  // fairly stupid pluralizer
  if (n === 1) {
    return '1 ' + thing;
  } else {
    return n + ' ' + thing + 's';
  }
});
client/helpers/handlebars.js

////

<template name="postItem">
//...
<p>
  {{pluralize votes "Vote"}},
  submitted by {{author}},
  <a href="{{pathFor 'postPage'}}">{{pluralize commentsCount "comment"}}</a>
  {{#if ownPost}}<a href="{{pathFor 'postEdit'}}">Edit</a>{{/if}}
</p>
//...
</template>
client/views/posts/post_item.html
Perfecting Proper Pluralization (now say that 10 times)
Perfecting Proper Pluralization (now say that 10 times)

Commit 13-3

Added pluralize helper to format text better.

////

Smarter Voting Algorithm

////

////

  1. ////
  2. ////
  3. ////

////

Meteor.methods({
  post: function(postAttributes) {
    //...
  },

  upvote: function(postId) {
    var user = Meteor.user();
    // ensure the user is logged in
    if (!user)
      throw new Meteor.Error(401, "You need to login to upvote");

    Posts.update({
      _id: postId, 
      upvoters: {$ne: user._id}
    }, {
      $addToSet: {upvoters: user._id},
      $inc: {votes: 1}
    });
  }
});
collections/posts.js

Commit 13-4

Better upvoting algorithm.

////

////

Latency Compensation

////

> Posts.update(postId, {$set: {votes: 10000}});
Browser console

////

////

////

////

////

Ranking the Front Page Posts

////

////

////

////

PostsListController = RouteController.extend({
  template: 'postsList',
  increment: 5, 
  limit: function() { 
    return parseInt(this.params.postsLimit) || this.increment; 
  },
  findOptions: function() {
    return {sort: this.sort, limit: this.limit()};
  },
  waitOn: function() {
    return Meteor.subscribe('posts', this.findOptions());
  },
  posts: function() {
    return Posts.find({}, this.findOptions());
  },
  data: function() {
    var hasMore = this.posts().fetch().length === this.limit();
    return {
      posts: this.posts(),
      nextPath: hasMore ? this.nextPath() : null
    };
  }
});

NewPostsListController = PostsListController.extend({
  sort: {submitted: -1, _id: -1},
  nextPath: function() {
    return Router.routes.newPosts.path({postsLimit: this.limit() + this.increment})
  }
});

BestPostsListController = PostsListController.extend({
  sort: {votes: -1, submitted: -1, _id: -1},
  nextPath: function() {
    return Router.routes.bestPosts.path({postsLimit: this.limit() + this.increment})
  }
});

Router.map(function() {
  this.route('home', {
    path: '/',
    controller: NewPostsListController
  });

  this.route('newPosts', {
    path: '/new/:postsLimit?',
    controller: NewPostsListController
  });

  this.route('bestPosts', {
    path: '/best/:postsLimit?',
    controller: BestPostsListController
  });
  // ..
});
lib/router.js

////

////

////

<template name="header">
  <header class="navbar">
    <div class="navbar-inner">
      <a class="btn btn-navbar" data-toggle="collapse" data-target=".nav-collapse">
        <span class="icon-bar"></span>
        <span class="icon-bar"></span>
        <span class="icon-bar"></span>
      </a>
      <a class="brand" href="{{pathFor 'home'}}">Microscope</a>
      <div class="nav-collapse collapse">
        <ul class="nav">
          <li>
            <a href="{{pathFor 'newPosts'}}">New</a>
          </li>
          <li>
            <a href="{{pathFor 'bestPosts'}}">Best</a>
          </li>
          {{#if currentUser}}
            <li>
              <a href="{{pathFor 'postSubmit'}}">Submit Post</a>
            </li>
            <li class="dropdown">
              {{> notifications}}
            </li>
          {{/if}}
        </ul>
        <ul class="nav pull-right">
          <li>{{loginButtons}}</li>
        </ul>
      </div>
    </div>
  </header>
</template>
client/views/include/header.html

////

Ranking by points
Ranking by points

Commit 13-5

Added routes for post lists, and pages to display them.

A Better Header

////

////

<template name="header">
  <header class="navbar">
    <div class="navbar-inner">
      <a class="btn btn-navbar" data-toggle="collapse" data-target=".nav-collapse">
        <span class="icon-bar"></span>
        <span class="icon-bar"></span>
        <span class="icon-bar"></span>
      </a>
      <a class="brand" href="{{pathFor 'home'}}">Microscope</a>
      <div class="nav-collapse collapse">
        <ul class="nav">
          <li class="{{activeRouteClass 'home' 'newPosts'}}">
            <a href="{{pathFor 'newPosts'}}">New</a>
          </li>
          <li class="{{activeRouteClass 'bestPosts'}}">
            <a href="{{pathFor 'bestPosts'}}">Best</a>
          </li>
          {{#if currentUser}}
            <li class="{{activeRouteClass 'postSubmit'}}">
              <a href="{{pathFor 'postSubmit'}}">Submit Post</a>
            </li>
            <li class="dropdown">
              {{> notifications}}
            </li>
          {{/if}}
        </ul>
        <ul class="nav pull-right">
          <li>{{loginButtons}}</li>
        </ul>
      </div>
    </div>
  </header>
</template>
client/views/includes/header.html
Template.header.helpers({
  activeRouteClass: function(/* route names */) {
    var args = Array.prototype.slice.call(arguments, 0);
    args.pop();

    var active = _.any(args, function(name) {
      return Router.current().route.name === name
    });

    return active && 'active';
  }
});
client/views/includes/header.js
Showing the active page
Showing the active page

Helper Arguments

////

////

////

////

////

Commit 13-6

Added active classes to the header.

////

Advanced Publications

Sidebar 13.5

////

Publishing a Collection Multiple Times

////

////

////

////

////

Publishing a collection twice
Publishing a collection twice
Meteor.publish('allPosts', function() {
  return Posts.find({}, {fields: {title: true, author: true}});
});

Meteor.publish('postDetail', function(postId) {
  return Posts.find(postId);
});

////

////

////

////

Meteor.publish('newPosts', function(limit) {
  return Posts.find({}, {sort: {submitted: -1}, limit: limit});
});

Meteor.publish('bestPosts', function(limit) {
  return Posts.find({}, {sort: {votes: -1, submitted: -1}, limit: limit});
});
server/publications.js

Subscribing to a Publication Multiple Times

////

////

////

Subscribing twice to one publication
Subscribing twice to one publication

////

Meteor.publish('posts', function(options) {
  return Posts.find({}, options);
});

////

Meteor.subscribe('posts', {submitted: -1, limit: 10});
Meteor.subscribe('posts', {baseScore: -1, submitted: -1, limit: 10});

////

////

Multiple Collections in a Single Subscription

////

////

////

////

////

////

////

Two collections in one subscription
Two collections in one subscription
Meteor.publish('topComments', function(topPostIds) {
  return Comments.find({postId: topPostIds});
});

////

////

Meteor.publish('topPosts', function(limit) {
  var sub = this, commentHandles = [], postHandle = null;

  // send over the top two comments attached to a single post
  function publishPostComments(postId) {
    var commentsCursor = Comments.find({postId: postId}, {limit: 2});
    commentHandles[post._id] = 
      Meteor.Collection._publishCursor(commentsCursor, sub, 'comments');
  }

  postHandle = Posts.find({}, {limit: limit}).observeChanges({
    added: function(id, post) {
      publishPostComments(post._id);
      sub.added('posts', id, post);
    },
    changed: function(id, fields) {
      sub.changed('posts', id, fields);
    },
    removed: function(id) {
      // stop observing changes on the post's comments
      commentHandles[id] && commentHandles[id].stop();
      // delete the post
      sub.removed('posts', id);
    }
  });

  sub.ready();

  // make sure we clean everything up (note `_publishCursor`
  //   does this for us with the comment observers)
  sub.onStop(function() { postsHandle.stop(); });
});

////

////

////

Linking different collections

////

One collection for two subscriptions
One collection for two subscriptions

////

////

////

////

  Meteor.publish('videos', function() {
    var sub = this;

    var videosCursor = Resources.find({type: 'video'});
    Meteor.Collection._publishCursor(videosCursor, sub, 'videos');

    // _publishCursor doesn't call this for us in case we do this more than once.
    sub.ready();
  });

////

////

Animations

14

////

Meteor & the DOM

////

////

////

////

  1. ////
  2. ////
  3. ////
  4. ////
  5. ////
  6. ////

////

Swtiching two posts
Swtiching two posts

////

////

////

Proper Timing

////

////

////

////

////

////

CSS Positioning

////

////

////

////

////

.post{
  position:relative;
  transition:all 300ms 0ms ease-in;
}
client/stylesheets/style.css

////

////

Position:absolute

////

////

Total Recall

////

////

////

////

////

Ranking Posts

////

////

////

////

Template.postsList.helpers({
  postsWithRank: function() {
    this.posts.rewind();
    return this.posts.map(function(post, index, cursor) {
      post._rank = index;
      return post;
    });
  }
});
/client/views/posts/posts_list.js

////

////

<template name="postsList">
  <div class="posts">
    {{#each postsWithRank}}
      {{> postItem}}
    {{/each}}

    {{#if nextPath}}
      <a class="load-more" href="{{nextPath}}">Load more</a>
    {{/if}}
  </div>
</template>
/client/views/posts/posts_list.html

Be Kind, Rewind

////

////

////

Putting it together

////

Template.postItem.helpers({
  //...
});

Template.postItem.rendered = function(){
  // animate post from previous position to new position
  var instance = this;
  var rank = instance.data._rank;
  var $this = $(this.firstNode);
  var postHeight = 80;
  var newPosition = rank * postHeight;

  // if element has a currentPosition (i.e. it's not the first ever render)
  if (typeof(instance.currentPosition) !== 'undefined') {
    var previousPosition = instance.currentPosition;
    // calculate difference between old position and new position and send element there
    var delta = previousPosition - newPosition;
    $this.css("top", delta + "px");
  }

  // let it draw in the old position, then..
  Meteor.defer(function() {
    instance.currentPosition = newPosition;
    // bring element back to its new original position
    $this.css("top",  "0px");
  }); 
};

Template.postItem.events({
  //...
});
/client/views/posts/post_item.js

Commit 14-1

Added post reordering animation.

////

////

////

Animating New Posts

////

////

  1. ////
  2. ////

////

////

Template.postItem.helpers({
  //...
});

Template.postItem.rendered = function(){
  // animate post from previous position to new position
  var instance = this;
  var rank = instance.data._rank;
  var $this = $(this.firstNode);
  var postHeight = 80;
  var newPosition = rank * postHeight;

  // if element has a currentPosition (i.e. it's not the first ever render)
  if (typeof(instance.currentPosition) !== 'undefined') {
    var previousPosition = instance.currentPosition;
    // calculate difference between old position and new position and send element there
    var delta = previousPosition - newPosition;
    $this.css("top", delta + "px");
  } else {
    // it's the first ever render, so hide element
    $this.addClass("invisible");
  }

  // let it draw in the old position, then..
  Meteor.defer(function() {
    instance.currentPosition = newPosition;
    // bring element back to its new original position
    $this.css("top",  "0px").removeClass("invisible");
  }); 
};

Template.postItem.events({
  //...
});
/client/views/posts/post_item.js

Commit 14-2

Fade items in when they are drawn.

////

CSS & JavaScript

////

////

////

Meteor Vocabulary

Sidebar 14.5

////

Client

////

Collection

////

Computation

////

Cursor

////

DDP

////

Deps

////

Document

////

Helpers

////

Latency Compensation

////

Method

////

MiniMongo

////

Package

////

  1. ////
  2. ////
  3. ////
  4. ////

////

Publication

////

Server

////

Session

////

Subscription

////

Template

////

Template Data Context

////

Going Further

14.5

//

Extra Chapters

//

Evented Mind

//

MeteorHacks

//

Atmosphere

//

//

Meteorpedia

//

The Meteor Podcast

//

Other Resources

//

//

Getting Help

//

Community

//