Discover Meteor

Building Real-Time JavaScript Web Apps

Giriş

1

Benim için zihinsel bir deney yapın. Bilgisayarınızın iki farklı penceresinde iki farklı klasör açtığınızı hayal edin.

Şimdi, iki pencereden birine tıklayın ve bir dosya silin. Bu dosya diğer pencereden de kayboldu mu?

Kaybolduğunu bilmek için bu adımları uygulamanıza gerek yok. Yerel dosya sistemimizde bir değişiklik yaptığımızda, değişiklik her yere, yenileme veya geri çağırma yapmaksızın uygulanır. Bu kadar basit.

Oysa, hadi aynı senaryonun web'de nasıl gerçekleşeceğini düşünelim. Diyelim ki, aynı WordPress admin sayfasını iki farklı tarayıcı penceresinde açtınız ve birinde yeni bir gönderi yarattınız. Bilgisayarınızın aksine, ne kadar beklerseniz bekleyin, diğer pencere bu değişikliği, sayfayı yenilemediğiniz sürece yansıtmayacaktır.

Yıllar içinde, bir web sitesinin bizimle kısa, ayrık aralıklarla iletişim kurmasına alışmış bulunmaktayız.

Ancak Meteor web'i gerçek zamanlı ve reaktif yaparak statükoya meydan okuyan, yeni akım framework ve teknolojilerin bir parçasıdır.

Meteor nedir?

Meteor Node.js'in üzerine bina edilmiş gerçek zamanlı web uygulamaları geliştirme amaçlı bir platformdur. Uygulamanızın veritabanı ve kullanıcı arayüzü arasında, iki tarafın senkronize olmasını sağlamak üzere konuşlanmıştır.

Node.js üzerine inşaa edildiğine göre, Meteor hem istemci hem de sunucu tarafında Javascript kullanır. Daha da önemlisi Meteor, iki ortam arasında kod da paylaşabilir.

Tüm bunların neticesinde, web uygulamalarında sıklıkla görülen birçok güçlük ve tehlikeyi soyutlayarak çok güçlü ve çok basit bir platform olmayı başarmıştır.

Neden Meteor?

Neden başka bir web framework'ü yerine Meteor'u öğrenmek için vakit harcamalısınız? Tüm çeşitli özelliklerini bir kenara koyarsak, özetle tek bir neden olduğuna inanıyoruz: Meteor'u öğrenmesi çok basit.

Meteor, gerçek zamanlı bir web uygulamasını saatler içinde çalışır hale getirip yayına sokmayı, diğer framework'lere kıyasla daha kolay bir şekilde mümkün kılmaktadır. Daha önce ön-yüz geliştirme yaptıysanız, Javascript'e zaten aşinasınız ve yeni bir dil öğrenme ihtiyacınız yok.

Meteor ihtiyaçlarınız için ideal framework olabilir de olmayabilir de. Ama, madem ki bir kaç akşam veya haftasonu sürecince başlayabilirsiniz, neden deneyip kendiniz karar vermiyorsunuz?

Neden bu kitap?

Son 6 aydır, herkesin kendi, bağlantı paylaşılıp oylanabilen, sosyal haber sitesini (Reddit veya Hacker news‘u düşünün) yaratmasına olanak sağlayan açık kaynak kodlu bir Meteor uygulaması Telescope üzerinde çalışıyoruz.

Uygulamayı yaparken çok şey öğrendik, ancak sorularımıza her zaman yanıt bulmak kolay olmadı. Çok farklı kaynaklardan parçaları birleştirmek, ve çoğu sefer de kendi çözümlerimizi üretmek zorunda kaldık. Bu kitap ile birlikte, tüm bu dersleri paylaşmak ve sıfırdan tam teşekküllü bir Meteor uygulaması yazmayı adım adım anlatan bir kılavuz yaratmak istedik.

Yapacağımız uygulama Telescope'un kısmen basitleştirilmiş versiyonu olan, Miscroscope diye adlandırdığımız bir uygulama. Bunu yazarken, bir Meteor uygulaması yaparken incelenmesi gereken, kullanıcı hesapları, Meteor koleksiyonları, yönlendirme vb. tüm farklı elemanları irdeleyeceğiz.

Kitabı okumayı bitirdiğinizde, daha ileri gitmek isterseniz, Telescope'un kodunu, aynı modelleri takip ettiğinden kolayca anlayabileceksiniz.

Bu kitap kimin için?

Kitabı yazarken hedeflerimizden biri, herşeyin ulaşılabilir ve kolayca anlaşılır olmasını sağlamaktı. Dolayısıyla, Meteor, Node, MVC frameworkleri veya genel olarak server-side kodlama deneyiminiz olmasa da takip edebilmeniz lazım.

Öte yandan, basit Javascript sentaksı ve konseptlerine aşina olduğunuzu varsayıyoruz. Eğer biraz jQuery ile uğraşmışlığınız veya tarayıcınızın geliştirici konsoluyla oynamışlığınız varsa, problem yaşamamanız lazım.

Yazarlar Hakkında

Kim olduğumuzu merak ediyor ve neden bize güvenmeniz gerektiğini düşünüyorsanız, ikimizin özgeçmişi hakkında bilgiyi aşağıda bulabilirsiniz.

Tom Coleman kalite ve kullanıcı deneyimine odaklanan bir web atölyesi olan Percolate Studio'un bir parçasıdır. Meteorite'ın ve paket deposu Atmosphere'in yaratıcılarından biri ve aynı zamanda pek çok açık kaynak kodlu Meteor projesinin (Iron Router gibi) arkasındaki beyinlerden biridir.

Sacha Greif has worked with startups such as Hipmunk and RubyMotion as a product and web designer. He’s the creator of Telescope and Sidebar (which is based on Telescope), and is also the founder of Folyo.

Sacha Greif Hipmunk ve RubyMotion gibi startup'larda ürün ve web tasarımcısı olarak çalışmıştır. Telescope ve Sidebar'ın (Telescope'dan baz alınmış) yaratıcısı ve Folyo'nun kurucusudur.

Bölümler & Ek Bilgiler

Bu kitabın hem acemi Meteor kullanıcısı hem de ileri düzey programcılar için faydalı olmasını istedik, bu yüzden bölümleri iki kategoriye ayırdık: normal bölümler (1'den 14'e numaralandırılmış) ve ek bilgiler (.5 numaralar)

Normal bölümler size uygulamayı yapma yolunda eşlik edecek, ve sizi çok fazla detaya boğmadan en önemli basamakları açıklayarak en kısa sürede işlevsel olmanızı sağlamaya çalışacak.

Öte yandan, ek bilgi bölümleri Meteor'un karışık taraflarına değinecek ve arka planda gerçekten neler olduğu hakkında daha iyi bir fikir edinmenize yardımcı olacak.

Eğer yeni başlıyorsanız, ek bilgi bölümlerini ilk okumanızda atlayabilir, ve Meteor'u biraz kurcaladıktan sonra tekrar geri dönebilirsiniz.

Commitler & Canlı Örnekler

Bir programcılık kitabını takip edip, birden kendi kodunuzun örneklerden farklılaştığını, hiç birşeyin artık çalışması gerektiği gibi çalışmadığını farketmekten kötüsü yoktur.

Bunu engellemek için, Microscope için bir GitHub reposu yarattık, ve her birkaç kod değişikliği için ilgili git commitine bağlantılar sağladık. Ek olarak, her commit uygulamanın ilgili commit için canlı örneğine bağlantı kuruyor, bu sayede yerel kopyanız ile kıyaslayabiliyorsunuz. İşte bunun nasıl birşeye benzediğine dair bir örnek:

Commit 11-2

Başlıkta bildirimlerin görüntülenmesi.

Ama şuna dikkat etmek gerekir ki, bu commitleri sağlamamız, sadece bir 'git checkoutundan’ diğerine gitmeniz gerektiği anlamına gelmiyor. Uygulamanızın kodunu manüel olarak yazmanız, çok daha iyi öğrenmenizi sağlayacaktır!

Diğer birkaç kaynak

Eğer Meteor'un belli bir tarafı hakkında daha fazlasını öğrenmek isterseniz, Meteor'un resmi dokümantasyonu başlamak için en iyi yerdir.

Sorunları giderme ve sorular için Stack Overflow'u ve canlı yardım isterseniz #meteor IRC kanalı'nı tavsiye ederiz.

Git'e ihtiyacım var mı?

Kitabı takip etmek için Git versiyon kontrolüne aşinalık kesin surette gerekli olmasa da, şiddetle tavsiye ederiz.

Hızla yol almak isterseniz size Nick Farina'nın Git Düşündüğünüzden Daha Basit.'ini tavsiye ederiz.

Git konusunda yeni iseniz, aynı zamanda, komut satırını kullanmadan git repolarını klonlamanıza ve yönetmenize yarayan GitHub for Mac uygulamasını da tavsiye ediyoruz.

Bize ulaşın

Başlarken

2

İlk izlenimler her zaman çok önemlidir, ve Meteor'un kurulumu da nispeten sancısız olmalı. Çoğu durumda uygulamanın kurulumu ve ayağa kalkması 5 dakikadan daha kısa sürer.

Meteor'a başlarken terminal penceremizi açıyoruz ve şunları yazıyoruz:

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

Bu işlem sisteminize ‘meteor’ çalıştırılabilir dosyasını kuracak ve kullanıma hazır hale getirecek.

Not Meteor'un Kurulumu

Eğer meteor'u lokale kuramıyorsan (ya da kurmak istemiyorsan) sana Nitrous.io'a bir göz atmanı öneriyoruz.

Nitrous.io uygulamanı hızlıca ayağa kaldırabildiğin ve kodlarını tarayıcıda düzenleyebildiğin bir servistir, ve biz ayarlarını nasıl yapacağın hakkında sana yardımcı olabilecek kısa bir açıklama hazırladık a short guide.

“Meteor kurulumu” bölümünü basitçe takip ettikten sonra bu bölümdeki “Basit bir uygulama yaratmak” kısmına geçebilirsin.

Meteorite

////

////

Meteorite Kurulumu

////

////

$ npm install -g meteorite

Yetki hataları?

////

$ sudo -H npm install -g meteorite

////

////

////

### mrt vs meteor

////

Basit bir uygulama yaratmak

Şu an meteor kurulumunu tamamladık, hadi bir uygulama yaratalım. Yapmamız gereken Meteor'un komut satırını kullanmak:

$ mrt create microscope

Bu komut meteoru indirecek, ve basit kurulumunu hazırlayacak, böylece Meteor projesi senin için hazır. Herşey tamamlandıktan sonra göreceğin dizin şu şekilde olmalı microscope/:

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

////

////

$ cd microscope
$ meteor

////

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

Commit 2-1

Basit microscope projesi oluşturuldu.

////

Paket ekleyelim

////

$ mrt add bootstrap

Commit 2-2

Bootstrap paketi eklendi

Paketler konusunda bir not

////

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

Meteor uygulamaları için dosya yapısı

////

////

////

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

////

////

Meteor'un mimarisi MVC mi?

////

////

Genel değil?

////

////

Underscores vs CamelCase

////

////

////

CSS Müdahaleleri

////

////

.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

Dosya yapısı yeniden düzenlendi.

Bir not da CoffeeScript için

////

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'));
}

////

////

////

 Deps.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

////

////

Deps.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 ve Deny

Sidebar 8.5

Meteor'un güvenlik sistemi veritabanında yaptığımız her değişiklikte yeni bir Method tanıtma yerine yapılan değişiklikleri kontrol etmemize izin verir.

Daha önce post bilgisine ekstra özellikler eklediğimizden ve URL'ın daha önce eklenip eklenmediğine baktığımız için Method eklemek mantıklı bir adımdı.

Fakat, post'u güncellerken ve silerken yeni bir Method eklemeye ihtiyac duymadık. Bunun için sadece kullanıcının silme ve güncelleme hakkının olup olmadıgına baktık, ve bunu allow ve deny kullanarak kolaylıkla yaptık.

Bu geriçağırımları kullanmak veritabanında yaptığımız değişiklikleri, ve güncellemeleri daha net bir şekilde ifade etmemize yardımcı olur. Özellikle bunları tanımlarken kullanıcı sistemini de kullanabilmemiz bizim için ekstra bir avantajdır.

Çoklu geriçağırım

İstediğimiz kadar allow geriçağırımı tanıtabiliriz. Veritabanına değişikliğin kaydedilmesi için bunlardan en az birinin true dönmesi bizim için yeterli. Eğer Posts.insert kodu çağırıldığında (yaptığımız app'ın sayfasından ya da tarayıcının konsolundan direk olarak), server tarafındaki insert için tanıtılan allow kontrollerden herhangi biri true dönene kadar bütün kontroller denenir. Eğer bulunamazsa, veritabanına ekleme yapılmaz ve server geriye 403 hatası döner.

Aynı şekilde birden fazla deny geriçağırımı ekleyebiliriz. Eğer bunlardan herhangi biri true dönerse değişiklik durdurulur ve geriye 403 hata kodlu sayfa dönülür. Bundan ötürü eğer başarılı bir insert yapılması için kodumuzun bütün deny insert geriçağırımlarından sonra allow insert geriçağırımlarının bir veya birden fazlasından geçmesi gerekir.

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

Meteor deny geriçağırımlarından başlayarak allow listesine gider ve herhangi biri true dönene kadar tek tek geriçağırımları dener.

Uygulamalı bir örnek olarak iki tane allow() geriçağırımı olduğunu düşünelim: birinin postun giriş yapan kullanıcıya ait olup olmadığını, diğerinin de kullanıcının admin olup olmadığını kontrol ediyor olduğunu düşünelim. Eğer giriş yapan kullanıcı admin ise, herhangi posta değişiklik yapabilecektir, çünkü bu iki geriçağırımdan biri true dönecektir.

Gecikme Telafisi

Unutmayın ki veritabanında değişiklik yapan Methodlar (örneğin .update()) gecikme telafisinden geçer. Mesela kullanıcıya ait olmayan post tarayıcı konsolundan silindiğinde bu döküman lokal kolleksiyondan silindiği için post'un geçici olarak yok olduğunu görürüz. Fakat kısa bir süre sonra server tarafından dökümanın silinmediğini öğrenince bunun tekrardan geri geldiğini görürüz.

Genelde konsoldan yapılan değişiklikler problem değildir (ne de olsa kullanıcıların büyük bir kısmı tarayıcı konsolundan değişiklik yapmayacaklardır). Fakat bu tür değişikler kullanıcı arayüzünden kaynaklanmamalı. Örneğin, eğer kullanıcıya ait olmayan dökümanların silinmemesini istiyorsan kullanıcıya sil tuşunu göstermemen gerekir.

Dökümanlara ait gerekli izinleri (örneğin /lib klasörü altında kullanıcıların dökümanı silip silemeyeceğini belirten canDeletePost(user, post) fonksiyonu tanıtıp) kullanıcı ve server'ın ortak kullanacağı şekilde kodlayabilirsin.

Server Tarafından İzin

Veritabanına yapılan değişikleri kontrolden geçiren bu izin sisteminin sadece kullanıcı tarafından engellendiğini unutmamak gerekir. Server tarafında yapılan bütün değişiklikler herhangi bir engele takılmadan kabul edilir.

Eğer örnek olarak server tarafında tanıtılan bir deletePost Meteor Method'u kullanıcı tarafından çağırılabilirse, herhangi bir kullanıcı post dökümanlarını silebilecektir. Bu nedenle, server tarafından yapılan değişikliklerde öncelikle gerekli izinleri gözden geçirmek gerekir.

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

////