Creating a Document Sharing Site With Meteor.js

Background:

“Meteor is a set of new technologies for building top-quality web apps in a fraction of the time, whether you’re an expert developer or just getting started.” – Meteor.com

Goal:

Create and deploy a real time document sharing website. The final product is at: docshare-tutorial.meteor.com.

Updated: Source code at https://github.com/skalb/docshare-tutorial

Spec:

  • Single page app with two sections
  • Section 1
    • List of documents each with edit and delete buttons
    • Create new document button with name input
  • Section 2
    • Text area of the document currently being edited

Prerequisites:

  • Install Meteor
1
$ curl install.meteor.com | /bin/sh

Step 1: Getting things started

Lets create the app:

1
meteor create docshare-tutorial

Now, start the meteor server:

1
2
cd docshare-tutorial
meteor

You should see the default site at http://localhost:3000:

Lastly, add the other packages we are going to use

1
2
meteor add coffeescript
meteor add backbone

Step 2: Setting up the project

Go ahead and delete docshare-tutorial.js and empty out the contents of docshare-tutorial.html.

Meteor lets you separate client and server code in 2 different ways:

  1. Using the Meteor.is_client and Meteor.is_server flags
  2. Place client and server Javascript in the /client and /server folders, respectively. Any Javascript at the root level with run on both.

I prefer method 2 since it feels a bit cleaner to me, but feel free to instead combine everything into one file. Create docshare-tutorial.coffee at the root and client.coffee in /client folder and server.coffee in the /server folder.

Step 3: Server

Collections in Meteor are schemaless. We want our documents collection to be available to both the server and the client so we’ll add it to the root level.

docshare-tutorial.coffee
1
 @Documents = new Meteor.Collection("documents")

Our document object will have two fields: name and text. Let’s create a sample document on startup.

server.coffee
1
2
3
4
5
Meteor.startup ->
  if Documents.find().count() is 0
    Documents.insert
      name: "Sample doc"
      text: "Write here..."

Now, if you restart the meteor server, you’ll be able to access that document in the browser. Try this in the developer console:

console
1
Documents.findOne()

You will see an object with the properties we just created. This is the only time you should need to restart the meteor server.

Step 4: Client HTML

Here’s we’ll define our head and body. The body will render two templates: documentList and documentView.

console
1
2
3
4
5
6
7
8
<head>
  <title>docshare</title>
</head>
<body>
  
  <hr>
  
</body>

Next, create the two templates needed to display the documents: documentList and document.

console
1
2
3
4
5
6
7
8
9
10
11
<template name="documentList">
  <h1>Welcome to document sharing!</h1>
  <div>
    
      
    
  </div>
  <div id="createDocument">
    Name: <input type="text" id="new-document-name" placeholder="New document" /><input type="button" id="new-document" value="create"/>
  </div>
</template>

Here we are using the built in Handlebars iterator #each to render the individual document objects. Finally, there’s a input field and create button to add a new document.

Now add the template to list the documents names each with an edit and delete button. We’ll also use a template method to determine which document is selected.

console
1
2
3
4
5
6
7
8
9
<template name="document">
  <div class="document ">
    <p>
    
    <input type="button" id="edit-document" value="Edit"/>
    <input type="button" id="delete-document" value="Delete"/>
    </p>
   </div>
</template>

Lastly, let’s add the actual text field that the users can edit.

console
1
2
3
4
5
6
7
8
9
10
<template name="documentView">
  
  
    <div>
      <p></p>
        <textarea id="document-text" rows="10" cols="80"></textarea>
    </div>
   
   
</template>

Note that this will only be rendered if a document is currently selected. Having both an #if and a #with seems redundant, but I didn’t see a better way. Based off the Handlebars documentation, I should only need the #if, but that doesn’t work.

Step 5: Client Coffeescript

First, we’ll setup a Backbone router to allow us to keep track of which document we’re viewing. This will allow us to support page refreshes and permalinking to documents.

client.coffee
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
DocumentsRouter = Backbone.Router.extend(
  routes:
    ":document_id": "main"

  main: (document_id) ->
    Session.set "document_id", document_id

  setDocument: (document_id) ->
    @navigate(document_id, true)
)

Router = new DocumentsRouter

Meteor.startup ->
  Backbone.history.start pushState: true

Basically this will store the document_id into the Meteor session whenever the user navigates to a URL or form /:document_id. If you’re not familar with Backbone, don’t worry about this, or read up at http://backbonejs.org/

We are also using Meteor.startup again here but for a different purpose. On the client side it will run after DOM is loaded every time. I think it would be more clear if this method didn’t mean different things based on context.

Next, we need to define where the documentList template gets its data and handle the create new button

client.coffee
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Template.documentList.documents = ->
  Documents.find({},
    sort:
      name: 1
  )

Template.documentList.events =
  'click #new-document': (e) ->
    name = $('#new-document-name').val()
    if name
      Documents.insert(
        name: name
        text: ""
      )

Notice that we’re sorting the documents by name. Each time a new document is added it will be correctly injected into the DOM. The entire list is not recreated. There is also a bit of validation to make sure the name exists.

Documents.insert (and all collection operations) are non-blocking when called client side. Meteor will go ahead and insert the data to the local client and can optionally call a callback with an object identifier or error message after the real operation finishes. This is invisible to the user, of course.

Next, define the selected property and event handlers for edit and delete:

client.coffee
1
2
3
4
5
6
7
8
Template.document.events =
  'click #delete-document': (e) ->
    Documents.remove(@_id)
  'click #edit-document': (e) ->
    Router.setDocument(@_id)

Template.document.selected = ->
  if Session.equals("document_id", @_id) then "selected" else ""

Note that in handlers for events the this object is the actual Document object.

Next, define the selectedDocument using the id stored in the session and update the text of that document when the user presses a key.

client.coffee
1
2
3
4
5
6
7
8
9
10
11
12
Template.documentView.selectedDocument = ->
  document_id = Session.get("document_id")
  Documents.findOne(
    _id: document_id
  )

Template.documentView.events =
  'keyup #document-text': (e) ->
    # @_id should work here, but it doesn't
    sel = _id: Session.get("document_id")
    mod = $set: text: $('#document-text').val()
    Documents.update(sel, mod)

Meteor acknowledges in their docs: “For now, the event handler gets the template data from the top level of the current template, not the template data from the template context of the element that triggered the event. This will be changing.” This is why we have to pull the id from the session.

Lastly, add the css style for the selected div:

docshare-tutorial.css
1
2
3
.selected {
  background-color: yellow;
}

Conclusion: That’s it, done! 50 lines of HTML and 50 lines of Coffeescript for a very basic Google docs clone.

To test it out open two browsers and type!

Comments