Discover Meteor

Building Real-Time JavaScript Web Apps

Έκδοση 1.7.2 (updated December 5, 2014)

Εισαγωγή

1

Ας κάνουμε ένα νοητικό πείραμα. Φανταστείτε ότι ανοίγετε τον ίδιο φάκελο σε δύο διαφορετικά παράθυρα του υπολογιστή σας.

Τώρα πηγαίνετε σε ένα από τα δύο παράθυρα και σβήστε ένα αρχείο. Το αρχείο αυτό εξαφανίστηκε και από το άλλο παράθυρο;

Δεν χρειάζεται να κάνετε αυτά τα βήματα για να βεβαιωθείτε ότι έτσι είναι. Όταν αλλάζουμε κάτι στο τοπικό σύστημα αρχείων (local filesystem), η αλλαγή εφαρμόζεται παντού χωρίς να χρειάζεται ανανέωση (refresh) ή επανάκληση (callback). Απλά συμβαίνει.

Όμως, ας σκεφτούμε πώς θα εξελισσόταν το ίδιο σενάριο στο διαδίκτυο. Για παράδειγμα, ας πούμε ότι ανοίγετe την σελίδα διαχείρισης (admin page) του WordPress του ίδιου ιστοτόπου σε δύο διαφορετικά παράθυρα φιλομετρητή (browser) και στη συνέχεια δημιουργείτε ένα νέο δημοσίευμα (post) σε ένα από τα δύο παράθυρα. Αντίθετα με την επιφάνεια εργασίας, όσο και να περιμένετε το άλλο παράθυρο φιλομετρητή δε θα απεικονίσει την αλλαγή μέχρι να το ανανεώσετε.

Με τα χρόνια συνηθίσαμε την ιδέα ότι οι ιστότοποι είναι κάτι με το οποίο επικοινωνούμε σε μικρές χωριστές ρυπές.

Το Meteor όμως είναι μέρος ενός νέου κύματος προγραμματιστικών πλαισίων (frameworks) και τεχνολογιών που αμφισβητούν το status quo κάνοντας το διαδίκτυο περισσότερο ζωντανό (real-time) και αντιδραστικό (reactive).

Τι είναι το Meteor;

Το Meteor είναι μια πλατφόρμα η οποία χτίστηκε πάνω στο Node.js για την κατασκευή εφαρμογών πραγματικού χρόνου. Είναι αυτό που κάθεται ανάμεσα στη βάση δεδομένων (database) της εφαρμογής σας και το περιβάλλον χρήσης (user interface), και φροντίζει να συγχρονίζονται μεταξύ τους.

Δεδομένου ότι χτίστηκε πάνω στο Node.js, το Meteor χρησιμοποιεί JavaScript τόσο στο περιβάλλον του πελάτη (client) όσο και στο περιβάλλον του διακομιστή (server). Επιπλέον, το Meteor έχει τη δυνατότητα να μοιράζεται κώδικα μεταξύ των δύο περιβαλλόντων.

Το αποτέλεσμα όλων αυτών είναι μια πλατφόρμα που καταφέρνει να είναι πολύ ισχυρή και πολύ απλή, αφαιρώντας πολλές από τις συνήθεις δυσκολίες και παγίδες της ανάπτυξης εφαρμογών για το διαδίκτυο.

Γιατί το Meteor;

Γιατί να ξοδέψετε το χρόνο σας μαθαίνοντας το Meteor αντί κάποιου άλλου προγραμματιστικού πλαισίου; Αφήνοντας στην άκρη τα χαρακτηριστικά του Meteor, πιστεύουμε ότι όλα συνοψίζονται σε ένα πράγμα: το Meteor είναι εύκολο στη μάθηση.

Περισσότερο από κάθε άλλο προγραμματιστικό πλαίσιο, το Meteor παρέχει τη δυνατότητα να δημιουργήσετε και να θέσετε σε λειτουργία μια διαδικτυακή εφαρμογή ζωντανού χρόνου (real-time web app) σε λίγες ώρες. Και αν έχετε ασχοληθεί με την ανάπτυξη περιβάλλοντος χρήσης στο παρελθόν, θα έχετε ήδη κάποια εμπειρία με τη JavaScript και δε θα χρειαστεί καν να μάθετε μια νέα γλώσσα.

Το Meteor ίσως είναι το ιδανικό προγραμματιστικό πλαίσιο για τις ανάγκες σας, αλλά ίσως και όχι. Αφού όμως μπορείτε να ξεκινήσετε σε μερικά απογεύματα ή σε ένα Σαββατοκύριακο, γιατί να μην το δοκιμάσετε;

Γιατί αυτό το βιβλίο;

Τα τελευταία χρόνια ασχοληθήκαμε με πολλές εφαρμογές με βάση το Meteor, από εφαρμογές για το διαδίκτυο μέχρι εφαρμογές για κινητά τηλέφωνα, τόσο για εμπορική χρήση όσο και λογισμικού ανοιχτού κώδικα.

Μάθαμε πολλά, αλλά δεν ήταν πάντα εύκολο να βρούμε τις απαντήσεις στις απορίες μας. Έπρεπε να μαζέψουμε πληροφορίες από διάφορες πηγές, και σε πολλές περιπτώσεις να ανακαλύψουμε τις δικές μας λύσεις. Με αυτό το βιβλίο θέλαμε να μοιραστούμε όλα αυτά τα μαθήματα και να δημιουργήσουμε έναν απλό οδηγό βήμα-βήμα που θα σας δείξει πώς να φτιάξετε μια πλήρη εφαρμογή του Meteor ξεκινώντας από το μηδέν.

Η εφαρμογή που θα κατασκευάσουμε είναι μια απλοποιημένη έκδοση ενός ιστοτόπου κοινωνικών νέων όπως το Hacker News ή το Reddit, το οποίο θα ονομάσουμε Microscope (σε αναλογία με το μεγαλύτερο αδερφό του, την εφαρμογή ανοιχτού κώδικα Telescope). Κατά την κατασκευή της θα ασχοληθούμε με όλα τα επιμέρους στοιχεία που συνδυάζονται για τη δημιουργία μιας Meteor εφαρμογής, όπως οι λογαριασμοί χρηστών, οι συλλογές Meteor, η δρομολόγηση (routing) και πολλά άλλα.

Για ποιον είναι αυτό το βιβλίο;

Ένας από τους στόχους μας κατά τη συγγραφή του βιβλίου ήταν να διατηρήσουμε τα πάντα προσβάσιμα και εύκολα στην κατανόηση. Συνεπώς θα πρέπει να μπορείτε να το παρακολουθήσετε ακόμα και αν δεν έχετε κάποια εμπειρία με το Meteor, το Node.js, τα MVC frameworks, ή ακόμα με τη δημιουργία κώδικα για περιβάλλον διακομιστή γενικά.

Θεωρούμε όμως δεδομένη μια σχετική εξοικίωση με τη βασική σύνταξη και τις έννοιες της JavaScript. Αν έχετε τροποποιήσει ποτέ κώδικα jQuery ή αν έχετε πειραματιστεί με την κονσόλα προγραμματισμου του φυλομετρητή σας (browser’s developer console), λογικά είστε εντάξει.

Αν δεν είστε εξοικιωμένοι με την JavaScript ακόμα, προτείνουμε να δείτε το JavaScript primer for Meteor πριν αρχίσετε να διαβάζετε αυτό το βιβλίο.

Σχετικά με τους συγγραφείς

Σε περίπτωση που αναρωτιέστε ποιοι είμαστε και γιατί να μας εμπιστευτείτε, παραθέτουμε μερικές πληροφορίες για εμάς.

Ο Tom Coleman είναι μέλος του Percolate Studio, μια εταιρία ανάπτυξης εφαρμογών διαδικτύου με εστίαση στην ποιότητα και την εμπειρία του χρήστη. Είναι ένας από τους συντηρητές της βιβλιοθήκης πακέτων Atmosphere, καθώς και ένα από τα μυαλά πίσω από πολλά άλλα σχέδια ανοιχτού κώδικα για το Meteor (όπως το Iron Router).

Ο Sacha Greif έχει εργαστεί ως σχεδιαστής προϊόντος και ιστοσελίδων για εγχειρήματα όπως το Hipmunk και το RubyMotion. Είναι ο δημιουργός του Telescope και του Sidebar (το οποίο βασίζεται στο Telescope), καθώς και ο ιδρυτής του Folyo.

Κεφάλαια & Παραρτήματα

Θέλαμε αυτό το βιβλίο να είναι χρήσιμο τόσο για τον αρχάριο χρήστη του Meteor όσο και για τον προχωρημένο, γι’ αυτό χωρίζουμε τα κεφάλαια σε δύο κατηγορίες: κανονικά κεφάλαια (αριθμημένα από 1 ως 24) και παραρτήματα τα οποία συνοδεύουν το αντίστοιχο κεφάλαιο και σημαίνονται με δεκαδικούς αριθμούς (.5).

Τα κανονικά κεφάλαια θα σας οδηγήσουν στη δημιουργία της εφαρμογής, και θα προσπαθήσουν να σας φέρουν σε λειτουργική κατάσταση το συντομότερο δυνατό, εξηγώντας τα πιο σημαντικά βήματα χωρίς να σας μπερδεύουν με πολλές λεπτομέρειες.

Από την άλλη πλευρά, τα παραρτήματα εμβαθύνουν σε πιο περίπλοκες πτυχές του Meteor και θα σας βοηθήσουν να καταλάβετε καλύτερα τι πραγματικά συμβαίνει στο παρασκήνιο.

Συνεπώς αν είστε αρχάριος, συστήνεται να παρακάμψετε τα παραρτήματα κατά την πρώτη ανάγνωση, και να επιστρέψετε σε αυτά αργότερα, όταν θα έχετε αποκτήσει μια αρχική εμπειρία με το Meteor.

Commits & Live Instances

Δεν υπάρχει κάτι χειρότερο από το να ακολουθείτε ένα βιβλίο προγραμματισμού και ξαφνικά να διαπιστώνετε ότι ο κώδικάς σας δεν είναι συγχρονισμένος με τα παραδείγματα και τίποτα δεν λειτουργεί σωστά.

Για να το αποφύγουμε αυτό, έχουμε στήσει μια αποθήκη GitHub για το Microscope, και θα παρέχουμε άμεσες συνδέσεις σε git commits κάθε τόσο. Επιπλέον, κάθε git commit συνδέεται σε μια ζωντανή έκδοση της εφαρμογής για το συγκεκριμένο commit, έτσι ώστε να μπορείτε να το συγκρίνετε με το δικό σας. Για παράδειγμα:

Commit 11-2

Εμφάνιση ειδοποιήσεων στην επικεφαλίδα.

Να θυμάστε όμως ότι παρέχουμε αυτά τα commits για βοήθεια, όχι για να κάνετε git checkout από το ένα στο άλλο. Θα μάθετε πολύ περισσότερα αν πληκτρολογήσετε τον κώδικα μόνοι σας!

Άλλες Πηγές

Αν θέλετε να μάθετε περισσότερα για κάποια συγκεκριμένη λειτουργία του Meteor, η επίσημη τεκμηρίωση του Meteor είναι το καλύτερο μέρος για να ξεκινήσετε.

Συστήνουμε επίσης το Stack Overflow για ερωτήσεις και αντιμετώπιση σφαλμάτων, καθώς και το #meteor κανάλι IRC αν θέλετε ζωντανή βοήθεια.

Χρειάζομαι το Git;

Παρότι η εξοικείωση με τον έλεγχο εκδόσεων του Git δεν είναι αυστηρά απαραίτητη για να ακολουθήσετε αυτό το βιβλίο, αλλά θεωρούμε ότι σας βοηθήσει σημαντικά.

Προτείνουμε να διαβάσετε το άρθρο του Nick Farina Git Is Simpler Than You Think.

Όσον αφορά το λογισμικό, ειδικά αν είστε αρχάριος και δυσκολεύεστε να χρησιμοποιήσετε τη γραμμή εντολών, προτείνουμε το GitHub for Windows και το GitHub for Mac, τα οποία παρέχουν πολλές λειτουργίες σε γραφικό περιβάλλον.

Πώς να έρθετε σε Επαφή μαζί μας

Ξεκινώντας

2

Οι πρώτες εντυπώσεις είναι σημαντικές, και η διαδικασία εγκατάστασης του Meteor είναι σχετικά ανώδυνη. Στις περισσότερες περιπτώσεις θα είστε έτοιμοι σε λιγότερο από 5 λεπτά.

Ξεκινώντας, μπορούμε να εγκαταστήσουμε το Meteor ανοίγοντας ένα παράθυρο τερματικού και πληκτρολογώντας:

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

Αυτή η εντολή θα εγκαταστήσει το εκτελέσιμο αρχείο του Meteor στο σύστημά σας και θα είστε έτοιμοι να το χρησιμοποιήσετε.

Χρήση του Meteor χωρίς εγκατάσταση

Αν δεν μπορείτε (ή δεν θέλετε) να εγκαταστήσετε το Meteor τοπικά, σας προτείνουμε το Nitrous.io.

Το Nitrous.io είναι μια υπηρεσία που σας επιτρέπει να εκτελείτε εφαρμογές και να τροποποιείτε τον κώδικά τους μέσα από τον φυλλομετρητή σας. Έχουμε ετοιμάσει ένα σύντομο οδηγό που θα σας βοηθήσει να ξεκινήσετε.

Μπορείτε απλά να ακολουθήσετε τον οδηγό αυτό μέχρι και την ενότητα “Installing Meteor”, και στη συνέχεια να επιστρέψετε σε αυτό το βιβλίο, στην ενότητα “Δημιουργώντας μια Απλή Εφαρμογή” αυτού του κεφαλαίου.

Δημιουργώντας μια Απλή Εφαρμογή

Τώρα που έχουμε εγκαταστήσει το Meteor, ας δημιουργήσουμε μια εφαρμογή. Για να το κάνουμε αυτό, χρησιμοποιούμε το εργαλείο εντολών του Meteor:

meteor create microscope

Αυτή η εντολή θα κατεβάσει το Meteor και θα δημιουργήσει μια απλή εφαρμογή Meteor για εσάς. Όταν ολοκληρωθεί η διαδικασία, θα πρέπει να δείτε ένα νέο φάκελο με το όνομα microscope/, ο οποίος περιλαμβάνει τα εξής αρχεία:

.meteor
microscope.css
microscope.html
microscope.js

Η εφαρμογή που δημιούργησε για εσάς το Meteor είναι πολύ απλή και επιδεικνύει μερικά βασικά στοιχεία.

Παρότι η εφαρμογή δεν κάνει πολλά πράγματα, μπορούμε να την εκτελέσουμε. Για να γίνει αυτό, επιστρέψτε στο παράθυρο τερματικού και πληκτρολογήστε:

cd microscope
meteor

Τώρα ανοίξτε το φυλλομετρητή σας και επισκεφτείτε τη σελίδα http://localhost:3000/ (ή το ισοδύναμο http://0.0.0.0:3000/) και θα πρέπει να δείτε κάτι σαν αυτό:

Το Hello World του Meteor.
Το Hello World του Meteor.

Commit 2-1

Δημιουργήθηκε το αρχικό σχέδιο για το microscope.

Συγχαρητήρια! Η πρώτη σας εφαρμογή Meteor λειτουργεί. Αν θέλετε να σταματήσετε την εφαρμογή, πηγαίνετε στο παράθυρο τερματικού στο οποίο εκτελείται και πατήστε ctrl+c.

Σημειώστε επίσης ότι αν χρησιμοποιείτε το Git, τώρα είναι καλή στιγμή να αρχικοποιήσετε την αποθήκη σας με την εντολή git init.

Αντίο Meteorite

Για πολύ καιρό το Meteor βασιζόταν σε ένα εξωτερικό διαχειριστή πακέτων που ονομαζόταν Meteorite. Από την έκδοση 0.9.0 και μετά το Meteor απέκτησε το δικό του σύστημα διαχείρισης πακέτων και το Meteorite δεν χρειάζεται πια.

Συνεπώς αν συναντήσετε αναφορές στην εντολη mrt του Meteorite σε αυτό το βιβλίο ή οπουδήποτε αλλού, μπορείτε με ασφάλεια να το αντικαταστήσετε με την εντολή meteor.

Προσθέτοντας ένα Πακέτο

Τώρα θα χρησιμοποιήσουμε το σύστημα πακέτων του Meteor για να προσθέσουμε το προγραμματιστικό πλαίσιο Bootstrap στην εφαρμογή μας.

Αυτό δεν έχει διαφορά από το να προσθέσουμε χειροκίνητα το Bootstrap με τα CSS και JavaScript αρχεία του. Εμείς όμως βασιζόμαστε στο μέλος της κοινότητας Meteor Andrew Mao (το “mizzao” στο mizzao:bootstrap-3 είναι το όνομα χρήστη του δημιουργού του πακέτου) για να διατηρεί το πακέτο ενημερωμένο.

Και όσο ασχολούμαστε με αυτό, θα προσθέσουμε και το πακέτο Underscore. Το Underscore είναι μια βιβλιοθήκη εργαλείων JavaScript, και είναι χρήσιμη για την διαχείριση δομών δεδομένων της JavaScript.

Προς το παρόν, το πακέτο underscore ανήκει ακόμα στα “επίσημα” πακέτα του Meteor, γι’ αυτό δεν έχει ως πρόθεση το όνομα του δημιουργού:

meteor add mizzao:bootstrap-3
meteor add underscore

Σημειώστε ότι προσθέτουμε το Bootstrap 3. Κάποιες από τις εικόνες σε αυτό το βιβλίο λήφθηκαν με μια παλιότερη έκδοση του Microscope, η οποία χρησιμοποιούσε το Bootstrap 2, με αποτέλεσμα να εμφανίζονται λίγο διαφορετικά.

Commit 2-2

Προστέθηκαν τα πακέτα bootstrap και underscore.

Μόλις προσθέσετε το πακέτο Bootstrap θα παρατηρήσετε μια αλλαγή στη βασική εφαρμογή σας:

Με το Bootstrap.
Με το Bootstrap.

Αντίθετα με τον παραδοσιακό τρόπο εισαγωγής εξωτερικών στοιχείων (πχ ), δεν χρειάστηκε να συνδέσουμε κανένα αρχείο CSS ή JavaScript στην εφαρμογή μας, γιατί το Meteor το φροντίζει για εμάς! Κι αυτό είναι ένα μόνο από τα πλεονεκτήματα των πακέτων του Meteor.

Μια σημείωση για τα Πακέτα

Όταν μιλάμε για πακέτα στο περιβάλλον του Meteor, είναι σημαντικό να είμαστε συγκεκριμένοι. Το Meteor χρησιμοποιεί πέντε βασικούς τύπους πακέτων:

  • Ο πυρήνας του Meteor χωρίζεται σε διαφορετικά πακέτα πλατφόρμας Meteor. Συμπεριλαμβάνονται σε κάθε εφαρμογή Meteor, και δεν θα χρειαστεί να ανησυχήσετε γι’ αυτά.
  • Τα κάνονικά πακέτα Meteor είναι γνωστά και σαν “isopacks”, ή ισομορφικά πακέτα (σημαίνει ότι λειτουργούν τόσο στο περιβάλλον Πελάτη όσο και στο περιβάλλον Διακομιστή). Τα επίσημα πακέτα όπως το accounts-ui ή το appcache συντηρούνται από την βασική ομάδα του Meteor και συμπεριλαμβάνονται με το Meteor.
  • Τα ανεπίσημα πακέτα είναι απλά isopacks που αναπτύσσονται από άλλους χρήστες και φιλοξενούνται στον διακομιστή πακέτων του Meteor. Μπορείτε να τα δείτε στο Atmosphere ή με την εντολή meteor search.
  • Τα τοπικά πακέτα είναι αυτά που φτιάχνετε μόνοι σας και τα τοποθετείτε στο φάκελο /packages.
  • Τα πακέτα NPM (Node.js Packaged Modules) είναι πακέτα του Node.js. Παρότι δεν λειτουργούν άμεσα στο Meteor, μπορούν να χρησιμοποιηθούν από τους προηγούμενους τύπους πακέτων.

Η Δομή των Αρχείων μιας εφαρμογής Meteor

Προτού ξεκινήσουμε να γράφουμε κώδικα, πρέπει να σχεδιάσουμε την εφαρμογή μας σωστά. Για να διασφαλίσουμε ότι ξεκινάμε με καθαρό περιβάλλον, ανοίξτε το φάκελο microscope και διαγράψτε τα αρχεία microscope.html, microscope.js, και microscope.css.

Τώρα, δημιουργήστε τέσσερις φακέλους μέσα στο /microscope: /client, /server, /public, and /lib.

Στη συνέχεια, θα δημιουργήσουμε τα κενά αρχεία main.html και main.js μέσα στον φάκελο /client. Μην ανησυχείτε αν αυτό χαλάει την εφαρμογή σας προς το παρόν, θα αρχίσουμε να τα γεμίζουμε στο επόμενο κεφάλαιο.

Θα πρέπει να αναφέρουμε ότι τα ονόματα των φακέλων που δημιουργήσαμε δεν είναι τυχαία. Κι αυτό γιατί όσον αφορά την εκτέλεση του κώδικα, το Meteor έχει μερικούς κανόνες:

  • Ο κώδικας στο φάκελο /server εκτελείται μόνο στο περιβάλλον Διακομιστή.
  • Ο κώδικας στο φάκελο /client εκτελείται μόνο στο περιβάλλον Πελάτη.
  • Οτιδήποτε άλλο εκτελείται και στα δύο περιβάλλοντα.
  • Τα στατικά στοιχεία (fonts, images, etc.) τοποθετούνται στο φάκελο /public.

Επίσης είναι χρήσιμο να γνωρίζετε τον τρόπο με τον οποίο το Meteor αποφασίζει με ποια σειρά θα φορτώσει τα αρχεία σας:

  • Τα αρχεία στο φάκελο /lib φορτώνονται πριν από οτιδήποτε άλλο.
  • Τα αρχεία με όνομα main.* φορτώνονται μετά από οτιδήποτε άλλο.
  • Όλα τα άλλα αρχεία φορτώνονται με αλφαβητική σειρά.

Σημειώστε ότι παρότι το Meteor έχει αυτούς τους κανόνες, δεν σας υποχρεώνει να χρησιμοποιήσετε κάποια συγκεκριμένη δομή αρχείων. Συνεπώς η δομή που χρησιμοποιούμε είναι βάσει της δικής μας προτίμησης, αν θέλετε την ακολουθείτε.

Σας προτείνουμε να διαβάσετε την επίσημη τεκμηρίωση του Meteor αν θέλετε περισσότερες λεπτομέρειες για το θέμα.

Το Meteor ακολουθεί το μοτίβο MVC;

Αν έρχεστε στο Meteor από άλλα προγραμματιστικά πλαίσια όπως το Ruby on Rails, πιθανώς αναρωτιέστε αν οι εφαρμογές Meteor υιοθετούν το μοτίβο MVC (Model View Controller).

Η σύντομη απάντηση είναι όχι. Αντίθετα με το Rails, το Meteor δεν επιβάλει κάποια συγκεκριμένη δομή στην εφαρμογή σας. Συνεπώς σε αυτό το βιβλίο θα οργανώσουμε τον κώδικα με τρόπο που εξυπηρετεί εμάς, χωρίς να ασχολούμαστε με ακρονύμια.

Χωρίς public;

Εντάξει, είπαμε ψέματα. Δεν χρειαζόμαστε πραγματικά τον φάκελο public/ για τον απλό λόγο ότι το Microscope δεν χρησιμοποιεί στατικά στοιχεία! Επειδή όμως οι περισσότερες άλλες εφαρμογές Meteor περιλαμβάνουν τουλάχιστον μερικές εικόνες, σκεφτήκαμε ότι ήταν σημαντικό να το καλύψουμε και αυτό.

Παρεπιπτόντως, μπορεί να προσέξατε ένα κρυφό φάκελο .meteor. Το Meteor αποθηκεύει εδώ τον δικό του κώδικα, και είναι μάλλον κακή ιδέα να κάνετε μεταβολές στα αρχεία του. Για την ακρίβεια, δεν χρειάζεται ποτέ να ασχοληθείτε με αυτόν τον φάκελο. Η μόνη εξαίρεση σ’ αυτό είναι τα αρχεία .meteor/packages και .meteor/release, τα οποία περιέχουν ένα κατάλογο με τα έξυπνα πακέτα της εφαρμογής σας και την έκδοση του Meteor που χρησιμοποιείτε αντίστοιχα. Όταν προσθέτε πακέτα ή αλλάζετε έκδοση του Meteor, μπορεί να χρειαστεί να ελέγξετε τις αλλαγές σε αυτά τα αρχεία.

Underscores vs CamelCase

Το μόνο πράγμα που θα πούμε για την “αιώνια” διαμάχη μεταξύ underscore (my_variable) και camelCase (myVariable) είναι ότι δεν έχει ιδιαίτερη σημασία ποιο από τα δύο προτιμάτε, αρκεί να επιλέξετε από την αρχή ένα και να το διατηρήσετε μέχρι το τέλος.

Σε αυτό το βιβλίο χρησιμοποιούμε camelCase γιατί είναι αρκετά συνηθισμένο στην JavaScript (άλλωστε γράφεται JavaScript όχι java_script!).

Η μόνη εξαίρεση σε αυτόν τον κανόνα είναι τα ονόματα των αρχείων, τα οποία χρησιμοποιούν underscores (my_file.js), και οι CSS classes, οι οποίες χρησιμοποιούν hyphens (.my-class). Ο λόγος γι’ αυτό είναι ότι στο σύστημα αρχείων τα underscores είναι περισσότερο συνηθισμένα, ενώ η σύνταξη της CSS ήδη χρησιμοποιεί hyphens (font-family, text-align, etc.).

Τακτοποιώντας την CSS

Αυτό το βιβλίο δεν είναι για την CSS. Για να μην κάνουμε παρένθεση σε κάθε κεφάλαιο, αποφασίσαμε να τακτοποιήσουμε όλη τη stylesheet από την αρχή και να μην ασχοληθούμε ξανά μαζί της.

Η CSS φορτώνεται αυτόματα και ελαχιστοποιείται από το Meteor, οπότε σε αντίθεση με τα άλλα στατικά στοιχεία την τοποθετούμε στο φάκελο /client όχι στο /public. Δημιουργήστε τώρα ένα φάκελο client/stylesheets/, και μέσα σε αυτόν δημιουργήστε το αρχείο style.css με το εξής περιεχόμενο:

.grid-block, .main, .post, .comments li, .comment-form {
  background: #fff;
  -webkit-border-radius: 3px;
  -moz-border-radius: 3px;
  -ms-border-radius: 3px;
  -o-border-radius: 3px;
  border-radius: 3px;
  padding: 10px;
  margin-bottom: 10px;
  -webkit-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.15);
  -moz-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.15);
  box-shadow: 0 1px 1px rgba(0, 0, 0, 0.15); }

body {
  background: #eee;
  color: #666666; }

.navbar {
  margin-bottom: 10px; }
  /* line 32, ../sass/style.scss */
  .navbar .navbar-inner {
    -webkit-border-radius: 0px 0px 3px 3px;
    -moz-border-radius: 0px 0px 3px 3px;
    -ms-border-radius: 0px 0px 3px 3px;
    -o-border-radius: 0px 0px 3px 3px;
    border-radius: 0px 0px 3px 3px; }

#spinner {
  height: 300px; }

.post {
  /* For modern browsers */
  /* For IE 6/7 (trigger hasLayout) */
  *zoom: 1;
  position: relative;
  opacity: 1; }
  .post:before, .post:after {
    content: "";
    display: table; }
  .post:after {
    clear: both; }
  .post.invisible {
    opacity: 0; }
  .post.instant {
    -webkit-transition: none;
    -moz-transition: none;
    -o-transition: none;
    transition: none; }
  .post.animate{
    -webkit-transition: all 300ms 0ms ease-in;
    -moz-transition: all 300ms 0ms ease-in;
    -o-transition: all 300ms 0ms ease-in;
    transition: all 300ms 0ms ease-in; }
  .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;
  -webkit-border-radius: 3px;
  -moz-border-radius: 3px;
  -ms-border-radius: 3px;
  -o-border-radius: 3px;
  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); }

.posts .spinner-container{
  position: relative;
  height: 100px;
}

.jumbotron{
  text-align: center;
}
.jumbotron h2{
  font-size: 60px;
  font-weight: 100;
}

@-webkit-keyframes fadeOut {
  0% {opacity: 0;}
  10% {opacity: 1;}
  90% {opacity: 1;}
  100% {opacity: 0;}
}

@keyframes fadeOut {
  0% {opacity: 0;}
  10% {opacity: 1;}
  90% {opacity: 1;}
  100% {opacity: 0;}
}

.errors{
  position: fixed;
  z-index: 10000;
  padding: 10px;
  top: 0px;
  left: 0px;
  right: 0px;
  bottom: 0px;
  pointer-events: none;
}
.alert {
          animation: fadeOut 2700ms ease-in 0s 1 forwards;
  -webkit-animation: fadeOut 2700ms ease-in 0s 1 forwards;
     -moz-animation: fadeOut 2700ms ease-in 0s 1 forwards;
  width: 250px;
  float: right;
  clear: both;
  margin-bottom: 5px;
  pointer-events: auto;
}
client/stylesheets/style.css

Commit 2-3

Αναδιαμορφώθηκε η δομή των αρχείων.

Μια σημείωση για την CoffeeScript

Σε αυτό το βιβλίο χρησιμοποιούμε καθαρή JavaScript. Αν όμως προτιμάτε την CoffeeScript, το Meteor σας παρέχει την ευκολία. Απλά προσθέστε το πακέτο CoffeeScript και είστε έτοιμοι:

meteor add coffeescript

Ανάπτυξη

Sidebar 2.5

Σε κάποιους αρέσει να δουλεύουν σιωπηρά σε ένα σχέδιο εφαρμογής μέχρι να είναι τέλειο, ενώ άλλοι ανυπομονούν να το δείξουν στον κόσμο το συντομότερο δυνατόν.

Αν είστε από τους πρώτους θα προτιμάτε να εργαστείτε τοπικά στον υπολογιστή σας προς το παρόν, μπορείτε να παραλείψετε από το κεφάλαιο. Αντιθέτως, αν προτιμάτε να μάθετε τώρα πώς να αναπτύξετε την εφαρμογή σας στο διαδίκτυο, στο κεφάλαιο αυτό θα βρείτε ό,τι χρειάζεστε.

Θα μάθουμε πώς να αναπτύσσουμε την εφαρμογή μας με διαφόρους τρόπους. Μπορείτε ελεύθερα να χρησιμοποιήσετε καθέναν από αυτούς σε οποιοδήποτε στάδιο κατασκευής της εφαρμογής σας, είτε πρόκειται για το Microscope είτε οποιαδήποτε άλλη εφαρμογή. Ας ξεκινήσουμε!

Παρουσιάζοντας τα Παραρτήματα

Αυτό το κεφάλαιο είναι ένα παράρτημα. Τα παραρτήματα εμβαθύνουν σε ενότητες ανεξάρτητα από το υπόλοιπο βιβλίο.

Συνεπώς, αν προτιμάτε να συνεχίσετε με την κατασκευή του Microscope, μπορείτε να παραλείψετε από το κεφάλαιο και να επιστρέψετε αργότερα.

Ανάπτυξη στο Meteor.com

Η ανάπτυξη σε υποτομέα (subdomain) του Meteor.com (πχ http://myapp.meteor.com) είναι η πιο εύκολη επιλογή, και η πρώτη που θα δοκιμάσουμε. Μπορεί να σας φανεί χρήσιμο για να δείξετε την εφαρμογή σας σε άλλους σε πρώιμα στάδια, ή αν θέλετε μια γρήγορη και πρόχειρη λύση.

Η διαδικασία είναι σχετικά απλή. Ανοίξτε ένα παράθυρο τερματικού, πηγαίνετε στο φάκελο της εφαρμογής σας και πληκτρολογήστε:

meteor deploy myapp.meteor.com

Φυσικά, θα πρέπει να αντικαταστήσετε το “myapp” με ένα όνομα της επιλογής σας, το οποίο δεν χρησιμοποιείται ήδη.

Αν είναι η πρώτη φορά που αναπτύσσετε μια εφαρμογή, θα σας ζητηθεί να δημιουργήσετε ένα λογαριασμό Meteor. Αν όλα πάνε καλά, σε μερικά δευτερόλεπτα θα μπορείτε να δείτε την εφαρμογή σας στο http://myapp.meteor.com.

Μπορείτε να διαβάσετε την επίσημη τεκμηρίωση για περισσότερες πληροφορίες για θέματα όπως η προσπέλαση της βάσης δεδομένων στο διακομιστή ή η ρύθμιση του δικού σας τομέα (domain) για την εφαρμογή σας.

Ανάπτυξη στο Modulus

Το Modulus είναι μια πολύ καλή επιλογή για την ανάπτυξη εφαρμογών Node.js. Είναι από τους λίγους παρόχους PaaS (platform-as-a-service, πλατφόρμα ως υπηρεσία) που υποστηρίζουν επίσημα το Meteor, και υπάρχουν ήδη αρκετοί χρήστες που το χρησιμοποιούν για την ανάπτυξη των εφαρμογών τους σε επίπεδο παραγωγής (production level).

Demeteorizer

Modulus open-sourced a tool called demeteorizer which converts your Meteor app into a standard Node.js app. Το Modulus παρέχει ένα εργαλείο ανοιχτού κώδικα το οποίο ονομάζεται demeteorizer και μετατρέπει την Meteor εφαρμογή σας σε τυπική εφαρμογή Node.js.

Ξεκινήστε δημιουργώντας ένα λογαριασμό. Για να αναπτύξετε την εφαρμογή σας στο Modulus θα χρειαστεί να εγκαταστήσετε το εργαλείο γραμμής εντολών του Modulus.

npm install -g modulus

Και στη συνέχεια πρέπει να ταυτοποιηθείτε πληκτρολογώντας:

modulus login

Τώρα θα δημιουργήσουμε ένα σχέδιο εφαρμογής Modulus (σημειώστε ότι αυτό μπορείτε να το κάνετε από τον πίνακα ελέγχου της ιστοσελίδας):

modulus project create

Το επόμενο βήμα είναι να δημιουργήσουμε μια βάση δεδομένων MongoDB για την εφαρμογή μας. Αυτό μπορούμε να το κάνουμε είτε με το Modulus, είτε με το Compose ή οποιοδήποτε άλλο πάροχο υπηρεσιών MongoDB.

Μόλις δημιουργήσουμε την βάση δεδομένων μας, μπορούμε να βρούμε το MONGO_URL της από τον πίνακα ελέγχου του Modulus (Dashboard > Databases > Select your database > Administration) και να ρυθμίσουμε την εφαρμογή μας ως εξής:

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

Τώρα ήρθε η ώρα να αναπτύξουμε την εφαρμογή μας:

modulus deploy

Μόλις αναπτύξαμε επιτυχώς την εφαρμογή μας στο Modulus. Δείτε την τεκμηρίωση του Modulus για περισσότερες πληροφορίες σχετικά με την προσπέλαση των αρχείων καταγραφής (logs), την ρύθμιση του δικού σας τομέα (domain) και τη χρήση SSL.

Meteor Up

Παρότι νέοι πάροχοι εμφανίζονται καθημερινά, συχνά έχουν τα δικά τους προβλήματα και περιορισμούς. Προς το παρόν η καλύτερη λύση είναι η ανάπτυξη των εφαρμογών στον δικό σας διακομιστή. Το μόνο πρόβλημα είναι ότι η διαδικασία δεν είναι τόσο απλή, ειδικά σε επίπεδο παραγωγής (production level).

Το Meteor Upmup για συντομία) είναι μια προσπάθεια για την επίλυση του προβλήματος, παρέχοντας ένα εργαλείο γραμμής εντολών που αναλαμβάνει την ρύθμιση και ανάπτυξη των εφαρμογών για εσάς. Ας δούμε λοιπόν τη διαδικασία ανάπτυξης για το Microscope με τη χρήση του Meteor Up.

Πριν από οτιδήποτε άλλο, θα χρειαστούμε ένα διακομιστή. Σαν πάροχο προτείνουμε είτε το Digital Ocean με κόστος που ξεκινά από $5 το μήνα, είτε το AWS που προσφέρει δωρεάν Micro instances (θα συναντήσετε γρήγορα περιορισμούς αλλά προς το παρόν είναι αρκετό για να δοκιμάσουμε το Meteor Up).

Όποιο πάροχο και να επιλέξετε, θα χρειαστείτε τρία πράγματα: τη διεύθυνση IP του διακομιστή σας, το όνομα χρήστη (συνήθηως root ή ubuntu), και τον κωδικό πρόσβασης. Φυλάξτε αυτά τα στοιχεία με ασφάλεια, θα τα χρειαστείτε σύντομα!

Αρχικοποίηση του Meteor Up

Για να ξεκινήσουμε, θα χρειαστεί να εγκαταστήσουμε το Meteor Up μέσω του npm ως εξής:

npm install -g mup

Στη συνέχεια θα δημιουργήσουμε έναν ειδικό ξεχωριστό φάκελο που θα περιέχει τις ρυθμίσεις του Meteor Up για τη συγκεκριμένη εφαρμογή. Αυτό το κάνουμε για δύο λόγους: πρώτον είναι συνήθως καλύτερο να αποφεύγουμε την τοποθέτηση προσωπικών στοιχείων πρόσβασης στην απόθήκη Git, ειδικά όταν αυτή είναι δημόσια.

Δεύτερον, με τη χρήση πολλαπλών ξεχωστών φακέλων, μπορούμε να διαχειριστούμε παράλληλα διαφορετικές ρυθμίσεις του Meteor Up για την εφαρμογή μας. Αυτό θα φανεί ιδιαίτερα χρήσιμο για την ανάπτυξη της εφαρμογή μας σε επίπεδο δοκιμαστικό ή παραγωγής αργότερα.

Ας δημιουργήσουμε λοιπόν αυτό το φάκελο και ας αρχικοποιήσουμε τις ρυθμίσεις του Meteor Up:

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

Μοιραστείτε με το Dropbox

Ένας πολύ καλός τρόπος για να είστε βέβαιοι ότι όλοι στην ομάδα σας χρησιμοποιούν τις ίδιες ρυθμίσεις ανάπτυξης, είναι να δημιουργήσετε τον φάκελο με τις ρυθμίσεις το Meteor Up μέσα στο Dropbox ή οποιαδήποτε άλλη παρόμοια υπηρεσία.

Meteor Up Configuration

Κατά την αρχικοποίηση μιας εφαρμογής το Meteor Up δημιουργεί δύο αρχεία: mup.json και settings.json.

Το mup.json περιέχει όλες τις ρυθμίσεις σχετικά με την ανάπτυξη, ενώ το settings.json περίεχει όλες τις ρυθμίσεις σχετικά με την εφαρμογή (OAuth tokens, analytics tokens, κλπ).

Το επόμενο βήμα είναι να ρυθμίσετε το αρχείο mup.json. Αυτό είναι το mup.json που δημιουργείται από την εντολή mup init, και το μόνο που πρέπει να κάνετε είναι να συμπληρώσετε τα κενά:

{
  //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 (Ταυτοποίηση Διακομιστή)

Προσέξτε ότι το Meteor Up υποστηρίζει ταυτοποίηση τόσο μέσω κωδικού πρόσβασης όσο και μέσω προσωπικού κλειδιού (PEM), οπότε μπορείτε να το χρησιμοποιήσετε με σχεδόν όλους τους παρόχους.

Σημαντική σημείωση: αν επιλέξετε να χρησιμοποιήσετε την ταυτοποίηση μέσω κωδικού πρόσβασης, βεβαιωθείτε ότι έχετε εγκαταστήσει το sshpass πρώτα (οδηγίες).

MongoDB Configuration (Ρύθμιση MongoDB)

Το επόμενο βήμα είναι η ρύθμιση της βάσης δεδομένων της εφαρμογής σας. Προτείνουμε τη χρήση του Compose ή οποιουδήποτε άλλου παρόχου υπηρεσιών MongoDB, επειδή προσφέρουν επαγγελματική υποστήριξη και καλύτερα εργαλεία διαχείρησης.

Αν επιλέξατε το Compose, ορίστε την παράμετρο setupMongo ως false και προσθέστε τη μεταβλητή περιβάλλοντος MONGO_URL στο τμήμα env του αρχείου mup.json. Αν επιλέξατε να χρησιμοποιήσετε τοπική εγκατάσταση του MongoDB, ορίστε την παράμετρο setupMongo ως true και το Meteor Up θα φροντίσει τα υπόλοιπα.

Meteor App Path (Διαδρομή Εφαρμογής Meteor)

Εφόσον τα αρχεία ρυθμίσεων του Meteor Up βρίσκονται σε διαφορετικό φάκελο, θα πρέπει να επισημάνουμε στις ρυθμίσεις τη διαδρομή στην οποία βρίσκεται η εφαρμογή μας. Χρησιμοποιείστε την πλήρη διαδρομή, την οποία μπορείτε να βρείτε πληκτρολογώντας στο παράθυρο τερματικού την εντολή pwd από το φάκελο της εφαρμογής (πχ /home/user/app).

Environment Variables (Μεταβλητές Περιβάλλοντος)

Μπορείτε να ορίσετε όλες τις εφαρμογές περιβάλλοντος της εφαρμογής σας (όπως ROOT_URL, MAIL_URL, MONGO_URL, κλπ) μέσα στο τμήμα env του αρχείου mup.json.

Ρύθμιση και Ανάπτυξη

Προτού μπορέσουμε να αναπτύξουμε την εφαρμογή μας, θα πρέπει να ρυθμίσουμε το διακομιστή για να είναι έτοιμος να εκτελέσει εφαρμογές Meteor. Το Meteor Up πραγματοποιεί μαγικά όλη αυτή την περίπλοκη διαδικασία με μια μόνο εντολή!

mup setup

Η διαδικασία αυτή διαρκεί μερικά λεπτά ανάλογα με τους πόρους του διακομιστή και την ταχύτητα του δικτύου. Μετά την ολοκλήρωση της διαδικασίας μπορούμε επιτέλους να αναπτύξουμε την εφαρμογή μας:

mup deploy

Αυτή η εντολή θα ετοιμάσει την εφαρμογή Meteor και θα την αναπτύξει στον διακομιστή που μόλις ρυθμίσαμε.

Εμφάνιση Αρχείων Καταγραφής

Τα αρχεία καταγραφής είναι πολύ σημαντικά και το Meteor Up παρέχει ένα πολύ εύκολο τρόπο ανάγνωσής τους εξομοιώνοντας την εντολή tail -f. Απλά πληκτρολογήστε:

mup logs -f

Με αυτά ολοκληρώσαμε την επισκόπηση του Meteor Up. Για περισσότερες πληροφορίες προτείνουμε να επισκεφτείτε το Meteor Up’s GitHub repository.

Αυτοί οι τρεις τρόπου ανάπτυξης εφαρμογών Meteor είναι αρκετοί για τις περισσότερες περιπτώσεις. Φυσικά, κάποιοι από εσάς μπορεί να προτιμήσουν να έχουν τον πλήρη έλεγχο και να ρυθμίσουμε τον διακομιστή τους χειροκίνητα από την αρχή. Αυτό το θέμα όμως ίσως το συζητήσουμε μια άλλη μέρα… ή σε ένα άλλο βιβλίο!

Templates

3

////

////

////

<head>
  <title>Microscope</title>
</head>
<body>
  <div class="container">
    <header class="navbar navbar-default" role="navigation"> 
      <div class="navbar-header">
        <a class="navbar-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/templates/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/templates/posts/post_item.html

////

////

////

////

////

Going Further

////

////

////

////

////

Template Helpers

////

////

////

Controllers?

////

////

////

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

////

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

////

////

////

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

////

Commit 3-1

Added basic posts list template and static data.

The domain Helper

////

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

////

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

////

////

////

////

Commit 3-2

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

JavaScript Magic

////

////

////

////

Hot Code Reload

////

////

////

Χρησιμοποιώντας το Git και το GitHub

Sidebar 3.5

Το GitHub είναι ένα social repository για projects ανοιχτού κώδικα που βασίζονται πάνω στο Git version control system, και η κύρια εφαρμογή του είναι να κάνει εύκολη την διαμοίραση κώδικα και την συνεργασία σε projects. Είναι όμως και ένα φανταστικό εργαλείο μαθήσεως. Σε αυτό το sidebar, θα κοιτάξουμε στα γρήγορα μερικούς τρόπους που μπορείτε να χρησιμοποιήσετε το GitHub ώστε να μπορέσετε να παρακολουθήσετε το Discover Meteor.

Το sidebar αυτό υποθέτει ότι δεν είστε πολύ γνώριμοι με το Git και το GitHub. Εάν νιώθετε άνετα και με τα δύο, νιώστε ελεύθεροι να πάτε κατευθείαν στο επόμενο κεφάλαιο!

Being Committed

Το βασικό working block από ένα git repository είναι ένα commit. Μπορείτε να σκεφτείτε το commit ως ένα στιγμιότυπο από την κατάσταση του κώδικα σε μια στιγμή του χρόνου.

Αντί να σας δώσουμε απλά τον τελειωμένο κώδικα για το Microscope, έχουμε πάρει τέτοια στιγμιότυπα για κάθε βήμα στην πορεία, και μπορείτε να τα δείτε όλα online στο GitHub.

Για παράδειγμα, έτσι φαίνεται το τελευταίο commit του τελευταίου κεφαλαίου:

Ένα Git commit όπως φαίνεται στο GitHub.
Ένα Git commit όπως φαίνεται στο GitHub.

Εδώ βλέπετε το “diff” (για “difference”) απο το αρχείο post_item.js, με άλλα λόγια τις αλλαγές που εμφανίστηκαν με αυτό το commit. Σε αυτήν την περίπτωση, δημιουργήσαμε το post_item.js αρχείο εξ'αρχής, οπότε όλα τα περιεχόμενά του είναι επισημασμένο με πράσινο.

Ας συγκρίνουμε με ένα παράδειγμα απο αργότερα στο βιβλίο:

Κάνοντας αλλαγές στον κώδικα.
Κάνοντας αλλαγές στον κώδικα.

Αυτήν την φορά, μόνο οι αλλαγμένες γραμμές είναι επισημασμένες με πράσινο.

Βέβαια, μερικές φορές μπορεί να μην προσθέτετε ή να αλλάζετε γραμμές κώδικα, αλλά να τις διαγράφετε:

Διαγραφή κώδικα.
Διαγραφή κώδικα.

Έτσι έχουμε δει την πρώτη χρήση του GitHub: βλέπωντας τι έχει αλλάξει με μια γρήγορη ματιά.

Browsing A Commit’s Code

Το view του commit από το Git μας δείχνει τις αλλαγές που συμπεριλαμβάνονται σε αυτό το commit, αλλά μερικές φορές μπορεί να θέλετε να κοιτάξετε αρχεία που δεν έχουν αλλάξει, έτσι ώστε να είστε σίγουροι πως πρέπει ο κώδικάς τους να είναι γραμμένος στην παρούσα φάση της διαδικασίας.

Το GitHub μια ακόμη φορά μας σώζει. Όταν βρίσκεστε σε σελίδα commit, πατήστε το Browse code button:

Το κουμπί Browse code.
Το κουμπί Browse code.

Τώρα θα έχετε πρόσβαση στο repository όπως είναι σε ένα συγκεκριμένο commit:

Το repository στο commit 3-2.
Το repository στο 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 Mongo.Collection('posts');
lib/collections/posts.js

To Var Or Not To Var?

////

Storing Data

////

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

////

Client & Server

////

////

////

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

////

////

meteor mongo

> 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

////

////

////

Populating the Database

////

////

////

////

meteor reset

////

////

meteor

////

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

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

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

////

////

Dynamic Data

////

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

////

////

////

Template.postsList.helpers({
  posts: function() {
    return Posts.find();
  }
});
client/templates/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

////

////

////

meteor 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 navbar-default" role="navigation"> 
      <div class="navbar-header">
        <a class="navbar-brand" href="/">Microscope</a>
      </div>
    </header>
    <div id="main" class="row-fluid">
      {{> yield}}
    </div>
  </div>
</template>
client/templates/application/layout.html

////

////

////

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

Router.route('/', {name: 'postsList'});
lib/router.js

////

////

The /lib folder

////

////

Named Routes

////

////

////

////

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

//...
client/templates/application/layout.html

Waiting On Data

////

////

////

////

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

Router.route('/', {name: 'postsList'});
lib/router.js

////

////

Get A Load Of This

////

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

Router.route('/', {name: 'postsList'});
lib/router.js

////

////

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

////

////

A First Glance At Reactivity

////

////

////

Routing To A Specific Post

////

////

////

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

////

////

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

Router.route('/', {name: 'postsList'});
Router.route('/posts/:_id', {
  name: 'postPage'
});
lib/router.js

////

////

////

////

The data context.
The data context.

////

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

Router.route('/', {name: 'postsList'});
Router.route('/posts/:_id', {
  name: 'postPage',
  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 btn-default">Discuss</a>
  </div>
</template>
client/templates/posts/post_item.html

////

////

////

////

////

A single post page.
A single post page.

HTML5 pushState

////

////

////

Post Not Found

////

////

////

<template name="notFound">
  <div class="not-found jumbotron">
    <h2>404</h2>
    <p>Sorry, we couldn't find a page at this address.</p>
  </div>
</template>
client/templates/application/not_found.html

////

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

//...
lib/router.js

////

////

////

//...

Router.onBeforeAction('dataNotFound', {only: 'postPage'});
lib/router.js

////

Why “Iron”?

////

The Session

Sidebar 5.5

////

////

////

The Meteor Session

////

////

////

Changing the Session

////

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

////

////

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

A Note About Sidebar Code

////

////

////

 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

////

////

//// ////

////

Adding Users

6

////

////

////

Accounts: users made simple

////

////

////

meteor add ian:accounts-ui-bootstrap-3
meteor add accounts-password
Terminal

////

////

<template name="layout">
  <div class="container">
    {{> header}}
    <div id="main" class="row-fluid">
      {{> yield}}
    </div>
  </div>
</template>
client/templates/application/layout.html
<template name="header">
  <nav class="navbar navbar-default" role="navigation">
    <div class="container-fluid">
      <div class="navbar-header">
        <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navigation">
          <span class="sr-only">Toggle navigation</span>
          <span class="icon-bar"></span>
          <span class="icon-bar"></span>
          <span class="icon-bar"></span>
        </button>
        <a class="navbar-brand" href="{{pathFor 'postsList'}}">Microscope</a>
      </div>
      <div class="collapse navbar-collapse" id="navigation">
        <ul class="nav navbar-nav navbar-right">
          {{> loginButtons}}
        </ul>
      </div>
    </div>
  </nav>
</template>
client/templates/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

////

Meteor.startup(function() {
  Tracker.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',
  notFoundTemplate: 'notFound',
  waitOn: function() { return Meteor.subscribe('posts'); }
});

Router.route('/', {name: 'postsList'});

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

Router.route('/submit', {name: 'postSubmit'});

Router.onBeforeAction('dataNotFound', {only: 'postPage'});
lib/router.js

Adding A Link To The Header

////

<template name="header">
  <nav class="navbar navbar-default" role="navigation">
    <div class="container-fluid">
      <div class="navbar-header">
        <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navigation">
          <span class="sr-only">Toggle navigation</span>
          <span class="icon-bar"></span>
          <span class="icon-bar"></span>
          <span class="icon-bar"></span>
        </button>
        <a class="navbar-brand" href="{{pathFor 'postsList'}}">Microscope</a>
      </div>
      <div class="collapse navbar-collapse" id="navigation">
        <ul class="nav navbar-nav">
          <li><a href="{{pathFor 'postSubmit'}}">Submit Post</a></li>
        </ul>
        <ul class="nav navbar-nav navbar-right">
          {{> loginButtons}}
        </ul>
      </div>
    </div>
  </nav>
</template>
client/templates/includes/header.html

////

<template name="postSubmit">
  <form class="main form">
    <div class="form-group">
      <label class="control-label" for="url">URL</label>
      <div class="controls">
          <input name="url" id="url" type="text" value="" placeholder="Your URL" class="form-control"/>
      </div>
    </div>
    <div class="form-group">
      <label class="control-label" for="title">Title</label>
      <div class="controls">
          <input name="title" id="title" type="text" value="" placeholder="Name your post" class="form-control"/>
      </div>
    </div>
    <input type="submit" value="Submit" class="btn btn-primary"/>
  </form>
</template>
client/templates/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()
    };

    post._id = Posts.insert(post);
    Router.go('postPage', post);
  }
});
client/templates/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 Mongo.Collection('posts');

Posts.allow({
  insert: function(userId, doc) {
    // only allow posting if you are logged in
    return !! userId;
  }
});
lib/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',
  loadingTemplate: 'loading',
  notFoundTemplate: 'notFound',
  waitOn: function() { return Meteor.subscribe('posts'); }
});

Router.route('/', {name: 'postsList'});

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

Router.route('/submit', {name: 'postSubmit'});

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

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

////

<template name="accessDenied">
  <div class="access-denied jumbotron">
    <h2>Access Denied</h2>
    <p>You can't get here! Please log in.</p>
  </div>
</template>
client/templates/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

////

////

////

////

////

//...

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

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

Commit 7-4

Show a loading screen while waiting to login.

Hiding the Link

////

//...

<ul class="nav navbar-nav">
  {{#if currentUser}}<li><a href="{{pathFor 'postSubmit'}}">Submit Post</a></li>{{/if}}
</ul>

//...
client/templates/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()
    };

    Meteor.call('postInsert', post, function(error, result) {
      // display the error to the user and abort
      if (error)
        return alert(error.reason);

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

////

////

Security Check

////

////

////

////

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

Meteor.methods({
  postInsert: function(postAttributes) {
    check(Meteor.userId(), String);
    check(postAttributes, {
      title: String,
      url: String
    });

    var user = Meteor.user();
    var post = _.extend(postAttributes, {
      userId: user._id, 
      author: user.username, 
      submitted: new Date()
    });

    var postId = Posts.insert(post);

    return {
      _id: postId
    };
  }
});
lib/collections/posts.js

////

Bye Bye Allow/Deny

////

////

Preventing Duplicates

////

Meteor.methods({
  postInsert: function(postAttributes) {
    check(this.userId, String);
    check(postAttributes, {
      title: String,
      url: String
    });

    var postWithSameLink = Posts.findOne({url: postAttributes.url});
    if (postWithSameLink) {
      return {
        postExists: true,
        _id: postWithSameLink._id
      }
    }

    var user = Meteor.user();
    var post = _.extend(postAttributes, {
      userId: user._id, 
      author: user.username, 
      submitted: new Date()
    });

    var postId = Posts.insert(post);

    return {
      _id: postId
    };
  }
});
collections/posts.js

////

////

////

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

    Meteor.call('postInsert', post, function(error, result) {
      // display the error to the user and abort
      if (error)
        return alert(error.reason);

      // show this result but route anyway
      if (result.postExists)
        alert('This link has already been posted');

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

Sorting Posts

////

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

////

////

Latency Compensation

Sidebar 7.5

////

Without latency compensation
Without latency compensation

////

////

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

////

Latency Compensation

With latency compensation
With latency compensation

////

////

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

////

Observing Latency Compensation

////

////

////

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

Meteor.methods({
  postInsert: function(postAttributes) {
    check(this.userId, String);
    check(postAttributes, {
      title: String,
      url: String
    });

    if (Meteor.isServer) {
      postAttributes.title += "(server)";
      // wait for 5 seconds
      Meteor._sleepForMs(5000);
    } else {
      postAttributes.title += "(client)";
    }

    var postWithSameLink = Posts.findOne({url: postAttributes.url});
    if (postWithSameLink) {
      return {
        postExists: true,
        _id: postWithSameLink._id
      }
    }

    var user = Meteor.user();
    var post = _.extend(postAttributes, {
      userId: user._id, 
      author: user.username, 
      submitted: new Date()
    });

    var postId = Posts.insert(post);

    return {
      _id: postId
    };
  }
});
collections/posts.js

////

////

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

    Meteor.call('postInsert', post, function(error, result) {
      // display the error to the user and abort
      if (error)
        return alert(error.reason);

      // show this result but route anyway
      if (result.postExists)
        alert('This link has already been posted');

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

////

////

////

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

    Meteor.call('postInsert', post, function(error, result) {
      // display the error to the user and abort
      if (error)
        return alert(error.reason);

      // show this result but route anyway
      if (result.postExists)
        alert('This link has already been posted');
    });

    Router.go('postsList');  

  }
});
client/templates/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

////

////

//// ////

Methods Calling Methods

////

////

////

////

Editing Posts

8

////

////

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

Router.route('/', {name: 'postsList'});

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

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

Router.route('/submit', {name: 'postSubmit'});

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

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

The Post Edit Template

////

<template name="postEdit">
  <form class="main form">
    <div class="form-group">
      <label class="control-label" for="url">URL</label>
      <div class="controls">
          <input name="url" id="url" type="text" value="{{url}}" placeholder="Your URL" class="form-control"/>
      </div>
    </div>
    <div class="form-group">
      <label class="control-label" for="title">Title</label>
      <div class="controls">
          <input name="title" id="title" type="text" value="{{title}}" placeholder="Name your post" class="form-control"/>
      </div>
    </div>
    <input type="submit" value="Submit" class="btn btn-primary submit"/>
    <hr/>
    <a class="btn btn-danger delete" href="#">Delete post</a>
  </form>
</template>
client/templates/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/templates/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 btn-default">Discuss</a>
  </div>
</template>
client/templates/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/templates/posts/post_item.js
Post edit form.
Post edit 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 Mongo.Collection('posts');

Posts.allow({
  update: function(userId, post) { return ownsDocument(userId, post); },
  remove: function(userId, post) { return ownsDocument(userId, post); },
});

//...
lib/collections/posts.js

Commit 8-2

Added basic permission to check the post’s owner.

Limiting Edits

////

////

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

Posts.allow({
  update: function(userId, post) { return ownsDocument(userId, post); },
  remove: function(userId, post) { return ownsDocument(userId, post); },
});

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

//...
lib/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

////

////

Errors

9

////

////

////

Introducing Local Collections

////

////

// Local (client-only) collection
Errors = new Mongo.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/templates/application/layout.html

////

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

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

Twin Templates

////

////

////

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

////

throwError("I'm an error!");
Testing error messages.
Testing error messages.

Two Kinds of Errors

////

////

////

////

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

    Meteor.call('postInsert', post, function(error, result) {
      // display the error to the user and abort
      if (error)
        return throwError(error.reason);

      // show this result but route anyway
      if (result.postExists)
        throwError('This link has already been posted');

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

////

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
        throwError(error.reason);
      } else {
        Router.go('postPage', {_id: currentPostId});
      }
    });
  },
  //...
});
client/templates/posts/post_edit.js

////

Triggering an error
Triggering an error

Clearing Errors

////

@keyframes fadeOut {
  0% {opacity: 0;}
  10% {opacity: 1;}
  90% {opacity: 1;}
  100% {opacity: 0;}
}

//...

.alert {
  animation: fadeOut 2700ms ease-in 0s 1 forwards;
  //...
}
client/stylesheets/style.css

////

////

Animations vs Animations

////

////

////

Stack overflow.
Stack overflow.

////

////

////

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

Template.error.rendered = function() {
  var error = this.data;
  Meteor.setTimeout(function () {
    Errors.remove(error._id);
  }, 3000);
};
client/templates/includes/errors.js

////

Seeking Validation

////

////

////

<template name="postSubmit">
  <form class="main form">
    <div class="form-group {{errorClass 'url'}}">
      <label class="control-label" for="url">URL</label>
      <div class="controls">
          <input name="url" id="url" type="text" value="" placeholder="Your URL" class="form-control"/>
          <span class="help-block">{{errorMessage 'url'}}</span>
      </div>
    </div>
    <div class="form-group {{errorClass 'title'}}">
      <label class="control-label" for="title">Title</label>
      <div class="controls">
          <input name="title" id="title" type="text" value="" placeholder="Name your post" class="form-control"/>
          <span class="help-block">{{errorMessage 'title'}}</span>
      </div>
    </div>
    <input type="submit" value="Submit" class="btn btn-primary"/>
  </form>
</template>
client/templates/posts/post_submit.html

////

////

////

////

////

////

Template.postSubmit.created = function() {
  Session.set('postSubmitErrors', {});
}

Template.postSubmit.helpers({
  errorMessage: function(field) {
    return Session.get('postSubmitErrors')[field];
  },
  errorClass: function (field) {
    return !!Session.get('postSubmitErrors')[field] ? 'has-error' : '';
  }
});
client/templates/posts/post_submit.js

////

Session.set('postSubmitErrors', {title: 'Warning! Intruder detected. Now releasing robo-dogs.'});
Browser console
Red alert! Red alert!
Red alert! Red alert!

////

////

//...

validatePost = function (post) {
  var errors = {};

  if (!post.title)
    errors.title = "Please fill in a headline";

  if (!post.url)
    errors.url =  "Please fill in a URL";

  return errors;
}

//...
lib/collections/posts.js

////

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

    var errors = validatePost(post);
    if (errors.title || errors.url)
      return Session.set('postSubmitErrors', errors);

    Meteor.call('postInsert', post, function(error, result) {
      // display the error to the user and abort
      if (error)
        return throwError(error.reason);

      // show this result but route anyway
      if (result.postExists)
        throwError('This link has already been posted');

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

////

Caught red-handed.
Caught red-handed.

Server-side Validation

////

////

Meteor.methods({
  postInsert: function(postAttributes) {
    check(this.userId, String);
    check(postAttributes, {
      title: String,
      url: String
    });

    var errors = validatePost(postAttributes);
    if (errors.title || errors.url)
      throw new Meteor.Error('invalid-post', "You must set a title and URL for your post");

    var postWithSameLink = Posts.findOne({url: postAttributes.url});
    if (postWithSameLink) {
      return {
        postExists: true,
        _id: postWithSameLink._id
      }
    }

    var user = Meteor.user();
    var post = _.extend(postAttributes, {
      userId: user._id, 
      author: user.username, 
      submitted: new Date()
    });

    var postId = Posts.insert(post);

    return {
      _id: postId
    };
  }
});
lib/collections/posts.js

////

////

Meteor.call('postInsert', {url: '', title: 'No URL here!'});

////

Edition Validation

////

<template name="postEdit">
  <form class="main form">
    <div class="form-group {{errorClass 'url'}}">
      <label class="control-label" for="url">URL</label>
      <div class="controls">
          <input name="url" id="url" type="text" value="{{url}}" placeholder="Your URL" class="form-control"/>
          <span class="help-block">{{errorMessage 'url'}}</span>
      </div>
    </div>
    <div class="form-group {{errorClass 'title'}}">
      <label class="control-label" for="title">Title</label>
      <div class="controls">
          <input name="title" id="title" type="text" value="{{title}}" placeholder="Name your post" class="form-control"/>
          <span class="help-block">{{errorMessage 'title'}}</span>
      </div>
    </div>
    <input type="submit" value="Submit" class="btn btn-primary submit"/>
    <hr/>
    <a class="btn btn-danger delete" href="#">Delete post</a>
  </form>
</template>
client/templates/posts/post_edit.html

////

Template.postEdit.created = function() {
  Session.set('postEditErrors', {});
}

Template.postEdit.helpers({
  errorMessage: function(field) {
    return Session.get('postEditErrors')[field];
  },
  errorClass: function (field) {
    return !!Session.get('postEditErrors')[field] ? 'has-error' : '';
  }
});

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()
    }

    var errors = validatePost(postProperties);
    if (errors.title || errors.url)
      return Session.set('postEditErrors', errors);

    Posts.update(currentPostId, {$set: postProperties}, function(error) {
      if (error) {
        // display the error to the user
        throwError(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/templates/posts/post_edit.js

////

////

//...

Posts.deny({
  update: function(userId, post, fieldNames, modifier) {
    var errors = validatePost(modifier.$set);
    return errors.title || errors.url;
  }
});

//...
lib/collections/posts.js

////

////

////

Creating a Meteor Package

Sidebar 9.5

////

////

////

////

Package.describe({
  name: "tmeasday:errors",
  summary: "A pattern to display application errors to the user",
  version: "1.0.0"
});

Package.onUse(function (api, where) {
  api.versionsFrom('0.9.0');

  api.use(['minimongo', 'mongo-livedata', 'templating'], 'client');

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

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

////

////

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

  throw: function(message) {
    Errors.collection.insert({message: message, seen: false})
  }
};
packages/tmeasday:errors/errors.js
<template name="meteorErrors">
  <div class="errors">
    {{#each errors}}
      {{> meteorError}}
    {{/each}}
  </div>
</template>

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

Template.meteorError.rendered = function() {
  var error = this.data;
  Meteor.setTimeout(function () {
    Errors.collection.remove(error._id);
  }, 3000);
};
packages/tmeasday:errors/errors_list.js

Testing the package out with Microscope

////

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

////

  {{> header}}
  {{> meteorErrors}}
client/templates/application/layout.html
Meteor.call('postInsert', post, function(error, result) {
  if (error) {
    // display the error to the user
    Errors.throw(error.reason);

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

Commit 9-5-1

Created basic errors package and linked it in.

////

Writing tests

////

////

Tinytest.add("Errors - collection", 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", function(test, done) {  
  Errors.throw('A new error!');
  test.equal(Errors.collection.find({}).count(), 1);

  // render the template
  UI.insert(UI.render(Template.meteorErrors), document.body);

  Meteor.setTimeout(function() {
    test.equal(Errors.collection.find({}).count(), 0);
    done();
  }, 3500);
});
packages/tmeasday:errors/errors_tests.js

////

////

////

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

  api.addFiles('errors_tests.js', 'client');
});
packages/tmeasday:errors/package.js

////

meteor test-packages tmeasday:errors
Terminal
Passing all tests
Passing all tests

Releasing the package

////

////

cd packages/tmeasday:errors
meteor publish --create
Terminal

////

rm -r packages/errors
meteor add tmeasday:errors
Terminal (run from the top level of the app)

Commit 9-5-4

Removed package from development tree.

////

Comments

10

////

////

Comments = new Mongo.Collection('comments');
lib/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: new Date(now - 7 * 3600 * 1000)
  });

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

  Comments.insert({
    postId: telescopeId,
    userId: sacha._id,
    author: sacha.profile.name,
    submitted: new Date(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: new Date(now - 10 * 3600 * 1000)
  });

  Posts.insert({
    title: 'The Meteor Book',
    userId: tom._id,
    author: tom.profile.name,
    url: 'http://themeteorbook.com',
    submitted: new Date(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',
  notFoundTemplate: 'notFound',
  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}}
      {{> commentItem}}
    {{/each}}
  </ul>
</template>
client/templates/posts/post_page.html
Template.postPage.helpers({
  comments: function() {
    return Comments.find({postId: this._id});
  }
});
client/templates/posts/post_page.js

////

////

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

////

Template.commentItem.helpers({
  submittedText: function() {
    return this.submitted.toString();
  }
});
client/templates/comments/comment_item.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 btn-default">Discuss</a>
  </div>
</template>
client/templates/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/templates/posts/post_item.js

////

Displaying comments
Displaying comments

Submitting Comments

////

////

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

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

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

////

<template name="commentSubmit">
  <form name="comment" class="comment-form form">
    <div class="form-group {{errorClass 'body'}}">
        <div class="controls">
            <label for="body">Comment on this post</label>
            <textarea name="body" id="body" class="form-control" rows="3"></textarea>
            <span class="help-block">{{errorMessage 'body'}}</span>
        </div>
    </div>
    <button type="submit" class="btn btn-primary">Add Comment</button>
  </form>
</template>
client/templates/comments/comment_submit.html

////

Template.commentSubmit.created = function() {
  Session.set('commentSubmitErrors', {});
}

Template.commentSubmit.helpers({
  errorMessage: function(field) {
    return Session.get('commentSubmitErrors')[field];
  },
  errorClass: function (field) {
    return !!Session.get('commentSubmitErrors')[field] ? 'has-error' : '';
  }
});

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

    var errors = {};
    if (! comment.body) {
      errors.body = "Please write some content";
      return Session.set('commentSubmitErrors', errors);
    }

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

////

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

Meteor.methods({
  commentInsert: function(commentAttributes) {
    check(this.userId, String);
    check(commentAttributes, {
      postId: String,
      body: String
    });

    var user = Meteor.user();
    var post = Posts.findOne(commentAttributes.postId);

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

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

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

////

The comment submit form
The comment submit form

Controlling the Comments Subscription

////

////

////

////

////

////

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

////

//...

Router.route('/posts/:_id', {
  name: 'postPage',
  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) {
  check(postId, String);
  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

////

////

////

// 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: new Date(now - 7 * 3600 * 1000),
    commentsCount: 2
  });

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

  Comments.insert({
    postId: telescopeId,
    userId: sacha._id,
    author: sacha.profile.name,
    submitted: new Date(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: new Date(now - 10 * 3600 * 1000),
    commentsCount: 0
  });

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

////

////

//...

var post = _.extend(postAttributes, {
  userId: user._id,
  author: user.username,
  submitted: new Date(),
  commentsCount: 0
});

var postId = Posts.insert(post);

//...
collections/posts.js

////

//...

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

// 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

////

////

//// //// //// ////

////

The Downsides of Denormalization

////

Notifications

11

////

////

////

Creating Notifications

////

////

////

  • ////
  • ////
  • ////
Notifications = new Mongo.Collection('notifications');

Notifications.allow({
  update: function(userId, doc, fieldNames) {
    return ownsDocument(userId, doc) && 
      fieldNames.length === 1 && fieldNames[0] === 'read';
  }
});

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
    });
  }
};
lib/collections/notifications.js

////

////

////

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

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

    //...

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

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

    // 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;
  }
});
lib/collections/comments.js

////

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

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

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

////

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

Displaying Notifications

////

<template name="header">
  <nav class="navbar navbar-default" role="navigation">
    <div class="container-fluid">
      <div class="navbar-header">
        <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navigation">
          <span class="sr-only">Toggle navigation</span>
          <span class="icon-bar"></span>
          <span class="icon-bar"></span>
          <span class="icon-bar"></span>
        </button>
        <a class="navbar-brand" href="{{pathFor 'postsList'}}">Microscope</a>
      </div>
      <div class="collapse navbar-collapse" id="navigation">
        <ul class="nav navbar-nav">
          {{#if currentUser}}
            <li>
              <a href="{{pathFor 'postSubmit'}}">Submit Post</a>
            </li>
            <li class="dropdown">
              {{> notifications}}
            </li>
          {{/if}}
        </ul>
        <ul class="nav navbar-nav navbar-right">
          {{> loginButtons}}
        </ul>
      </div>
    </div>
  </nav>
</template>
client/templates/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}}
        {{> notificationItem}}
      {{/each}}
    {{else}}
      <li><span>No Notifications</span></li>
    {{/if}}
  </ul>
</template>

<template name="notificationItem">
  <li>
    <a href="{{notificationPostPath}}">
      <strong>{{commenterName}}</strong> commented on your post
    </a>
  </li>
</template>
client/templates/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.notificationItem.helpers({
  notificationPostPath: function() {
    return Router.routes.postPage.path({_id: this.postId});
  }
});

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

////

////

Displaying notifications.
Displaying notifications.

Controlling access to notifications

////

////

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

////

////

////

////

////

Meteor.publish('notifications', function() {
  return Notifications.find({userId: this.userId, read: false});
});
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).url, 
      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 Tracker.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);

////

////

////

meteor add reactive-var

////

var currentLikeCount = new ReactiveVar();

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

////

Comparing Tracker 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: new Date(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: new Date(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',
  notFoundTemplate: 'notFound',
  waitOn: function() { 
    return [Meteor.subscribe('notifications')]
  }
});
lib/router.js

////

//...

Router.route('/:postsLimit?', {
  name: 'postsList',
});

//...
lib/router.js

////

////

////

//...

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

//...
lib/router.js

////

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

Meteor.publish('comments', function(postId) {
  check(postId, String);
  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.route('/:postsLimit?', {
  name: 'postsList',
  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',
  notFoundTemplate: 'notFound',
  waitOn: function() { 
    return [Meteor.subscribe('notifications')]
  }
});

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

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

Router.route('/submit', {name: 'postSubmit'});

Router.route('/:postsLimit?', {
  name: 'postsList',
  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})
    };
  }
});

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

Router.onBeforeAction('dataNotFound', {only: 'postPage'});
Router.onBeforeAction(requireLogin, {only: 'postSubmit'});
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, 
  postsLimit: function() { 
    return parseInt(this.params.postsLimit) || this.increment; 
  },
  findOptions: function() {
    return {sort: {submitted: -1}, limit: this.postsLimit()};
  },
  waitOn: function() {
    return Meteor.subscribe('posts', this.findOptions());
  },
  data: function() {
    return {posts: Posts.find({}, this.findOptions())};
  }
});

//...

Router.route('/:postsLimit?', {
  name: 'postsList'
});

//...
lib/router.js

////

////

////

////

Commit 12-3

Refactored postsLists route into a RouteController.

Adding A Load More Link

////

////

////

//...

PostsListController = RouteController.extend({
  template: 'postsList',
  increment: 5, 
  postsLimit: function() { 
    return parseInt(this.params.postsLimit) || this.increment; 
  },
  findOptions: function() {
    return {sort: {submitted: -1}, limit: this.postsLimit()};
  },
  waitOn: function() {
    return Meteor.subscribe('posts', this.findOptions());
  },
  posts: function() {
    return Posts.find({}, this.findOptions());
  },
  data: function() {
    var hasMore = this.posts().count() === this.postsLimit();
    var nextPath = this.route.path({postsLimit: this.postsLimit() + 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/templates/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…

A Better User Experience

////

////

////

////

//...

PostsListController = RouteController.extend({
  template: 'postsList',
  increment: 5, 
  postsLimit: function() { 
    return parseInt(this.params.postsLimit) || this.increment; 
  },
  findOptions: function() {
    return {sort: {submitted: -1}, limit: this.postsLimit()};
  },
  subscriptions: function() {
    this.postsSub = Meteor.subscribe('posts', this.findOptions());
  },
  posts: function() {
    return Posts.find({}, this.findOptions());
  },
  data: function() {
    var hasMore = this.posts().count() === this.postsLimit();
    var nextPath = this.route.path({postsLimit: this.postsLimit() + this.increment});
    return {
      posts: this.posts(),
      ready: this.postsSub.ready,
      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>
    {{else}}
      {{#unless ready}}
        {{> spinner}}
      {{/unless}}
    {{/if}}
  </div>
</template>
client/templates/posts/posts_list.html

Commit 12-5

Add a spinner to make pagination nicer.

Accessing Any Post

////

An empty template.
An empty template.

////

////

////

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

Meteor.publish('singlePost', function(id) {
  check(id, String)
  return Posts.find(id);
});

//...
server/publications.js

////

//...

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

Router.route('/posts/:_id/edit', {
  name: 'postEdit',
  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: new Date(now - 7 * 3600 * 1000),
    commentsCount: 2,
    upvoters: [], 
    votes: 0
  });

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

  Comments.insert({
    postId: telescopeId,
    userId: sacha._id,
    author: sacha.profile.name,
    submitted: new Date(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: new Date(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: new Date(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: new Date(now - i * 3600 * 1000 + 1),
      commentsCount: 0,
      upvoters: [], 
      votes: 0
    });
  }
}
server/fixtures.js

////

//...

var postWithSameLink = Posts.findOne({url: postAttributes.url});
if (postWithSameLink) {
  return {
    postExists: true,
    _id: postWithSameLink._id
  }
}

var user = Meteor.user();
var post = _.extend(postAttributes, {
  userId: user._id, 
  author: user.username, 
  submitted: new Date(),
  commentsCount: 0,
  upvoters: [], 
  votes: 0
});

var postId = Posts.insert(post);

return {
  _id: postId
};

//...
collections/posts.js

Voting Templates

////

<template name="postItem">
  <div class="post">
    <a href="#" class="upvote btn btn-default"></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 btn-default">Discuss</a>
  </div>
</template>
client/templates/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/templates/posts/post_item.js

////

//...

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

  upvote: function(postId) {
    check(this.userId, String);
    check(postId, String);

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

    if (_.include(post.upvoters, this.userId))
      throw new Meteor.Error('invalid', 'Already upvoted this post');

    Posts.update(post._id, {
      $addToSet: {upvoters: this.userId},
      $inc: {votes: 1}
    });
  }
});

//...
lib/collections/posts.js

////

////

User Interface Tweaks

////

<template name="postItem">
  <div class="post">
    <a href="#" class="upvote btn btn-default {{upvotedClass}}"></a>
    <div class="post-content">
      //...
  </div>
</template>
client/templates/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/templates/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.

////

UI.registerHelper('pluralize', function(n, thing) {
  // fairly stupid pluralizer
  if (n === 1) {
    return '1 ' + thing;
  } else {
    return n + ' ' + thing + 's';
  }
});
client/helpers/spacebars.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/templates/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

////

////

//// //// ////

////

//...

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

  upvote: function(postId) {
    check(this.userId, String);
    check(postId, String);

    var affected = Posts.update({
      _id: postId, 
      upvoters: {$ne: this.userId}
    }, {
      $addToSet: {upvoters: this.userId},
      $inc: {votes: 1}
    });

    if (! affected)
      throw new Meteor.Error('invalid', "You weren't able to upvote that post");
  }
});

//...
collections/posts.js

////

Latency Compensation

////

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

////

////

////

////

////

Ranking the Front Page Posts

////

////

////

////

////

//...

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

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

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

Router.route('/', {
  name: 'home',
  controller: NewPostsController
});

Router.route('/new/:postsLimit?', {name: 'newPosts'});

Router.route('/best/:postsLimit?', {name: 'bestPosts'});
lib/router.js

////

////

////

//// //// //// //// ////

lib/router.js

////

<template name="header">
  <nav class="navbar navbar-default" role="navigation">
    <div class="container-fluid">
      <div class="navbar-header">
        <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navigation">
          <span class="sr-only">Toggle navigation</span>
          <span class="icon-bar"></span>
          <span class="icon-bar"></span>
          <span class="icon-bar"></span>
        </button>
        <a class="navbar-brand" href="{{pathFor 'home'}}">Microscope</a>
      </div>
      <div class="collapse navbar-collapse" id="navigation">
        <ul class="nav navbar-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 navbar-nav navbar-right">
          {{> loginButtons}}
        </ul>
      </div>
    </div>
  </nav>
</template>
client/templates/includes/header.html

////

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

    if (confirm("Delete this post?")) {
      var currentPostId = this._id;
      Posts.remove(currentPostId);
      Router.go('home');
    }
  }
client/templates/posts_edit.js

////

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">
  <nav class="navbar navbar-default" role="navigation">
    <div class="container-fluid">
      <div class="navbar-header">
        <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navigation">
          <span class="sr-only">Toggle navigation</span>
          <span class="icon-bar"></span>
          <span class="icon-bar"></span>
          <span class="icon-bar"></span>
        </button>
        <a class="navbar-brand" href="{{pathFor 'home'}}">Microscope</a>
      </div>
      <div class="collapse navbar-collapse" id="navigation">
        <ul class="nav navbar-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 navbar-nav navbar-right">
          {{> loginButtons}}
        </ul>
      </div>
    </div>
  </nav>
</template>
client/templates/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() && Router.current().route.getName() === name
    });

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

Helper Arguments

////

////

////

////

////

////

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[postId] = 
      Mongo.Collection._publishCursor(commentsCursor, sub, 'comments');
  }

  postHandle = Posts.find({}, {limit: limit}).observeChanges({
    added: function(id, post) {
      publishPostComments(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() { postHandle.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'});
    Mongo.Collection._publishCursor(videosCursor, sub, 'videos');

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

////

////

////

Animations

14

Out of Date?

////

////

////

Meteor & the DOM

////

////

////

The Soviet Runner

////

////

////

Breaking It Down

////

////

//// //// //// //// //// ////

////

Switching two posts
Switching 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() {
    return this.posts.map(function(post, index, cursor) {
      post._rank = index;
      return post;
    });
  }
});
/client/templates/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/templates/posts/posts_list.html

Putting It Together

////

//// //// ////

////

//// ////

/client/templates/posts/post_item.html

////

////

var POST_HEIGHT = 80;
var Positions = new Mongo.Collection(null);

Template.postItem.helpers({

  //..

  },
  attributes: function() {
    var post = _.extend({}, Positions.findOne({postId: this._id}), this);
    var newPosition = post._rank * POST_HEIGHT;
    var attributes = {};

    if (! _.isUndefined(post.position)) {
      var offset = post.position - newPosition;      
      attributes.style = "top: " + offset + "px";
      if (offset === 0)
        attributes.class = "post animate"
    }

    Meteor.setTimeout(function() {
      Positions.upsert({postId: post._id}, {$set: {position: newPosition}})
    });

    return attributes;
  }
});

//..
/client/templates/posts/post_item.js

////

////

////

Running Schedule

////

////

////

The Attributes Helper

////

////

////

////

////

////

Timing Out

////

////

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

////

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

////

Animating New Posts

////

//..

attributes: function() {
  var post = _.extend({}, Positions.findOne({postId: this._id}), this);
  var newPosition = post._rank * POST_HEIGHT;
  var attributes = {};

  if (_.isUndefined(post.position)) {
    attributes.class = 'post invisible';
  } else {
    var delta = post.position - newPosition;      
    attributes.style = "top: " + delta + "px";
    if (delta === 0)
      attributes.class = "post animate"
  }

  Meteor.setTimeout(function() {
    Positions.upsert({postId: post._id}, {$set: {position: newPosition}})
  });

  return attributes;
}

//..
/client/templates/posts/post_item.js

////

CSS & JavaScript

////

////

////

Going Further

14.5

////

Extra Chapters

////

Meteor Manual

////

Evented Mind

////

MeteorHacks

////

Atmosphere

////

////

Meteorpedia

////

BulletProof Meteor

////

The Meteor Podcast

////

Other Resources

////

////

Getting Help

////

Community

////

Λεξικό όρων του Meteor

99

Πελάτης (Client)

Όταν αναφερόμαστε στον Πελάτη, εννοούμε τον κώδικα που εκτελείται στο περιβάλλον του χρήστη, είτε πρόκειται για λογισμικό φυλλομετρητή όπως Firefox ή Safari, είτε κάτι πιο σύνθετο όπως το UIWebView σε μια εφαρμογή iPhone.

Συλλογή (Collection)

Μια Συλλογή του Meteor είναι η αποθήκη δεδομένων που συγχρονίζεται αυτόματα μεταξύ Πελάτη και Διακομιστή. Οι Συλλογές έχουν ένα όνομα (πχ posts), και συνήθως υπάρχουν τόσο στον Πελάτη όσο και στον Διακομιστή. Παρότι συμπεριφέρονται διαφορετικά, έχουν κοινό API το οποίο βασίζεται στο API του Mongo.

Υπολογισμός (Computation)

Ένας Υπολογισμός είναι ένα τμήμα κώδικα που εκτελείται κάθε φορά που αλλάζει κάποια αντιδραστική πηγή δεδομένων από την οποία εξαρτάται. Αν έχετε μια αντιδραστική πηγή δεδομένων (πχ μια μεταβλητή της Συνεδρίας) και θέλετε η εφαρμογή σας να ανταποκρίνεται στις αλλαγές της μεταβλητής, θα πρέπει να τη συσχετίσετε με έναν Υπολογισμό.

Δείκτης (Cursor)

Ένας Δείκτης είναι το αποτέλεσμα της εκτέλεσης μιας ερώτησης σε μια Συλλογή Mongo. Στο περιβάλλον του Πελάτη, ο Δείκτης δεν είναι απλά ένας πίνακας με αποτελέσματα, αλλά ένα αντιδραστικό αντικείμενο που μπορεί να παρακολουθείται για τυχών προσθήκη, διαγραφή ή τροποποίηση αντικειμένων στη Συλλογή.

Πρωτόκολλο Διανομής Δεδομένων (Distributed Data Protocol, DDP)

Το Πρωτόκολλο Διανομής Δεδομένων του Meteor είναι ο αλγόριθμος βάσει του οποίου γίνεται ο συγχρονισμός των Συλλογών και η κλήση Μεθόδων. Το DDP προορίζεται για γενική χρήση, και αντικαθιστά το HTTP στις εφαρμογές πραγματικού χρόνου που μεταφέρουν μεγάλο όγκο δεδομένων.

Ανιχνευτής (Tracker)

Ο Ανιχνευτής είναι το σύστημα αντίδρασης του Meteor. Χρησιμοποιείται στο παρασκήνιο για να φροντίζει η HTML να συγχρονίζεται αυτόματα με το υποκείμενο μοντέλο δεδομένων.

Έγγραφο (Document)

Το Mongo είναι μια βάση δεδομένων η οποία οργανώνεται σε Συλλογές, οι οποίες περιέχουν Έγγραφα, αντίστοιχα με τον τρόπο που μια SQL βάση δεδομένων θα οργάνωνε τα δεδομένα σε πίνακες που περιέχουν εγγραφές. Συνεπώς όταν κάνουμε μια αναζήτηση σε μια Συλλογή, κάθε αποτέλεσμα που λαμβάνουμε είναι ένα απλό αντικείμενο JavaScript, με μια ειδική ιδιότητα (property) με το όνομα _id, την όποία χρησιμοποιεί το Meteor για να παρακολουθεί την πορεία των αντικειμένων μέσω του DDP.

Βοηθοί (Helpers)

Όταν ένα πρότυπο (template) χρειάζεται να σχεδιάσει κάτι πιο περίπλοκο από μια ιδιότητα εγγράφου (document property) μπορεί να καλέσει μια συνάρτηση, η οποία ονομάζεται Βοηθός του προτύπου.

Αντιστάθμιση Καθυστέρησης (Latency Compensation)

Είναι μια τεχνική που επιτρέπει την εξομοίωση των κλήσεων των Μεθόδων στο περιβάλλον του Πελάτη, προκειμένου να αποφύγει την καθυστέρηση που θα προέκυπτε από την αναμονή για την απάντηση του Διακομιστή.

Meteor Development Group (MDG)

Η εταιρεία που αναπτύσσει το Meteor, σε αντίθεση με το ίδιο το Πλαίσιο.

Μέθοδοι (Method)

Μια Μέθοδος του Meteor είναι μια απομακρυσμένη κλήση διαδικασίας (remote procedure call) από τον Πελάτη προς τον Διακομιστή, με κάποιες επιπλέον συναρτήσεις για να παρακολουθεί τις αλλαγές των Συλλογών και να επιτρέπει την Αντιστάθμιση της Καθυστέρησης.

MiniMongo

Στο περιβάλλον του Πελάτη οι Συλλογές βρίσκονται στη μνήμη, σε μια αποθήκη δεδομένων που παρέχει ένα API παρόμοιο με αυτό του Mongo. Η βιβλιοθήκη που υποστηρίζει αυτή τη λειτουργία λέγεται “MiniMongo”, υποδεικνύοντας ότι πρόκειται μια μικρότερη έκδοση του Mongo που εκτελείται εξ’ ολοκλήρου στη μνήμη.

Πακέτο (Package)

Ένα Πακέτο Meteor μπορεί να αποτελείται από κώδικα JavaScript που εκτελείται στο Διακομιστή, κώδικα JavaScript που εκτελείται στον Πελάτη, οδηγίες για την επεξεργασία πόρων (πχ SASS σε CSS), και πόρους προς επεξεργασία.
Το Πακέτο είναι σαν μια υπερ-βιβλιοθήκη. Το Meteor περιλαμβάνει μια εκτεταμένη συλλογή βασικών πακέτων, και επιπλέον υπάρχει το Atmosphere, το οποίο περιέχει μια μεγάλη συλλογή πακέτων προερχόμενα από την κοινότητα.

Δημοσίευση (Publication)

Η Δημοσίευση είναι μια ονομαστική συλλογή δεδομένων η οποία προσαρμόζεται για κάθε χρήστη που δημιουργεί Συνδρομή σε αυτή. Η Δημοσίευση καθορίζεται στο Περιβάλλον του Διακομιστή.

Διακομιστής (Server)

Ο Διακομιστής Meteor είναι ένας HTTP και DDP διακομιστής που εκτελείται μέσω του Node.js. Αποτελείται από όλες τις βιβλιοθήκες του Meteor, καθώς και από τον κώδικα JavaScript της εφαρμογής μας που εκτελείται σε περιβάλλον διακομιστή. Όταν ξεκινάμε τον διακομιστή, συνδέεται σε μια βάση δεδομένων Mongo (κατά την ανάπτηξη της εφαρμογής το Meteor ξεκινάει τη βάση δεδομένων αυτόματα).

Συνεδρία (Session)

Η Συνεδρία στο Meteor αναφέρεται στην πηγή αντιδραστικών δεδομένων στο περιβάλλον του Πελ΄τη, η οποία χρησιμοποιείται από την εφαρμογή για να παρακολουθεί την κατάσταση του χρήστη.

Συνδρομή (Subscription)

Η Συνδρομή συνδέει έναν Πελάτη με μια Δημοσίευση. Πρόκειται για κώδικα που εκτελείται στο περιβάλλον του Πελάτη, επικοινωνεί με τη Δημοσίευση στον Διακομιστή και συγχρονίζει τα δεδομένα.

Πρότυπο (Template)

Το πρότυπο είναι μια μέθοδος δημιουργίας HTML στην JavaScript. Εξ’ ορισμού το Meteor υποστηρίζει το Spacebars, ένα σύστημα προτύπων logic-less, αλλά υπάρχουν σχέδια να υποστηριχθούν περισσότερα στο μέλλον.

Σχετιζόμενα Δεδομένα Προτύπου (Template Data Context)

Όταν ένα πρότυπο αποτυπώνεται, συσχετίζεται με ένα αντικείμενο JavaScript το οποίο παρέχει συγκεκριμένα δεδομένα για την αποτύπωση. Συνήθως πρόκειται για απλά αντικείμενα JavaScript (POJOs), συχνά Έγγραφα κάποιας Συλλογής, αλλά μπορούν να είναι πιο πολύπλοκα και να περιέχουν και συναρτήσεις.

Changelog

99

December 5, 2014 1.7.2

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