This tutorial will teach you how to make a simple OfficeJS application from scratch, using only RenderJS and jIO.
To make the most of this tutorial, please take some time to brush up on your JavaScript basics, especially on promises.
renderjs.js
, jio.js
, and rsvp.js
respectively.index.html
.
<!doctype html>
<html>
<head>
<title>OfficeJS App</title>
<script src="rsvp.js"></script>
<script src="renderjs.js"></script>
<script src="jio.js"></script>
<script src="index.js"></script>
<link href="index.css" rel="stylesheet">
</head>
<body>
<h1>OfficeJS Application</h1>
<p></p>
</body>
</html>
index.js
, which contains all the logic for the gadget. It should be the last script, since it will depend on RenderJS and jIO.index.css
and index.js
, respectively. The stylesheet is only here to demonstrate that gadgets are just ordinary HTML files, which can also be styled with CSS.
h1 {
color: red;
}
(function (window, rJS) {
rJS(window)
.declareService(function () {
this.element.querySelector("p").textContent = "Hello, world!";
})
}(window, rJS));
window
and rJS
are always required for a RenderJS app…rJS(window)
is the RenderJS gadget itself, which must always be at the start of every gadget chain.declareService()
handler is triggered when the gadget is loaded in the DOM.this
is rJS(window)
, which is the gadget itself. The DOM element on which the gadget is anchored is automatically stored as this.element
. For example, if you have <div data-gadget-url="index.html"></div>
in another page, then this.element
would be that div
.this.element
, is actually the whole <body>
. You can modify this.element
however you wish, for example by using querySelector()
.rJS(window).declareService().declareMethod().onEvent().onEvent()
and so on. This gadget chain may look strange at first, but you'll quickly get used to it.})
—in such a method, so remember to do so even after a function statement.You've just made your first OfficeJS app!
Open index.html
in a web browser to see it.
<p>
is empty in the HTML source, but actually dynamically filled up with content due to RenderJS.declareService()
handler triggered immediately when the gadget was loaded in the DOM.querySelector()
on this.element
, which is <body>
for the index gadget, to insert text into the paragraph tag.index.js
is included.<body>
in index.html
to give us some new elements to play around with:
<form>
<input type="text">
</form>
<ul>
</ul>
index.js
with the following code. Remember to always read all the details throughout the entire tutorial.
(function (window, document, rJS) {
rJS(window)
.declareService(function () {
this.element.querySelector("p").textContent = "Hello, world!";
})
.declareMethod("addItem", function (item) {
var list_item = document.createElement("LI");
list_item.appendChild(document.createTextNode(item));
this.element.querySelector("ul").appendChild(list_item);
})
.onEvent("submit", function (event) {
var item = event.target.elements[0].value;
event.target.elements[0].value = "";
this.addItem(item);
}, false, true);
}(window, document, rJS));
document
in the local scope of the IIFE, since lines 8 and 9 reference it. This makes accessing global variables a tiny bit faster and helps you remember that they're actually global variables.addItem()
, which takes as input a string called item
and appends it to the list.submit
, in pretty much exactly the same way as this.element.addEventListener()
.event.target
is the <form>
, which has only one child.onEvent()
are useCapture
and preventDefault
respectively, the same as in addEventListener()
.index.html
, type something in the input, and submit the form by pressing Enter or Return. Watch the list grow.rJS(window).declareService().declareMethod().declareMethod().onEvent();
.onEvent()
method at the front, middle, or back:rJS(window).onEvent().declareService().declareMethod().declareMethod().onEvent();
rJS(window).declareService().declareMethod().onEvent().declareMethod().onEvent();
rJS(window).declareService().declareMethod().declareMethod().onEvent().onEvent();
index.js
wherever you like:
.onEvent("click", function (event) {
this.element.querySelector("p").textContent =
"You have just clicked on a " + event.target.tagName + " tag.";
}, false, true)
onEvent()
is the same as this.element.addEventListener()
, and this.element
of the index gadget is <body>
.onEvent()
callbacks also trigger in their own element
, which is usually their own <div>
tag.index.js
; again, order does not matter.
.setState({
item_list: [],
current_item: null
})
.onStateChange(function (modification_dict) {
if (modification_dict.hasOwnProperty("current_item")) {
this.addItem(modification_dict.current_item);
}
})
{item_list: [], current_item: null}
.declareService()
. The state itself is just a JavaScript object, state
, and can store anything.changeState()
. Calling changeState()
will immediately trigger onStateChange()
.onStateChange()
is not triggered if you manually change the state with state.key = value
.changeState()
.onStateChange()
is a JavaScript object that contains all the properties in the state that have changed due to changeState()
. Only reassignments, like current_item = item
, count as changes; mutations of objects, like item_list.push()
, do not.addItem()
if someone changes state.current_item
using changeState()
.submit
event listener, replace this.addItem(item);
with this.changeState({current_item: item});
and refresh your app.state.current_item
does not change, so there is nothing in modification_dict
and it does not call addItem()
.onStateChange()
is called if, and only if, changeState()
is called and something in the state is reassigned.this.state.item_list.push(item);
anywhere inside addItem()
.submit
event listener:
this.element.querySelector("p").textContent =
"You just added the new item, " + this.state.current_item
+ ". You have added " + this.state.item_list.length + " item(s).";
changeState()
is only being run after the text content is already set…changeState()
did not include item_list
, you can still access item_list.length
after, because changeState()
does not overwrite the state, like with state = new_state
.changeState()
only overwrites the properties that it is given, like with state.current_item = new_state.current_item
.key: undefined
with it..then()
to chain promises, Nexedi's custom version of RSVP.js adds a RSVP.Queue()
.new RSVP.Queue().push(onFulfilled, onRejected).push(onFulfilled, onRejected)
and so on.addItem()
.changeState()
returns a promise queue too. To fix that bug, you'll be exploring all about promise queues in this chapter.this.element.querySelector("p").textContent
is being set before the promise queue returned by this.changeState({current_item: item});
is resolved.then()
.push()
.submit
event listener with an asynchronous promise queue.index.js
, add RSVP
to the IIFE (the first and last lines).submit
event listener with the following JavaScript:
.onEvent("submit", function (event) {
var item = event.target.elements[0].value;
event.target.elements[0].value = "";
new RSVP.Queue()
.push(function () {
this.changeState({current_item: item});
})
.push(function () {
this.element.querySelector("p").textContent =
"You just added the new item, " + this.state.current_item
+ ". You have added " + this.state.item_list.length + " item(s).";
});
}, false, true)
<p>
are only set after the state has changed.push()
is analogous to a then()
, perhaps the scope also changes in a promise queue, and this this
is no longer this
.this
inside and outside the queue and you'll find that they are indeed different.this
to something arbitrary—the RenderJS convention is gadget
—and assign var gadget = this;
outside the queue.changeState()
and then moves on, without actually waiting for it to be resolved.return
a promise in a promise queue to wait for that promise to be resolved.return
before gadget.changeState()
.submit
function should too. In fact, all your gadget methods should have return new RSVP.Queue()
at the end.changeState()
does too, something like return new RSVP.Queue().push().push()
.changeState()
already gives you a new RSVP.Queue()
when you call it, you can directly chain promises onto it, with return gadget.changeState().push().push()
and so on.return new RSVP.Queue().push(function () { return gadget.changeState(); }).push().push()
and so on.index.js
is in the details.
/*jslint nomen: true, indent: 2, maxerr: 3, maxlen: 80*/
/*global window, document, rJS*/
(function (window, document, RSVP, rJS) {
"use strict";
rJS(window)
.setState({
item_list: [],
current_item: null
})
.declareService(function () {
this.element.querySelector("p").textContent = "Hello, world!";
})
.onStateChange(function (modification_dict) {
if (modification_dict.hasOwnProperty("current_item")) {
this.addItem(modification_dict.current_item);
}
})
.declareMethod("addItem", function (item) {
var list_item = document.createElement("LI");
list_item.appendChild(document.createTextNode(item));
this.element.querySelector("ul").appendChild(list_item);
this.state.item_list.push(item);
})
.onEvent("submit", function (event) {
var gadget = this,
item = event.target.elements[0].value;
event.target.elements[0].value = "";
return gadget.changeState({current_item: item})
.push(function () {
gadget.element.querySelector("p").textContent =
"You just added the new item, " + gadget.state.current_item
+ ". You have added " + gadget.state.item_list.length + " item(s).";
});
}, false, true)
.onEvent("click", function (event) {
this.element.querySelector("p").textContent =
"You have just clicked on a " + event.target.tagName + " tag.";
}, false, true);
}(window, document, RSVP, rJS));
You now have a deeper understanding of the importance of using asynchronous promise queues everywhere in RenderJS.
Also, notice the directives for JSLint, which is used by all Nexedi developers. We also recommend that you use some code linter.
gadget_model.html
:
<!doctype html>
<html>
<head>
<title>Model Gadget</title>
<script src="rsvp.js"></script>
<script src="renderjs.js"></script>
<script src="jio.js"></script>
<script src="gadget_model.js"></script>
</head>
<body>
</body>
</html>
(function (window, rJS) {
rJS(window)
.declareService(function () {
console.log("Hello, world!");
})
}(window, rJS));
index.html
, this gadget has a different name, no stylesheet, and no body content, since a model gadget only needs to model information, and doesn't need to display anything.gadget_model.js
as well:<div>
with data-gadget-url
set to the URL of the gadget.data-gadget-scope
is a unique identifier for the declared gadget.data-gadget-sandbox
is public
, then the gadget is inserted in the DOM, so that everything in its <body>
is copied into that <div>
.data-gadget-sandbox
is iframe
, then it is put in an inline frame.gadget_model.html
, you can add the following snippet anywhere in the body of index.html
:
<div data-gadget-url="gadget_model.html"
data-gadget-scope="model"
data-gadget-sandbox="public">
</div>
index.html
as usual.XMLHttpRequest
s to load other gadgets like gadget_model.html
. However, modern web browsers block these sorts of requests on the local filesystem for security.index.html
and your app.python -m SimpleHTTPServer
with Python 2 or python -m http.server
with Python 3.gadget_model.js
with the following. Remember to check the details.
/*jslint nomen: true, indent: 2, maxerr: 3, maxlen: 80*/
/*global window, RSVP, rJS, jIO*/
(function (window, RSVP, rJS, jIO) {
"use strict";
rJS(window)
.declareService(function () {
return this.changeState({storage: jIO.createJIO({
type: "indexeddb",
database: "todos-renderjs"
})});
})
.declareMethod("put", function () {
return this.state.storage.put.apply(storage, arguments);
})
.declareMethod("get", function () {
return this.state.storage.get.apply(storage, arguments);
})
.declareMethod("allDocs", function () {
return this.state.storage.allDocs.apply(storage, arguments);
});
}(window, RSVP, rJS, jIO));
this.state.storage
.createJIO
is synchronous, so you can directly use it in the modification dict.put()
, which stores the given document of the given ID in the jIO storage, as put(id, document)
.declareService()
in index.js
with the following:
.declareService(function () {
var model_gadget;
return this.getDeclaredGadget("model")
.push(function (subgadget) {
model_gadget = subgadget;
return model_gadget.put("/", {
title: "Test",
completed: false
});
});
})
data-gadget-scope
? This is where it is used. To access the methods and state of a subgadget, the parent gadget can call getDeclaredGadget()
and pass in the child gadget's scope.getDeclaredGadget()
returns the child gadget itself. Just like in a promise chain, the resolved value of the previous promise is passed into the parameters of the next promise in a promise queue. Putting this together, the result is that anything the model gadget can do with this
, the parent gadget can now do with model_gadget
!getDeclaredGadget()
every time you need to use a child gadget, you can just assign the result of one call to model_gadget
in line 2, outside the promise queue, to use it everywhere insidemodel_gadget.put()
here is exactly the same as calling this.put()
inside model_gadget.js
.{title: "Test", completed: false}
inside the jIO storage with a hard-coded ID of "/"
.createJIO()
instead:
.declareService(function () {
return this.changeState({storage: jIO.createJIO({
type: "document",
document_id: "/",
sub_storage: {
type: "local"
}
})});
})
"/"
, which represents the local storage of your browser.put()
to put more documents in it.put()
directly on the DocumentStorage, which automatically puts an attachment on the document with ID document_id
in its sub_storage
, the LocalStorage.put()
in some actual user-generated data.onStateChange()
in index.js
with the following:
.onStateChange(function (modification_dict) {
if (modification_dict.hasOwnProperty("current_item")) {
this.addItem(modification_dict.current_item);
return this.getDeclaredGadget("model")
.push(function (model_gadget) {
return model_gadget.put(gadget.state.item_list.length.toString(), {
title: modification_dict.current_item,
completed: false
});
});
}
})
declareService()
.item_list
to give each new item a unique string ID, right?item_list
s in different tabs.get()
, which returns the document of the given ID on the jIO storage. For example, append this to the promise queue returned in declareService()
:
.push(function () {
return model_gadget.get("/");
})
.push(function (result) {
console.log(result);
});
put()
into "/"
on the console, {title: "Test", completed: false}
, showing again that jIO works.declareService()
.declareService()
, so you can't get()
them.0
, so some of you may have the bright idea of storing the current length of item_list
length with a hard-coded ID, retrieving it, and then calling get()
on all numbers up to that length.index.js
, delete the put()
, get()
, and console.log()
, then append the following instead:
.push(function () {
return model_gadget.allDocs();
})
.push(function (result_list) {
var promise_list = [], i;
for (i = 0; i < result_list.data.total_rows; i += 1) {
promise_list.push(model_gadget.get(result_list.data.rows[i].id));
}
return RSVP.all(promise_list);
})
.push(function (result_list) {
var i;
for (i = 0; i < result_list.length; i += 1) {
gadget.addItem(result_list[i].title);
}
});
allDocs
is self-explanatory: it returns a list of every ID.allDocs()
returns a curious object: {data: {rows: [doc, doc, ..., doc], total_rows: 42}}
, where each doc
contains a key, id
, for its ID.get()
, but all jIO methods, except for createJIO()
, are asynchronous.promise_list
is the list that stores all the promises you need to wait for.get()
returns a promise queue, you can call it for every document in the jIO storage to get them all, then push the resulting unresolved queues into promise_list
.return RSVP.all()
.RSVP.all()
returns a list containing, for each promise, its resolved value.RSVP.all()
throws the first rejection.get()
is simply a list of documents, whose title
s can now be added to the list.setState()
, ready()
, declareService()
, declareJob()
, and onEvent()
.ready()
and declareService()
.declareService()
in gadget_model.js
with the following, which introduces a ten second delay before creating the jIO storage.
.declareService(function () {
var gadget = this;
return new RSVP.Queue()
.push(function () {
return RSVP.delay(10000);
})
.push(function () {
return gadget.changeState({storage: jIO.createJIO({
type: "document",
document_id: "/",
sub_storage: {
type: "local"
}
})});
});
})
declareService()
in index.js
reaches model_gadget.allDocs()
before declareService()
in gadget_model.js
reaches jIO.createJIO()
, then there is no storage to allDocs()
from!ready()
handler is for. Replace declareService
in gadget_model.js
with ready()
, and the error disappears. After ten seconds, your todo list will also load correctly.ready()
handler blocks everything else.ready()
handler must wait until all the code in ready()
is executed—for a promise queue, that means that the last promise in the queue is resolved—before doing anything else, which ensures that the gadget is "ready" for the rest of its functions.declareService()
, onEvent()
, and everything else only runs after ready()
, so no event listeners are declared while ready()
is running./?
because there are no event listeners on the input, so it devolves into acting as a basic HTML form.ready()
handler must also wait, which ensures that the subgadget is "ready" for anything the parent wants to do. This has some interesting implications.ready()
blocks itself and its parent, and its parent blocks its parent's parent.ready()
handlers to finish executing before doing anything else.ready()
makes your code synchronous, so here are some guidelines:ready()
when you really need to wait. For example, creating the jIO storage must be done first, because other functions depend on it. Something like synchronizing storages is not as critical.ready()
handler. You can have many of them, but they just execute one after another in the order that they're declared, which is the sole exception where RenderJS method order actually matters. Having only one ready()
will get rid of all this confusion and make you think carefully about the necessity of every line you put in.ready()
. It is tempting to call a manual gadget initialization method, such as render()
, but ready()
blocks, so all your parent gadgets must now wait even longer. Try to initialize in declareService()
handlers as much as possible.ready()
, and minimizing the code you put in merely decreases the synchronous delay for other gadgets.ready()
is immediately called when the gadget is in memory, before it is rendered in the DOM, you cannot put any code that uses the DOM in ready()
, including all event binding, for example.ready()
is executed, so that you won't see any explicit errors at first, but it's still a race condition as bad as using declareService()
in your model gadget.declareService()
instead of ready()
.declareService()
is called only when the entire gadget is rendered on the DOM, freeing you to bind events without fearing that something does not exist.declareService()
is not limited to event binding, but that's just the most common use case, especially for small apps.ready()
and declareService()
.onEvent()
.onEvent()
is just an event listener in declareService()
bound to this.element
, so everything you know about declareService()
—that it runs after ready()
and after the gadget element is loaded in the DOM—applies to onEvent()
as well.
.declareService(function () {
var gadget = this;
return loopEventListener(document, "click", false,
function (event) {
gadget.element.querySelector("p").textContent =
"You have just clicked on a " + event.target.tagName + " tag.";
});
}, false)
onEvent()
is bound to the gadget element, events that don't reach this.element
cannot be caught using onEvent()
.declareService()
can still be used.onEvent()
, replace the click
event listener in index.js
with:index.js
, now copy the following somewhere between "use strict";
and rJS(window)
:
function loopEventListener(target, type, useCapture, callback,
prevent_default) {
//////////////////////////
// Infinite event listener (promise is never resolved)
// eventListener is removed when promise is cancelled/rejected
//////////////////////////
var handle_event_callback,
callback_promise;
if (prevent_default === undefined) {
prevent_default = true;
}
function cancelResolver() {
if ((callback_promise !== undefined) &&
(typeof callback_promise.cancel === "function")) {
callback_promise.cancel();
}
}
function canceller() {
if (handle_event_callback !== undefined) {
target.removeEventListener(type, handle_event_callback, useCapture);
}
cancelResolver();
}
function itsANonResolvableTrap(resolve, reject) {
var result;
handle_event_callback = function (evt) {
if (prevent_default) {
evt.stopPropagation();
evt.preventDefault();
}
cancelResolver();
try {
result = callback(evt);
} catch (e) {
result = RSVP.reject(e);
}
callback_promise = result;
new RSVP.Queue()
.push(function () {
return result;
})
.push(undefined, function (error) {
if (!(error instanceof RSVP.CancellationError)) {
canceller();
reject(error);
}
});
};
target.addEventListener(type, handle_event_callback, useCapture);
}
return new RSVP.Promise(itsANonResolvableTrap, canceller);
}
<body>
.document
, so you can click anywhere on the entire document and it still captures the events.declareJob()
, which is basically a declareService()
that is triggered by being called, rather than automatically triggered when the gadget loads in the DOM.declareJob()
was developed for was inside a page that waits for all its gadgets to load before displaying any content.declareJob()
, the loading of that specific gadget is forked.declareJob()
.declareService()
, the promise queue returned by it will be cancelled when the gadget is removed from the DOM, which could be useful to remove event listeners and such.declareJob()
, the promise queue returned by it will be cancelled when the job is triggered again, which could be useful to avoid unnecessary work.onEvent()
, the promise queue returned by it will be cancelled when the same event is fired again, which could be useful to avoid duplicating responses.state
object, that can be directly manipulated as this.state
or less directly manipulated with changeState()
, which then automatically triggers onStateChange()
.onStateChange()
is triggered every time changeState()
is called, and they are all executed in order, so if you call RSVP.delay(300000)
, then RenderJS will block the next onStateChange()
for five minutes.setState()
, it is simply the first ready()
handler, run before anything else and blocking everything else.setState({key: value})
is implemented as ready(function () { this.state = JSON.parse(JSON.stringify({key: value})) })
.onStateChange()
helps with something similar.onStateChange()
and all rendering properties inside state
.index.js
.current_tag
as a new property in setState()
.event.target.tagName
from the click
event listener.click
event listener with the following:
.onEvent("click", function (event) {
return this.changeState({current_tag: event.target.tagName});
}, false, true)
onStateChange
, add the following above the big if
statement:
if (modification_dict.hasOwnProperty("current_tag")) {
this.element.querySelector("p").textContent =
"You have just clicked on a " + this.state.current_tag + " tag.";
}
if
statement:
this.element.querySelector("p").textContent =
"You just added the new item, " + gadget.state.current_item
+ ". You have added " + gadget.state.item_list.length + " item(s).";
submit
event listener with the following:
.onEvent("submit", function (event) {
var item = event.target.elements[0].value;
event.target.elements[0].value = "";
return this.changeState({current_item: item});
}, false, true)
onStateChange()
.createElement
and appendChild
is incredibly slow, compared to calculating the raw HTML and changing innerHTML
.appendChild
cannot reflect any changes in the database made by other clients.addItem()
in the big if
statement:
this.element.querySelector("ul").innerHTML =
"<li>" + this.state.item_list.join("</li>\n<li>") + "</li>";
this.state.item_list.push(item);
from addItem()
, since the previous line does the actual list rendering.document
from the IIFE as well, since it's unused now.onStateChange()
, but nothing shows up if you refresh your app, since all the rendering only happens when you update current_item
.current_item
and changeState()
were used in the submit
event listener to introduce onStateChange()
. But now, you know more than enough about that, so remove current_item
entirely; it's worse than useless, since it prevents you from adding duplicates.return this.changeState({current_item: item});
in the submit
event listener with return this.addItem(item);
.update: false
, to setState()
.onStateChange()
doesn't trigger when you modify objects like item_list
, you have to manually signal that the state should refresh.update = false
at the bottom of onStateChange()
and {update: true}
in any calls to changeState()
, you can force onStateChange()
to run without actually changing state
.addItem()
entirely as:
.declareMethod("addItem", function (item) {
var gadget = this;
gadget.state.item_list.push(item);
return gadget.getDeclaredGadget("model")
.push(function (model_gadget) {
return model_gadget.put(gadget.state.item_list.length.toString(), {
title: item,
completed: false
});
})
.push(function () {
return gadget.changeState({update: true});
});
})
put()
is now in addItem()
, you don't want to put()
again all the documents you got from allDocs()
, nor do you want to run onStateChange()
for every todo you retrieve from storage at the beginning.push()
in declareService()
with:
.push(function (result_list) {
var i;
for (i = 0; i < result_list.length; i += 1) {
gadget.state.item_list.push(result_list[i].title);
}
return gadget.changeState({update: true});
});
current_item
from everywhere.querySelector
s too, remove the click
event listener, and even remove current_tag
, which was just introduced in this chapter.onStateChange()
should look like this by the end:
.onStateChange(function (modification_dict) {
this.element.querySelector("ul").innerHTML =
"<li>" + this.state.item_list.join("</li>\n<li>") + "</li>";
this.state.update = false;
})
Perhaps you've noticed a gradual decline in the depth of explanation in this chapter. That is intentional, because you are now already a fully qualified OfficeJS developer who can think on your own.
Therefore, after the next chapter (which involves lots of copying and pasting), the rest of the tutorial will only contain small snippets of code that you must integrate yourself.
Nevertheless, a reference implementation of this OfficeJS todo app will be available for download in a compressed format at the very end of the tutorial, for your own reference.
index.css
from your folder and download the latest versions of TodoMVC's base and application CSS files instead, base.css
and index.css
.index.html
with the following:
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OfficeJS App</title>
<script src="rsvp.js"></script>
<script src="renderjs.js"></script>
<script src="jio.js"></script>
<script src="index.js"></script>
<link href="base.css" rel="stylesheet">
<link href="index.css" rel="stylesheet">
</head>
<body>
<div data-gadget-url="gadget_model.html"
data-gadget-scope="model"
data-gadget-sandbox="public">
</div>
<section class="todoapp">
<header class="header">
<h1>todos</h1>
<form>
<input class="new-todo" placeholder="What needs to be done?" autofocus>
</form>
</header>
<section class="main">
<input class="toggle-all" type="checkbox">
<label for="toggle-all" class="toggle-label">Mark all as complete</label>
<ul class="todo-list"></ul>
</section>
<footer class="footer">
<span class="todo-count"></span>
<div class="filters">
<a href="#/" class="selected">All</a>
<a href="#/active">Active</a>
<a href="#/completed">Completed</a>
</div>
<button class="clear-completed">Clear completed</button>
</footer>
</section>
</section>
<footer class="info">
<p>Double-click to edit a todo</p>
</footer>
</body>
</html>
<meta charset="utf-8">
is needed to correctly render a certain Unicode arrow in index.css
.<meta name="viewport">
makes your app responsive on mobile.<section class="main">
and <footer class="footer">
should be hidden if there are no todos.this.element.querySelector(".main").style.display = "none"
at the top of declareService()
…onStateChange()
, so that's where you should put:
var plural = this.state.item_list.length === 1 ? " item" : " items";
if (this.state.item_list.length === 0) {
this.element.querySelector(".main").style.display = "none";
this.element.querySelector(".footer").style.display = "none";
} else {
this.element.querySelector(".main").style.display = "block";
this.element.querySelector(".footer").style.display = "block";
this.element.querySelector(".todo-count").textContent =
this.state.item_list.length + plural;
}
submit
event listener with the following:
.onEvent("submit", function (event) {
var item = event.target.elements[0].value.trim();
event.target.elements[0].value = "";
if (item) {
return this.addItem(item);
}
}, false, true)
<li>content</li>
, TodoMVC wants this:
<li class="todo-item">
<div class="view">
<input class="toggle" type="checkbox">
<label class="todo-label">content</label>
<button class="destroy"></button>
</div>
<input class="edit">
</li>
var todo_html_start = "<li class='todo-item'>\n"
+ " <div class='view'>\n"
+ " <input class='toggle' type='checkbox'>\n"
+ " <label class='todo-label'>\n",
todo_html_end = " </label>\n"
+ " <button class='destroy'></button>\n"
+ " </div>\n"
+ " <input class='edit'>\n"
+ "</li>";
this.element.querySelector("ul").innerHTML =
todo_html_start + this.state.item_list
.join(todo_html_end + todo_html_start) + todo_html_end;
handlebars.js
, and add <script src="handlebars.js"></script>
to <head>
.<body>
except:
<body>
<div data-gadget-url="gadget_model.html"
data-gadget-scope="model"
data-gadget-sandbox="public">
</div>
<main class="handlebars-anchor">
</main>
</body>
<head>
.
<script class="handlebars-template" type="text/x-handlebars-template">
<section class="todoapp">
<header class="header">
<h1>todos</h1>
<form>
<input class="new-todo" placeholder="What needs to be done?" autofocus>
</form>
</header>
<section class="main {{#unless todo_exists}}hidden{{/unless}}">
<input class="toggle-all" type="checkbox" {{#if all_completed}}checked="true"{{/if}}>
<label for="toggle-all" class="toggle-label">Mark all as complete</label>
<ul class="todo-list">
{{#each todo_list}}
<li class="todo-item {{#if this.completed}}completed{{/if}} {{#if this.editing}}editing{{/if}}">
<div class="view {{#if this.edit}}hidden{{/if}}">
<input class="toggle" type="checkbox"{{#if this.completed}} checked="true"{{/if}}>
<label class="todo-label">{{this.title}}</label>
<button class="destroy"></button>
</div>
<input class="edit{{#unless this.editing}} hidden{{/unless}}">
</li>
{{/each}}
</ul>
</section>
<footer class="footer {{#unless todo_exists}}hidden{{/unless}}">
<span class="todo-count">{{todo_count}}</span>
<div class="filters">
<a href="#/" class="selected">All</a>
<a href="#/active">Active</a>
<a href="#/completed">Completed</a>
</div>
<button class="clear-completed">Clear completed</button>
</footer>
</section>
<footer class="info">
<p>Double-click to edit a todo</p>
</footer>
</script>
id
s inside them.<script>
tag, the class
allows you to refer to the script inside your JavaScript code, no matter how many copies of the same gadget are on the same page.type
specifies that it is not a JavaScript script, but instead a Handlebars script.var handlebars_template;
as a global variable, between "use strict"
and rJS(window)
.loopEventListener
, anything inside the IIFE but outside the gadget chain acts a global variable for the gadget.declareService()
immediately calls changeState()
in the promise queue, and changeState()
needs the Handlebars template to render todos, so you must compile the Handlebars template before all of that.declareService()
:
handlebars_template = Handlebars.compile(
document.head.querySelector(".handlebars-template").innerHTML
);
onStateChange()
handler with:
.onStateChange(function (modification_dict) {
var plural = this.state.item_list.length === 1 ? " item" : " items";
this.element.querySelector(".handlebars-anchor").innerHTML =
handlebars_template({
todo_list: this.state.item_list,
todo_exists: this.state.item_list.length >= 1,
todo_count: this.state.item_list.length.toString() + plural,
all_completed: false
});
this.state.update = false;
})
<main class="handlebars-anchor">
with the HTML in <script class="handlebars-template">
, where for example, the content of <span class="todo-count">
is filled by the value of todo_count
, and elements are checked or hidden based on the Booleans todo_exists
and all_completed
.querySelector()
or appendChild()
, drastically simplifying the code.Handlebars
to the IIFE, refresh your app, and notice that all the todos are blank.this
, like {{this.title}}
to display actual content, but the elements of todo_list
are just strings.push
ing full todo
objects to item_list
, such as {title: "laundry", completed: false}
. Perhaps you should rename it to todo_list
too.You should now have a stylish todo app truly befitting the name TodoMVC, even though none of the buttons work.
apply
syntax was to quickly introduce some jIO methods, but now you all know how to put()
documents (JavaScript objects) with string IDs, get()
documents using their IDs, and get allDocs()
from a whole storage.allDocs()
and then get()
, the model gadget should have a function, maybe getTodoList()
, that does both:
.declareMethod("getTodoList", function () {
var gadget = this;
return this.state.storage.allDocs()
.push(function (result_list) {
var promise_list = [], i;
for (i = 0; i < result_list.data.total_rows; i += 1) {
promise_list.push(
gadget.state.storage.get(result_list.data.rows[i].id)
);
}
return RSVP.all(promise_list);
});
})
put()
, it should update only the given properties, like with changeState()
:
.declareMethod("putTodo", function (id, todo) {
var gadget = this;
return this.state.storage.get(id)
.push(function (result) {
var key;
for (key in todo) {
if (todo.hasOwnProperty(key)) {
result[key] = todo[key];
}
}
return result;
}, function () {
return todo;
})
.push(function (todo) {
return gadget.state.storage.put(id, todo);
});
})
.push(null, function (error) { console.log(error) });
in order to catch and log these rejections without letting the entire promise queue throw.index.js
that use model_gadget
.declareService()
should be eight lines long.state.todo_list
separately, so all you have to do is ignore memory and reload the entire todo list from getTodoList()
every update.onStateChange()
, you only need to change that one method.model_gadget.getTodoList()
, then use its resolved value to replace this.state.todo_list
in the method.todo_list
from the state and the rest of the code, which further separates the concerns of the model gadget and the index—the view.todo_list
, like declareService()
and addItem()
, may become drastically simplified or even themselves removed by its removal, but that's all up to you.onStateChange()
will now be in a promise queue, the scope will change, so remember to replace this
with gadget
as well.this
issue several times.this
is very bad style.this
to gadget
whenever it gets used inside a gadget chain, why not just call it gadget
everywhere so you don't need to change it?var gadget = this;
to the top of every method, and replace this
with gadget
everywhere else.putTodo()
based on the length of todo_list
, so that the IDs on each tab are almost always the same, and they end up overwriting each other all the time.allDocs()
retrieves documents in order of ID, so a numerically increasing ID puts your todos in chronological order."10"
comes before "2"
and "42"
comes after "419"
.put()
?post()
method that put
s a document in storage with a randomly-generated UUID, then returns that UUID.createJIO
to:
{
type: "uuid",
sub_storage: {
type: "document",
document_id: "/",
sub_storage: {
type: "local"
}
}
}
document_id
for LocalStorage, you can put whatever storage you want as a sub_storage
, even other handlers.post()
:
.declareMethod("postTodo", function (title) {
var gadget = this;
return gadget.state.storage.post({
title: title,
completed: false
});
})
putTodo()
in index.js
with postTodo()
and refresh your different tabs. Finally, all conflicts have disappeared.index.js
to handle single click events, double click events, and keyboard events respectively.
.onEvent("click", function (event) {
var gadget = this,
todo_item = event.target.parentElement.parentElement,
jio_id = todo_item.getAttribute("data-jio-id");
return gadget.getDeclaredGadget("model")
.push(function (model_gadget) {
switch (event.target.className) {
case "toggle":
return model_gadget.toggleOneTodoStatus(
jio_id,
!todo_item.classList.contains("completed")
);
case "toggle-all":
return model_gadget.toggleAllTodoStatus(event.target.checked);
case "toggle-label":
return model_gadget.toggleAllTodoStatus(
!gadget.element.querySelector(".toggle-all").checked
);
case "destroy":
return model_gadget.removeOneTodo(jio_id);
case "clear-completed":
return model_gadget.removeAllCompletedTodo();
default:
if (gadget.state.editing_jio_id
&& event.target.className !== "edit") {
return "clicking outside of the input box cancels editing";
}
return "default";
}
})
.push(function (path) {
if (path !== "default") {
return gadget.changeState({update: true, editing_jio_id: ""});
}
});
}, false, false)
.onEvent("dblclick", function (event) {
var gadget = this;
if (event.target.className === "todo-label") {
return gadget.changeState({
editing_jio_id: event.target.parentElement
.parentElement.getAttribute("data-jio-id")
});
}
}, false, false)
.onEvent("keydown", function (event) {
var gadget = this, item;
if (event.target.className === "edit") {
if (event.keyCode === ESCAPE_KEY) {
return gadget.changeState({update: true, editing_jio_id: ""});
}
item = event.target.value.trim();
if (event.keyCode === ENTER_KEY && item) {
return gadget.getDeclaredGadget("model")
.push(function (model_gadget) {
return model_gadget.changeTodoTitle(
event.target.parentElement.getAttribute("data-jio-id"),
item
);
})
.push(function () {
return gadget.changeState({update: true, editing_jio_id: ""});
});
}
}
}, false, false)
preventDefault
is set to false
, which allows checkboxes to be toggled and inputs to be typed.var ENTER_KEY = 13;
and var ESCAPE_KEY = 27;
as global variables, beside handlebars_template
.editing_jio_id: ""
to setState()
, which contains the ID of the field currently being edited, or ""
if there is none.data-jio-id
to each <li class="todo-item">
so that your event listeners can tell the model which todo to modify.
<li class="todo-item {{#if this.completed}}completed{{/if}} {{#if this.editing}}editing{{/if}}"
data-jio-id="{{this.id}}">
todo_list
that you pass to Handlebars don't actually have an id
property, so you must figure out a way to return a list that does in getTodoList()
.get()
doesn't give you back the IDs you pass in, but good luck!data-jio-id
is set correctly.gadget_model.js
:
.declareMethod("changeTodoTitle", function (id, title) {
var gadget = this;
return gadget.putTodo(id, {title: title});
})
.declareMethod("toggleOneTodoStatus", function (id, completed) {
var gadget = this;
return gadget.putTodo(id, {completed: completed});
})
.declareMethod("toggleAllTodoStatus", function (completed) {
var gadget = this,
return gadget.state.storage.allDocs()
.push(function (result_list) {
var promise_list = [], i;
for (i = 0; i < result_list.data.total_rows; i += 1) {
promise_list.push(
gadget.toggleOneTodoStatus(result_list.data.rows[i].id, completed)
);
}
return RSVP.all(promise_list);
});
})
.declareMethod("removeOneTodo", function (id) {
var gadget = this,
return gadget.state.storage.remove(id);
})
.declareMethod("removeAllCompletedTodo", function () {
var gadget = this;
return gadget.getTodoList()
.push(function (todo_list) {
var promise_list = [], i;
for (i = 0; i < todo_list.length; i += 1) {
if (todo_list[i].completed) {
promise_list.push(gadget.removeOneTodo(todo_list[i].id));
}
}
return RSVP.all(promise_list);
});
})
get()
from the jIO storage before calling put()
in putTodo()
, because changeTodoTitle()
and toggleOneTodoStatus()
only care about the title and completed properties respectively, and have no idea about the other.toggleAllTodoStatus()
uses the basic allDocs()
because all it needs are the IDs of every todo in order to call toggleOneTodoStatus()
on each.removeOneTodo()
uses a jIO method, remove()
, that removes the document with the given ID.removeAllCompletedTodo()
uses getTodoList()
because it needs the completed status of every todo.removeAllCompletedTodo()
needs to know the IDs of all completed todos in order to call removeOneTodo()
on them, so I hope you really did implement that properly in getTodoList()
!onStateChange()
:
.onStateChange(function (modification_dict) {
var gadget = this;
return gadget.getDeclaredGadget("model")
.push(function (model_gadget) {
return model_gadget.getTodoList();
})
.push(function (todo_list) {
var plural = todo_list.length === 1 ? " item" : " items",
all_completed = true,
i;
for (i = 0; i < todo_list.length; i += 1) {
if (!todo_list[i].completed) {
all_completed = false;
}
if (todo_list[i].id === gadget.state.editing_jio_id) {
todo_list[i].editing = true;
} else {
todo_list[i].editing = false;
}
}
gadget.element.querySelector(".handlebars-anchor").innerHTML =
handlebars_template({
todo_list: todo_list,
todo_exists: todo_list.length >= 1,
todo_count: todo_list.length.toString() + plural,
all_completed: all_completed
});
gadget.state.update = false;
});
})
<input class="edit">
.editing_jio_id
exists, or <input class="new-todo">
otherwise. These two can be combined, since the same querySelector
can both focus the currently edited todo and update its value.<input class="new-todo">
should be saved by assigning its value to some variable before using the Handlebars template, and loading its value from that variable after using the Handlebars template.submit
event listener, by passing in a new state property to changeState()
. This will also eliminate event.target.elements[0].value = "";
, ensuring that every DOM modification in the entire app really only happens in onStateChange()
.allDocs()
.type: "query"
and put your current UUID/Document/LocalStorage inside as a sub_storage
.creation_date: Date.now()
to postTodo()
.getTodoList()
, you can then customize allDocs()
into allDocs({sort_on: [["creation_date", "ascending"]]})
.getTodoList()
.
.declareMethod("getTodoList", function () {
var gadget = this;
return gadget.state.storage.allDocs({
sort_on: [["creation_date", "ascending"]],
select_list: ["title", "completed"]
})
.push(function (result_list) {
var todo_list = [], todo, i;
for (i = 0; i < result_list.data.total_rows; i += 1) {
todo = result_list.data.rows[i];
todo_list.push({
id: todo.id,
title: todo.value.title,
completed: todo.value.completed
});
}
return todo_list;
});
})
select_list
, and allDocs()
will return a list of documents that each contain the properties of select_list
in value
.limit: [3, 7]
to only return between 3 and 7 documents, and query: '(title:"buy%") AND (completed:"true")'
to only return completed todos that begin with "buy". Notice the double quotes; jIO cannot parse single quotes.Other than a few details in the details about attachments, you now know everything there is to know about jIO!
Please carefully read through all the storage types and handlers, especially ReplicateStorage, because you can still do so much more with jIO.
putAttachment()
, getAttachment()
, removeAttachment()
, etc.Blob
s, meant only to store binary data.putAttachment("foo", "bar", new Blob([content], {type: "text/plain"}))
everywhere, just use a FileSystemBridgeStorage handler on top of these connectors and stick with put()
and get()
.gadget_router.html
and gadget_router.js
.index.html
, then log its birth to the console.index.js
:
.declareService(function () {
var gadget = this,
template = gadget.element.querySelector(".handlebars-template"),
div = document.createElement("div");
gadget.element.appendChild(div);
handlebars_template = Handlebars.compile(
document.head.querySelector(".handlebars-template").innerHTML
);
return gadget.declareGadget("gadget_router.html", {
scope: "router",
sandbox: "public",
element: div
})
.push(function () {
return gadget.changeState({update: true});
});
})
declareGadget
exactly mirror the parameters in the HTML.element
, determines which element the gadget is attached to./#/completed
only shows the completed todos."hashchange"
event on window
(you can't use onEvent()
because it's not this.element
), by copying the loopEventListener
from Advanced RenderJS into gadget_router.js
.getDeclaredGadget()
.put()
and get()
methods to all its subgadgets, no matter how nested they are.
.allowPublicAcquisition("setQuery", function (param_list) {
var gadget = this;
return gadget.changeState({query: param_list[0]});
})
param_list
, in the actual function definition.setQuery()
from all child gadgets, you would not be able to call it from the parent gadget.
.allowPublicAcquisition("setQuery", function (param_list) {
var gadget = this;
return gadget.setQuery.apply(gadget, param_list);
})
.declareMethod("setQuery", function (query) {
var gadget = this;
return gadget.changeState({query: query});
})
param_list
is already an array of arguments, you might as well call apply
on the actual declared method with it.setQuery
from the index gadget, so you can delete the extra declareMethod()
.state.query
in the index gadget, filtering the todo list by completion status is merely a matter of adjusting the query of allDocs()
in the model gadget.getTodoList()
, then plug it directly into allDocs()
, so that for example, removeAllCompletedTodo()
can call getTodoList('completed: "true"')
and not worry about checking in the loop for completeness as well.
.declareAcquiredMethod("setParentQuery", "setQuery");
gadget.setParentQuery()
wherever it likes, and RenderJS will automatically wrap those arguments in an array and send it to be executed by setQuery()
in the index gadget.hashchange
event listener and the appropriate arguments to getTodoList()
.todo_count
to always be the number of uncompleted todos, even with all these queries being thrown around.hashchange
event, and translates the hash to a specific query for allDocs()
.setParentQuery()
with said query, which calls setQuery()
on the index gadget via acquisition.setQuery()
on the index gadget calls changeState()
with said query, which triggers onStateChange()
with query
in modification_dict
.onStateChange()
calls getTodoList()
with said query.allDocs()
with said query, and the resulting list of todos has been successfully filtered by the hash changing.For the final part of the final chapter, you will take your web app offline.
Follow the steps in the details, and make sure your app works offline, as shown in the screenshot above.
serviceworker.js
.declareService()
of the index gadget, add this:
.push(function () {
if (navigator.serviceWorker) {
return navigator.serviceWorker.register("serviceworker.js");
}
})
serviceworker.js
:
/*jslint nomen: true, indent: 2, maxerr: 3, maxlen: 80*/
/*global self, caches, fetch*/
(function (self, caches, fetch) {
"use strict";
var CACHE_VERSION = 1,
CACHE_NAME = "todos-renderjs-" + CACHE_VERSION.toString();
self.addEventListener("install", function (event) {
event.waitUntil(caches.open(CACHE_NAME)
.then(function (cache) {
return cache.addAll([
"./",
"rsvp.js",
"renderjs.js",
"jio.js",
"handlebars.js",
"launcher_icon.png",
"base.css",
"index.css",
"index.html",
"index.js",
"gadget_model.html",
"gadget_model.js",
"gadget_router.html",
"gadget_router.js"
]);
})
.then(function () {
return self.skipWaiting();
}));
});
self.addEventListener("fetch", function (event) {
event.respondWith(caches.match(event.request)
.then(function (response) {
return response || fetch(event.request);
}));
});
self.addEventListener("activate", function (event) {
event.waitUntil(caches.keys()
.then(function (keys) {
return Promise.all(keys
.filter(function (key) {
return key !== CACHE_NAME;
})
.map(function (key) {
return caches.delete(key);
}));
})
.then(function () {
self.clients.claim();
}));
});
}(self, caches, fetch));
manifest.json
, with the following:
{
"short_name": "OfficeJS Todos",
"name": "OfficeJS Todo List - Sample Tutorial Application",
"icons": [{
"src": "launcher_icon.png",
"sizes": "any",
"type": "image/png"
}],
"start_url": "./",
"display": "standalone"
}
Congratulations! You've just made a fully responsive, storage-agnostic, modular, mobile-friendly, offline-first, progressive web application. You can check out our reference implementation on Nexedi Gitlab.
What's next?
Participate in the OfficeJS app developer contest!
Using RenderJS and jIO, the only limit is your own imagination. Good luck on your journey as an OfficeJS developer!
For more information, please contact Jean-Paul, CEO of Nexedi (+33 629 02 44 25).