File Navigator Guide (Advanced)
This 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 Guide (Simple) for an example that doesn't make data requests.
The final widget looks like:
Finished version of the CanJS File Navigator Guide (Advanced) on jsbin.com Open in JSFiddle
Note: If you don’t see any files show up, run the JS Bin again. This JS Bin uses randomly generated files, so it’s possible nothing shows up.
Start this tutorial by cloning the following JS Bin:
Starter version of the CanJS File Navigator Guide (Advanced) on jsbin.com Open in JSFiddle
This JS Bin has initial prototype HTML and CSS 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.
Watch a video of us building this recipe here:
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:
can.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 = can.fixture.store( entities ); can.fixture("/api/entities", entitiesStore);
rand can be used to create a random integer:
can.fixture.rand(10) //-> 10 can.fixture.rand(10) //-> 0
Solution
First, let’s make a function that generates an array of entities
that will be
stored on our fake server. Update the JavaScript tab to:
// 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 = can.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;
};
Next, let’s make those entities, create a store
to house them, and trap AJAX
requests to use that store
:
// 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 = can.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 = 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;
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:
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 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:
// 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 = can.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 = 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;
const Entity = can.DefineMap.extend({
id: "string",
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
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:
// 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 = can.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 = 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;
const Entity = can.DefineMap.extend({
id: "string",
name: "string",
parentId: "string",
hasChildren: "boolean",
type: "string"
});
Entity.List = can.DefineList.extend({});
can.connect.baseMap({
Map: 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 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:const template = can.stache.from(SCRIPT_ID);
Render the template with data into a documentFragment like:
const fragment = template({ something: {name: "Derek Brunson"} });
Insert a fragment into the page with:
document.body.appendChild(fragment);
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.
<script type="text/stache" id="app-template">
<span>{{folder.name}}</span>
</script>
Update the JavaScript tab to:
- Create a
folder
Entity
instance. - Load the
app-template
. Renders it withfolder
instance, and inserts the result in the<body>
element.
// 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 = can.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 = 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;
const Entity = can.DefineMap.extend({
id: "string",
name: "string",
parentId: "string",
hasChildren: "boolean",
type: "string"
});
Entity.List = can.DefineList.extend({});
can.connect.baseMap({
Map: Entity,
url: "/api/entities"
});
const folder = new Entity({
id: "0",
name: "ROOT/",
hasChildren: true,
type: "folder"
});
const template = can.stache.from("app-template");
const fragment = template({
folder: folder
});
document.body.appendChild( fragment );
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 incan-stache
. - Use {{#each(value)}} to do looping in
can-stache
. - Use {{#eq(value1, value2)}} to test equality in
can-stache
. Promise
s are observable incan-stache
. Given a promisesomePromise
, you can:- Check if the promise is loading like:
{{#if(somePromise.isPending)}}
. - Loop through the resolved value of the promise like:
{{#each(somePromise.value)}}
.
- Check if the promise is loading like:
- 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 includesfile
orfolder
andhasChildren
if the folder has children. - The
<li>
should have📝 <span>{{FILE_NAME}}</span>
if a file and📁 <span>{{FOLDER_NAME}}</span>
if a folder.
- An
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.
// 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 = can.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 = 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;
const Entity = can.DefineMap.extend({
id: "string",
name: "string",
parentId: "string",
hasChildren: "boolean",
type: "string"
});
Entity.List = can.DefineList.extend({});
can.connect.baseMap({
Map: Entity,
url: "/api/entities"
});
const folder = new Entity({
id: "0",
name: "ROOT/",
hasChildren: true,
type: "folder"
});
const template = can.stache.from("app-template");
const fragment = template({
entitiesPromise: Entity.getList({parentId: "0"}),
folder: folder
});
document.body.appendChild( fragment );
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 [guides/technicalViewModels#MaintainableMVVM 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 constructor functions created with can.DefineMap.can.DefineMap
can detail the type of a property with another type like:const Address = can.DefineMap.extend({ street: "string", city: "string" }); const Person = can.DefineMap.extend({ address: Address });
can.DefineMap
can also specify default values:const Person = can.DefineMap.extend({ address: Address, age: {default: 33} });
can.DefineMap
can also specify a default value and a type:const Person = can.DefineMap.extend({ address: Address, age: {default: 33, type: "number"} });
can.DefineMap
can also have methods:const Person = can.DefineMap.extend({ address: Address, age: {default: 33, type: "number"}, birthday: function() { this.age++; } });
Use on:event to listen to an event on an element and call a method in
can-stache
. For example, the following callsdoSomething()
when the<div>
is clicked.<div on:click="doSomething()"> ... </div>
The solution
The following:
- Defines a
FolderVM
type that will manage the UI state around a folder. SpecificallyFolderVM
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 changesisOpen
.
- Creates an instance of the
FolderVM
and uses it to render the template.
// 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 = can.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 = 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;
const Entity = can.DefineMap.extend({
id: "string",
name: "string",
parentId: "string",
hasChildren: "boolean",
type: "string"
});
Entity.List = can.DefineList.extend({});
can.connect.baseMap({
Map: Entity,
url: "/api/entities"
});
const folder = new Entity({
id: "0",
name: "ROOT/",
hasChildren: true,
type: "folder"
});
const FolderVM = can.DefineMap.extend({
folder: Entity,
entitiesPromise: {
default: function() {
return Entity.getList({parentId: this.folder.id});
}
},
isOpen: {type: "boolean", default: false},
toggleOpen: function() {
this.isOpen = !this.isOpen;
}
});
// Create an instance of `FolderVM` with the root folder
const rootFolderVM = new FolderVM({
folder: folder
});
const template = can.stache.from("app-template");
const fragment = template(rootFolderVM);
document.body.appendChild( fragment );
The following wraps the listing of child entities with a {{#if(isOpen)}} {{/if}}
:
<script type="text/stache" id="app-template">
<span on: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}}
📁 <span>{{name}}</span>
{{/eq}}
</li>
{{/each}}
</ul>
{{/if}}
{{/if}}
</script>
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:
const MyComponentVM = DefineMap.extend({ message: {default: "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’sViewModel
, in this caseMyComponentVM
.You can pass data to a component’s
ViewModel
with {data-bindings} like:<my-component message:from="'Hi There'" />
This sets
message
on the ViewModel to'Hi There'
. You can also send data within stache like:<my-component message:from="greeting" />
This sets
message
on the ViewModel to whatgreeting
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
inthis.name
refers to thecontext
object:const template = stache("{{this.name}}"); const 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:
- Changes the
app-template
to use the<a-folder>
component to render the root folder. It passes the root folder asfolder
to the<a-folder>
component’s ViewModel. It also sets the<a-folder>
component’s ViewModel’sisOpen
property totrue
. - Moves the content that was in
app-template
to thefolder-template
<script>
tag. - Recursively renders each child folder with
<a-folder {folder}="this" />
.
<script type="text/stache" id="app-template">
<a-folder folder:from="this" isOpen:from="true" /> <!-- CHANGED -->
</script>
<!-- CONTENT FROM app-template-->
<script type="text/stache" id="folder-template">
<span on: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:from="this" /> <!-- CHANGED -->
{{/eq}}
</li>
{{/each}}
</ul>
{{/if}}
{{/if}}
</script>
The following:
- Defines a custom
<a-folder>
element that manages its behavior withFolderVM
and uses it to render afolder-template
template. - Renders the
app-template
with the rootparent
folder instead of therootFolderVM
.
// 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 = can.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 = 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;
const Entity = can.DefineMap.extend({
id: "string",
name: "string",
parentId: "string",
hasChildren: "boolean",
type: "string"
});
Entity.List = can.DefineList.extend({});
can.connect.baseMap({
Map: Entity,
url: "/api/entities"
});
const folder = new Entity({
id: "0",
name: "ROOT/",
hasChildren: true,
type: "folder"
});
const FolderVM = can.DefineMap.extend({
folder: Entity,
entitiesPromise: {
default: function() {
return Entity.getList({parentId: this.folder.id});
}
},
isOpen: {type: "boolean", default: false},
toggleOpen: function() {
this.isOpen = !this.isOpen;
}
});
can.Component.extend({
tag: "a-folder",
ViewModel: FolderVM,
view: can.stache.from("folder-template")
});
const template = can.stache.from("app-template");
const fragment = template(folder);
document.body.appendChild( fragment );
Result
When complete, you should have a working file-navigation widget like the following JS Bin:
Finished version of the CanJS File Navigator Guide (Advanced) on jsbin.com Open in JSFiddle