DoneJS StealJS jQuery++ FuncUnit DocumentJS
6.0.1
5.33.2 4.3.0 3.14.1 2.3.35
  • About
  • Guides
  • API Docs
  • Community
  • Contributing
  • Bitovi
    • Bitovi.com
    • Blog
    • Design
    • Development
    • Training
    • Open Source
    • About
    • Contact Us
  • About
  • Guides
    • getting started
      • CRUD Guide
      • Setting Up CanJS
      • Technology Overview
    • topics
      • HTML
      • Routing
      • Service Layer
      • Debugging
      • Forms
      • Testing
      • Logic
      • Server-Side Rendering
    • app guides
      • Chat Guide
      • TodoMVC Guide
      • TodoMVC with StealJS
    • beginner recipes
      • Canvas Clock
      • Credit Card
      • File Navigator
      • Signup and Login
      • Video Player
    • intermediate recipes
      • CTA Bus Map
      • Multiple Modals
      • Text Editor
      • Tinder Carousel
    • advanced recipes
      • Credit Card
      • File Navigator
      • Playlist Editor
      • Search, List, Details
    • upgrade
      • Migrating to CanJS 3
      • Migrating to CanJS 4
      • Migrating to CanJS 5
      • Migrating to CanJS 6
      • Using Codemods
    • other
      • Reading the API Docs
  • API Docs
  • Community
  • Contributing
  • GitHub
  • Twitter
  • Chat
  • Forum
  • News
Bitovi

File Navigator

  • Edit on GitHub

This advanced guide walks you through building a file navigation widget that requests data with fetch. It takes about 45 minutes to complete.

Check out the File Navigator for an example that doesn't make data requests.

The final widget looks like:

See the Pen File Navigator advanced [Finished] by Bitovi (@bitovi) on CodePen.

Note: If you don’t see any files show up, run the CodePen again. This CodePen uses randomly generated files, so it’s possible nothing shows up.

START THIS TUTORIAL BY CLICKING THE “EDIT ON CODEPEN” BUTTON IN THE TOP RIGHT CORNER OF THE FOLLOWING EMBED:

See the Pen File Navigator Guide (Advanced) [Starter] by Bitovi (@bitovi) on CodePen.


This CodePen has initial prototype CSS and JS which is useful for getting the application to look right.

The following sections are broken down into:

  • Problem - A description of what the section is trying to accomplish.
  • Things to know - Information about CanJS that is useful for solving the problem.
  • Solution - The solution to the problem.

Build a service layer with fixtures

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:

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

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

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

Solution

Update the JavaScript tab to:

  • Make a function that generates an array of entities that will be stored on our fake server.

  • Make those entities, create a store to house them, and trap AJAX requests to use that store:

import { fixture } from "//unpkg.com/can@6/core.mjs";

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

// Returns an array of entities for the given `parentId`.
// Makes sure the `depth` of entities doesn’t exceed 5.
const makeEntities = function(parentId, depth) {
  if (depth > 5) {
    return [];
  }
  // The number of entities to create.
  const entitiesCount = fixture.rand(10);

  // The array of entities we will return.
  const entities = [];

  for (let i = 0; i < entitiesCount; i++) {
    // The id for this entity
    const id = "" + entityId++;

    // If the entity is a folder or file
    const isFolder = Math.random() > 0.3;

    // The children for this folder.
    const children = isFolder ? makeEntities(id, depth + 1) : [];

    const entity = {
      id: id,
      name: (isFolder ? "Folder" : "File") + " " + id,
      parentId: parentId,
      type: isFolder ? "folder" : "file",
      hasChildren: children.length > 0
    };
    entities.push(entity);

    // Add the children of a folder
    [].push.apply(entities, children);
  }
  return entities;
};

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

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

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

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

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-observable-object so we can do:

const 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 an ObservableObject type with the type’s properties and the properties’ types like:

import { ObservableObject } from "can";
class Type extends ObservableObject {
  static props = {
    id: String,
    hasChildren: Boolean
    // ...
  };
}

The solution

Extend ObservableObject with each property and its type as follows:

import { ObservableObject, fixture } from "//unpkg.com/can@6/core.mjs";

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

// Returns an array of entities for the given `parentId`.
// Makes sure the `depth` of entities doesn’t exceed 5.
const makeEntities = function(parentId, depth) {
  if (depth > 5) {
    return [];
  }
  // The number of entities to create.
  const entitiesCount = fixture.rand(10);

  // The array of entities we will return.
  const entities = [];

  for (let i = 0; i < entitiesCount; i++) {
    // The id for this entity
    const id = "" + entityId++;

    // If the entity is a folder or file
    const isFolder = Math.random() > 0.3;

    // The children for this folder.
    const children = isFolder ? makeEntities(id, depth + 1) : [];

    const entity = {
      id: id,
      name: (isFolder ? "Folder" : "File") + " " + id,
      parentId: parentId,
      type: isFolder ? "folder" : "file",
      hasChildren: children.length > 0
    };
    entities.push(entity);

    // Add the children of a folder
    [].push.apply(entities, children);
  }
  return entities;
};

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

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

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

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

class Entity extends ObservableObject {
  static props = {
    id: { type: String, identity: true },
    name: String,
    parentId: String,
    hasChildren: Boolean,
    type: String
  };
}

Connect the Entity model to the 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

restModel() can connect an ObjectType type to a url like:

restModel({
  ObjectType: Entity,
  url: "URL"
});

The solution

Use restModel to connect Entity to /api/entities like:

import {
  ObservableObject,
  fixture,
  restModel
} from "//unpkg.com/can@6/core.mjs";

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

// Returns an array of entities for the given `parentId`.
// Makes sure the `depth` of entities doesn’t exceed 5.
const makeEntities = function(parentId, depth) {
  if (depth > 5) {
    return [];
  }
  // The number of entities to create.
  const entitiesCount = fixture.rand(10);

  // The array of entities we will return.
  const entities = [];

  for (let i = 0; i < entitiesCount; i++) {
    // The id for this entity
    const id = "" + entityId++;

    // If the entity is a folder or file
    const isFolder = Math.random() > 0.3;

    // The children for this folder.
    const children = isFolder ? makeEntities(id, depth + 1) : [];

    const entity = {
      id: id,
      name: (isFolder ? "Folder" : "File") + " " + id,
      parentId: parentId,
      type: isFolder ? "folder" : "file",
      hasChildren: children.length > 0
    };
    entities.push(entity);

    // Add the children of a folder
    [].push.apply(entities, children);
  }
  return entities;
};

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

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

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

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

class Entity extends ObservableObject {
  static props = {
    id: { type: String, identity: true },
    name: String,
    parentId: String,
    hasChildren: Boolean,
    type: String
  };
}

Entity.connection = restModel({
  ObjectType: Entity,
  url: "/api/entities"
});

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 StacheElement uses can-stache to render data in a template and keep it live. Templates can be authored in view property like:

    import { StacheElement } from "can";
    
    class MyComponent extends StacheElement {
      static view = `TEMPLATE CONTENT`;
      static props = {};
    }
    
    customElements.define("my-component", MyComponent);
    

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

    import { StacheElement } from "can";
    
    class MyComponent extends StacheElement {
      static view = `{{something.name}}`;
      static props = {};
    }
    
    customElements.define("my-component", MyComponent);
    
  • Mount the component into the page with its custom tag:

    <my-component></my-component>
    
  • You can create an Entity instance as follows:

    const 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

Update the HTML tab to render the folder’s name.

<a-folder id="root"></a-folder>

Update the JavaScript tab to:

  1. Define a component with a-folder custom tag
  2. Write the component view template that displays the folder Entity name.
  3. Add the following properties to the component:
  • folder which references the folder being displayed.
  • entitiesPromise which will be a promise of all files for that folder.
  1. Set the component initial props values with the assign function
import {
  StacheElement,
  ObservableObject,
  fixture,
  restModel
} from "//unpkg.com/can@6/core.mjs";

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

// Returns an array of entities for the given `parentId`.
// Makes sure the `depth` of entities doesn’t exceed 5.
const makeEntities = function(parentId, depth) {
  if (depth > 5) {
    return [];
  }
  // The number of entities to create.
  const entitiesCount = fixture.rand(10);

  // The array of entities we will return.
  const entities = [];

  for (let i = 0; i < entitiesCount; i++) {
    // The id for this entity
    const id = "" + entityId++;

    // If the entity is a folder or file
    const isFolder = Math.random() > 0.3;

    // The children for this folder.
    const children = isFolder ? makeEntities(id, depth + 1) : [];

    const entity = {
      id: id,
      name: (isFolder ? "Folder" : "File") + " " + id,
      parentId: parentId,
      type: isFolder ? "folder" : "file",
      hasChildren: children.length > 0
    };
    entities.push(entity);

    // Add the children of a folder
    [].push.apply(entities, children);
  }
  return entities;
};

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

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

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

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

class Entity extends ObservableObject {
  static props = {
    id: { type: String, identity: true },
    name: String,
    parentId: String,
    hasChildren: Boolean,
    type: String
  };
}

Entity.connection = restModel({
  ObjectType: Entity,
  url: "/api/entities"
});

class AFolder extends StacheElement {
  static view = `
    <span>{{ this.folder.name }}</span>
  `;

  static props = {
    folder: Entity
  };
}

customElements.define("a-folder", AFolder);

root.assign({
  folder: new Entity({
    id: "0",
    name: "ROOT/",
    hasChildren: true,
    type: "folder"
  })
});

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 {{#for(of)}} 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: {{#for(item of 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 class attribute 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

Update the JavaScript tab to:

  • Add entitiesPromise property to the component. entitiesPromise will contain the files and folders that are directly within the root folder.

  • Use 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

import {
  Component,
  DefineMap,
  fixture,
  restModel
} from "//unpkg.com/can@5/core.mjs";

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

// Returns an array of entities for the given `parentId`.
// Makes sure the `depth` of entities doesn’t exceed 5.
const makeEntities = function(parentId, depth) {
  if (depth > 5) {
    return [];
  }
  // The number of entities to create.
  const entitiesCount = fixture.rand(10);

  // The array of entities we will return.
  const entities = [];

  for (let i = 0; i < entitiesCount; i++) {
    // The id for this entity
    const id = "" + entityId++;

    // If the entity is a folder or file
    const isFolder = Math.random() > 0.3;

    // The children for this folder.
    const children = isFolder ? makeEntities(id, depth + 1) : [];

    const entity = {
      id: id,
      name: (isFolder ? "Folder" : "File") + " " + id,
      parentId: parentId,
      type: isFolder ? "folder" : "file",
      hasChildren: children.length > 0
    };
    entities.push(entity);

    // Add the children of a folder
    [].push.apply(entities, children);
  }
  return entities;
};

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

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

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

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

class Entity extends ObservableObject {
  static props = {
    id: { type: String, identity: true },
    name: String,
    parentId: String,
    hasChildren: Boolean,
    type: String
  };
}

Entity.connection = restModel({
  ObjectType: Entity,
  url: "/api/entities"
});

class AFolder extends StacheElement {
  static view = `
    <span>{{ this.folder.name }}</span>
    {{# if(this.entitiesPromise.isPending) }}
      <div class="loading">Loading</div>
    {{ else }}
      <ul>
        {{# for(entity of this.entitiesPromise.value) }}
          <li class="{{entity.type}} {{# if(entity.hasChildren) }}hasChildren{{/ if }}">
            {{# eq(entity.type, 'file') }}
              📝 <span>{{ entity.name }}</span>
            {{ else }}
              📁 <span>{{ entity.name }}</span>
            {{/ eq }}
          </li>
        {{/ for }}
      </ul>
    {{/ if }}
  `;

  static props = {
    folder: Entity,
    get entitiesPromise() {
      if (this.folder) {
        return Entity.getList({ filter: { parentId: this.folder.id } });
      }
    }
  };
}

customElements.define("a-folder", AFolder);

root.assign({
  folder: new Entity({
    id: "0",
    name: "ROOT/",
    hasChildren: true,
    type: "folder"
  })
});

Manage <a-folder> custom element behavior

The problem

Now we want to make all the folders able to open and close.

Things to know

  • CanJS uses ObservableObject-like properties to manage the behavior of views. Components can have their own state, such as if a folder isOpen and should be showing its children.

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

    import { ObservableObject } from "can";
    class Address extends ObservableObject {
      static props = {
        street: String,
        city: String
      };
    }
    class Person extends ObservableObject {
      static props = {
        address: Address
      };
    }
    
  • ObservableObjects can also specify default values:

    class Person extends ObservableObject {
      static props = {
        address: Address
        age: { default: 33 }
      };
    }
    
  • ObservableObjects can also specify a default value and a type:

    class Person extends ObservableObject {
      static props = {
        address: Address
        age: { type: Number, default: 33 }
      };
    }
    
  • ObservableObjects can also have methods:

    class Person extends ObservableObject {
      static props = {
        address: Address
        age: { type: Number, default: 33 }
      };
      birthday() {
        this.age++;
      }
    }
    
  • Use on: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 on:click="doSomething()"> ... </div>
    

The solution

The following:

  1. Define component properties that will manage the UI state around a folder.:
  • isOpen which tracks if the folder’s children should be displayed.
  • toggleOpen which changes isOpen.
  1. Recursively renders each child folder with <a-folder folder:from="entity" />.
  2. Set the root folder isOpen property to true in the component mounting invocation (root.assign).
import {
  StacheElement,
  ObservableObject,
  fixture,
  restModel
} from "//unpkg.com/can@6/core.mjs";

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

// Returns an array of entities for the given `parentId`.
// Makes sure the `depth` of entities doesn’t exceed 5.
const makeEntities = function(parentId, depth) {
  if (depth > 5) {
    return [];
  }
  // The number of entities to create.
  const entitiesCount = fixture.rand(10);

  // The array of entities we will return.
  const entities = [];

  for (let i = 0; i < entitiesCount; i++) {
    // The id for this entity
    const id = "" + entityId++;

    // If the entity is a folder or file
    const isFolder = Math.random() > 0.3;

    // The children for this folder.
    const children = isFolder ? makeEntities(id, depth + 1) : [];

    const entity = {
      id: id,
      name: (isFolder ? "Folder" : "File") + " " + id,
      parentId: parentId,
      type: isFolder ? "folder" : "file",
      hasChildren: children.length > 0
    };
    entities.push(entity);

    // Add the children of a folder
    [].push.apply(entities, children);
  }
  return entities;
};

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

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

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

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

class Entity extends ObservableObject {
  static props = {
    id: { type: String, identity: true },
    name: String,
    parentId: String,
    hasChildren: Boolean,
    type: String
  };
}

Entity.connection = restModel({
  ObjectType: Entity,
  url: "/api/entities"
});

class AFolder extends StacheElement {
  static view = `
    <span on:click="this.toggleOpen()">{{ this.folder.name }}</span>
    {{# if(this.isOpen) }}
      {{# if(this.entitiesPromise.isPending) }}
        <div class="loading">Loading</div>
      {{ else }}
        <ul>
          {{# for(entity of this.entitiesPromise.value) }}
            <li class=" {{entity.type}}
                        {{# if(entity.hasChildren) }}hasChildren{{/ if }}">
              {{# eq(entity.type, 'file') }}
                📝 <span>{{ entity.name }}</span>
              {{ else }}
                📁 <a-folder folder:from="entity" />
              {{/ eq }}
            </li>
          {{/ for }}
        </ul>
      {{/ if }}
    {{/ if }}
  `;

  static props = {
    folder: Entity,
    isOpen: { type: Boolean, default: false },
    get entitiesPromise() {
      if (this.folder) {
        return Entity.getList({ filter: { parentId: this.folder.id } });
      }
    }
  };

  toggleOpen() {
    this.isOpen = !this.isOpen;
  }
}

customElements.define("a-folder", AFolder);

root.assign({
  isOpen: true,
  folder: new Entity({
    id: "0",
    name: "ROOT/",
    hasChildren: true,
    type: "folder"
  })
});

Result

When complete, you should have a working file-navigation widget like the following CodePen:

See the Pen File Navigator advanced [Finished] by Bitovi (@bitovi) on CodePen.

CanJS is part of DoneJS. Created and maintained by the core DoneJS team and Bitovi. Currently 6.0.1.

On this page

Get help

  • Chat with us
  • File an issue
  • Ask questions
  • Read latest news