Create a gist now

Instantly share code, notes, and snippets.

A walkthrough of how to build a file navigator widget with CanJS 3.5.

DoneJS Hack Night - File Navigator Advanced Track

This is a DoneJS Hack Night article that covers building a file navigation widget that looks like:

600_457801363

The full widget looks like: http://jsbin.com/qunuyi/edit?html,js,output

In this track, we will:

  1. Build a fixtured service layer for /api/entities which represents folders and files.
  2. Model an Entity in the client.
  3. Connect the Entity model to the fixtured service layer.
  4. Create the ROOT entity and render it.
  5. Render the ROOT entities children.
  6. Toggle children with a ViewModel.
  7. Create an <a-folder> custom element to manage folder behavior.

Start with this JSBin

1. Build a fixtured service layer

Problem

Make an /api/entities service layer that provides the files and folders for another folder. An entity can be either a file or folder. A single entity looks like:

{
  id: "2",
  name: "dogs",
  parentId: "0",     // The id of the folder this file or folder is within.
  type: "folder"     // or "file",
  hasChildren: true  // false for a folder with no children, or a file
}

To get the list of files and folders within a given folder, a GET request should be made as follows:

GET /api/entities?folderId=0

This should return the list of folders and files directly within that folder like:

{
  data: [
   { id: "7", name: "pekingese.png", parentId: "0", type: "file",   hasChildren: false },
   { id: "8", name: "poodles",       parentId: "0", type: "folder", hasChildren: false },
   { id: "9", name: "hounds",        parentId: "0", type: "folder", hasChildren: true }
  ]
}

The first level files and folders should have a parentId of "0".

Things to know

  • can-fixture - is used to trap AJAX requests like:

    can.fixture("/api/entities", function(request){
      request.data.folderId //-> "1"
      
      return {data: [...]}
    })
  • can-fixture.store - can be used to automatically filter records using the querystring.

    var entities = [ .... ];
    var entitiesStore = can.fixture.store( entities );
    can.fixture("/api/entities", entitiesStore);
  • can-fixture.rand - can be used to create a random integer.

    can.fixture.rand(10) //-> 10
    can.fixture.rand(10) //-> 0
    

Solution

First, make a function that generates an array of entities that will be stored on our fake server:

// Stores the next entity id to use. 
var entityId = 1;

// Returns an array of entities for the given `parentId`.
// Makes sure the `depth` of entities doesn't exceed 5.
var makeEntities = function(parentId, depth){
  if(depth > 5) {
    return [];
  }
  // The number of entities to create.
  var entitiesCount = can.fixture.rand(10);
  
  // The array of entities we will return.
  var entities = [];
  
  for(var i = 0 ;  i< entitiesCount; i++) {
    
    // The id for this entity
    var id = ""+(entityId++),
        // If the entity is a folder or file
        isFolder = Math.random() > 0.3,
        // The children for this folder.
        children = isFolder ? makeEntities(id, depth+1) : [];
    
    var entity = {
      id: id,
      name: (isFolder ? "Folder" : "File")+" "+id,
      parentId: parentId,
      type: (isFolder ? "folder" : "file"),
      hasChildren: children.length ? true : false
    };
    entities.push(entity);
    
    // Add the children of a folder
    [].push.apply(entities,  children)
    
  }
  return entities;
};

Then, make those entities, create a store to house them, and trap AJAX requests to use that store.

// Make the entities for the demo
var entities = makeEntities("0", 0);

// Add them to a client-like DB store
var entitiesStore = can.fixture.store(entities);

// Trap requests to /api/entities to read items from the entities store.
can.fixture("/api/entities", entitiesStore);

// Make requests to /api/entities take 1 second
can.fixture.delay = 1000;

2. Create the Entity Model

The problem

When we load entities from the server, it's useful to convert them into Entity type instances. We will want to create an observable Entity type using [can-define/map/map] so we can do:

var entity = new Entity({
  id: "2",
  name: "dogs",
  parentId: "0",     // The id of the folder this file or folder is within.
  type: "folder"     // or "file",
  hasChildren: true  // false for a folder with no children, or a file
});

entity.on("name", function(ev, newName){
  console.log("entity name changed to ", newName);
});

entity.name = "cats" //-> logs "entity name changed to cats"

Things to know

You can create a DefineMap type using DefineMap.extend with the type's properties and the properties' types like:

Type = can.DefineMap.extend({
  id: "string",
  hasChildren: "boolean",
  ...
})

The solution

Extend can.DefineMap with each property and its type as follows:

var Entity = can.DefineMap.extend({
  id: "string",
  name: "string",
  parentId: "string",
  hasChildren: "boolean",
  type: "string"
});

3. Connect the Entity model to the fixtured service layer.

The problem

We want to be able to load a list of Entity instances from GET /api/entities with:

Entity.getList({parentId: "0"}).then(function(entities){
    console.log(entities.get()) //-> [ Entity{id: "1", parentId: "0", ...}, ...]
})

Things to know

can.connect.baseMap() can connect a Map type to a url like:

can.connect.baseMap({
  Map: Entity,
  url: "URL"
})

The solution

Use can.connect.baseMap to connect Entity to /api/entities like:

can.connect.baseMap({
  Map: Entity
  url: "/api/entities"
})

4. Create the ROOT entity and render it.

The problem

We need to begin converting the static HTML the designer gave us into live HTML. This means rendering it in a template. We'll start slow by rendering the root parent folder's name in the same way it's expected by the designer.

Things to know

  • CanJS uses can-stache to render data in a template and keep it live. Templates can be authored in <script> tags like:

    <script type="text/stache" id="app-template">
      TEMPLATE CONTENT
    </script>

    A can-stache template uses {{key}} magic tags to insert data into the HTML output like:

    <script type="text/stache" id="app-template">
      {{something.name}}
    </script>
  • Load a template from a <script> tag with can.stache.from like:

    var template = can.stache.from(SCRIPT_ID);
  • Render the template with data into a documentFragment like:

    var frag = template({
      something: {name: "Derek Brunson"}
    });
  • Insert a fragment into the page with:

    document.body.appendChild(frag);
    
  • You can create an Entity instance as follows:

    var folder = new Entity({...});

    Where {...} is an object of the properties you need to create like {id: "0", name: "ROOT", ...}. Pass this to the template.

The solution

The following renders the folder's name.

<script type="text/stache" id="app-template">
<span>{{folder.name}}</span>
</script>

The following:

  1. Creates a folder Entity instance.
  2. Loads the app-template. Renders it with folder instance, and inserts the result in the <body> element.
var folder = new Entity({
  id: "0",
  name: "ROOT/", 
  hasChildren: true,
  type: "folder"
});

var template = can.stache.from("app-template"),
    frag = template({
      folder: folder
    });
    
document.body.appendChild( frag );

5. Render the ROOT entities children

The problem

In this section, we'll list the files and folders within the root folder.

Things to know

  • Use {{#if value}} to do if/else branching in can-stache.
  • Use {{#each value}} to do looping in can-stache.
  • Use {{#eq value1 value2}} to test equality in can-stache.
  • Promises are observable in can-stache. Given a promise somePromise, you can:
    • Check if the promise is loading like: {{#if somePromise.isPending}}.
    • Loop through the resolved value of the promise like: {{#each somePromise.value}}.
  • Write <div class="loading">Loading</div> when files are loading.
  • Write a <ul> to contain all the files. Within the <ul> there should be:
    • An <li> with a className that includes file or folder and hasChildren if the folder has children.
    • The <li> should have πŸ“ <span>{{FILE_NAME}}</span> if a file and πŸ“ <span>{{FOLDER_NAME}}</span> if a folder.

The solution

The following uses entitiesPromise to write <div class="loading">Loading</div> while the promise is pending, and then writes out an <li> for each entity in the resolved entitiesPromise:

<script type="text/stache" id="app-template">
<span>{{folder.name}}</span>
{{#if entitiesPromise.isPending}}
  <div class="loading">Loading</div>
{{else}}
  <ul>
    {{#each entitiesPromise.value}}
      <li class="{{type}} {{#if hasChildren}}hasChildren{{/if}}">
        {{#eq type 'file'}}
          πŸ“ <span>{{name}}</span>
        {{else}}
          πŸ“ <span>{{name}}</span>
        {{/eq}}
      </li>
    {{/each}}
  </ul>
{{/if}}
</script>

The following adds an entitiesPromise to data passed to the template. entitiesPromise will contain the files and folders that are directly within the root folder.

    frag = template({
      folder: folder,
      entitiesPromise: Entity.getList({parentId: "0"})
    });

6. Toggle children with a ViewModel.

The problem

We want to hide the root folder's children until the root folder is clicked on. An subsequent clicks on the root folder's name should toggle if the children are displayed.

Things to know

  • CanJS uses ViewModels to manage the behavior of views. ViewModels can have their own state, such as if a folder isOpen and should be showing its children. ViewModels are custructor functions created with can.DefineMap.

  • can.DefineMap can detail the type of a property with another type like:

    var Address = can.DefineMap.extend({
      street: "string",
      city: "string"
    });
    var Person = can.DefineMap.extend({
      address: Address
    });
  • can.DefineMap can also specify default values:

    var Person = can.DefineMap.extend({
      address: Address,
      age: {value: 33}
    });
  • can.DefineMap can also specify a default value and a type:

    var Person = can.DefineMap.extend({
      address: Address,
      age: {value: 33, type: "number"}
    });
  • can.DefineMap can also have methods:

    var Person = can.DefineMap.extend({
      address: Address,
      age: {value: 33, type: "number"},
      birthday: function(){
        this.age++;
      }
    });
  • Use ($EVENT) to listen to an event on an element and call a method in can-stache. For example, the following calls doSomething() when the <div> is clicked.

    <div ($click)="doSomething()"> ... </div>

The solution

The following:

  • Defines a FolderVM type that will manage the UI state around a folder. Specifically FolderVM has:
    • folder which references the folder being displayed.
    • entitiesPromise which will be a promise of all files for that folder.
    • isOpen which tracks if the folder's children should be displayed.
    • toggleOpen which changes isOpen.
  • Creates an instance of the FolderVM and uses it to render the template.
var FolderVM = can.DefineMap.extend({                    // ADDED
  folder: Entity,
  entitiesPromise: {
    value: function(){
      return Entity.getList({parentId: this.folder.id});
    }
  },
  isOpen: {type: "boolean", value: false},
  toggleOpen: function(){
    this.isOpen = !this.isOpen;
  }
});

// Create an instance of `FolderVM` with the root folder
var rootFolderVM = new FolderVM({                     // ADDED
  folder: folder
});

var template = can.stache.from("app-template"),
    frag = template(rootFolderVM);                   // CHANGED
    
document.body.appendChild( frag );

The following wraps the listing of child entities with a {{#if isOpen}} {{/if}}:

<script type="text/stache" id="app-template">
<span ($click)="toggleOpen()">{{folder.name}}</span>
{{#if isOpen}}                                         <!-- ADDED -->
  {{#if entitiesPromise.isPending}}
    <div class="loading">Loading</div>
  {{else}}
    <ul>
      {{#each entitiesPromise.value}}
        <li class="{{type}} {{#if hasChildren}}hasChildren{{/if}}">
          {{#eq type 'file'}}
            πŸ“ <span>{{name}}</span>
          {{else}}
            πŸ“ <span>{{name}}</span>
          {{/eq}}
        </li>
      {{/each}}
    </ul>
  {{/if}}
{{/if}}                                                <!-- ADDED -->
</script>

7. Create an <a-folder> custom element to manage folder behavior.

The problem

Now we want to make all the folders able to open and close. This means creating a FolderVM for every folder entity.

Things to know

  • can.Component is used to create custom elements like:

    var MyComponentVM = DefineMap.extend({
      message: {value: "Hello There!"}
    });
    
    can.Component.extend({
      tag: "my-component",
      ViewModel: MyComponentVM,
      view: can.stache("<h1>{{message}}</h1>");
    });

    This component will be created anytime a <my-component> element is found in the page. When the component is created, it creates an instance of it's ViewModel, in this case MyComponentVM.

  • You can pass data to a component's ViewModel with {data-bindings} like:

    <my-component {message}="'Hi There'"/>

    This sets message on the ViewModel to 'Hi There'. You can also send data within stache like:

    <my-component {message}="greeting"/>

    This sets message on the ViewModel to what greeting is in the stache template.

  • A component's [View] is rendered inside the component. This means that if the following is in a template:

    <my-component {message}="'Hi There'"/>
    

    The following will be inserted into the page:

    <my-component {message}="'Hi There'"><h1>Hi There</h1></my-component>
    
  • this in a stache template refers to the current context of a template or section.

    For example, the this in this.name refers to the context object:

    var template = stache("{{this.name}}");
    var context = {name: "Justin"};
    template(context);

    Or, when looping through a list of items, this refers to each item:

    {{#each items}}
      <li>{{this.name}}</li> <!-- this is each item in items -->
    {{/each}}

The solution

The following:

  1. Changes the app-template to use the <a-folder> component to render the root folder. It passes the root folder as folder to the <a-folder> component's ViewModel. It also sets the <a-folder> component's ViewModel's isOpen property to true.
  2. Moves the content that was in app-template to the folder-template <script> tag.
  3. Recursively renders each child folder with <a-folder {folder}="this"/>.
<script type="text/stache" id="app-template">
  <a-folder {folder}="this" {is-open}="true"/>        <!-- CHANGED -->
</script>

<!-- CONTENT FROM app-template-->
<script type="text/stache" id="folder-template">
<span ($click)="toggleOpen()">{{folder.name}}</span>
{{#if isOpen}}
  {{#if entitiesPromise.isPending}}
    <div class="loading">Loading</div>
  {{else}}
    <ul>
      {{#each entitiesPromise.value}}
        <li class="{{type}} {{#if hasChildren}}hasChildren{{/if}}">
          {{#eq type 'file'}}
            πŸ“ <span>{{name}}</span>
          {{else}}
            πŸ“ <a-folder {folder}="this"/>            <!-- CHANGED -->
          {{/eq}}
        </li>
      {{/each}}
    </ul>
  {{/if}}
{{/if}}
</script>

The following:

  1. Defines a custom <a-folder> element that manages its behavior with FolderVM and uses it to render a folder-template template.
  2. Renders the app-template with the root parent folder instead of the rootFolderVM.
can.Component.extend({                       // ADDED
  tag: "a-folder",
  ViewModel: FolderVM,
  view: can.stache.from("folder-template")
});

/*var rootFolderVM = new FolderVM({          // REMOVED
  folder: folder
});*/

var template = can.stache.from("app-template"),
    frag = template(folder);                   // CHANGED

document.body.appendChild( frag );
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment