TL;DR Write applications with Sirius.js
is easy.
Sirius.js it’s a modern MVC web framework, which is designed to simplify client side application development.
It has the following features:
- MVC style, separate work with Model, Controller and View if you want
- All important events (routes, custom events) in one place
- Binding, you might bind model and view, with modify any attributes (class, id, data-*) on changes, bind view to model, view to view, and any object property to view.
- For models available Validators, and you might easy add new own validators.
- Html5 Routing, in browsers which not support html5 routing, all routes will be convert to hashbase routes.
- Log all actions in application.
- Work with any javascript library (jQuery, Prototypejs).
Any way lets create a simple todo app with Sirius
.
Need create a several parts of application:
- When click on
checkbox
then task should mark as completed - When click on destroy button then should remove task
- We might add new Task with input
- When double click on task, then should be display input and we might modufy task title
- When click on
toggle-all
then all task should be marked as completed - When click on
clear completed
then should remove completed tasks - When set Task as completed, need update clear task counter in bottom
- When add a new Task need update task count in bottom
- When application not contain Task need hide bottom
- When click on ‘active’, ‘completed’ or ‘all’ then application should display task for this property (completed)
Before work
I use last Sirius.js
v0.6.3 from master branch.
Sirius.js have a some parts, which help us to create Application. Obviously for work we need define some Model, which contain model state. Models in Sirius.js
must be extend BaseModel
, who worked with Rails know about it. Our model should contain next attributes for correct work, firsly it’s title
where we might save user input information.
And we need completed
attribute for mark model as completed, and something for identify model like primary key
it’s id
attribute.
I override constructor
for generate uniq id for model:
class Task extends Sirius.BaseModel
@attrs: ["title", {completed: false}, "id"]
constructor: (obj = {}) ->
super(obj)
@_id = "todo-#{Math.random().toString(36).substring(7)}"
is_active: () -> !@completed()
is_completed: () -> @completed()
As you can see i add for @attrs
default argument for completed
attribute: false
, this means, when we create a model then it not marked as completed
.
And i call super(obj)
, it’s need if we want create model with defined attributes TodoList.add(new Task(title: "Rule the web"))
Ok, we create model class. But for work with application, we should store our Task, for it we need collection:
TodoList = new Sirius.Collection(Task)
Yes, we might use javascript array, but Sirius.Collection
support synchronization with server, filters, and might guarantee that collection only for one type (it’s means when you add in TodoList
not Task
, and other type (String
for example) then collection throw Error).
Start application
We alreay have Task
class and collection for save all tasks. Then let’s start application.
Sirius.Application.run
route : {}
adapter : new JQueryAdapter()
Any Sirius.Application must be start with run
method call. We must pass to run
- route
object, and adapter for current application. At the current moment Sirius
support JQueryAdapter
and PrototypeAdapter
.
Let’s create a first route for application. When Sirius
Application run, then it generate application:run
event, it’s perfect place for define default models.
First create a route:
routes =
"application:run" : {controller: MainController, action: "start"}
And for it need create Controller. Controller it’s a javascript object, which contain methods.
MainController =
start: () ->
TodoList.add(new Task({title : "Create a TodoMVC template", completed: true}))
TodoList.add(new Task(title: "Rule the web"))
It’s easy. When application start then application generate application:run
event, and call MainController#start
method.
Ok. We add Task in collection, but how to add it in our html?
One option it’s add in #start
method more code and convert Task
into html presentation, but it’s not sirius way.
Let’s look at Sirius.Collection#subscribe
method, when we add task in collection, collection will be generate add
event:
TodoList.subscribe('add', (todo) -> Renderer.append(todo))
Then data flow:
application:run -> MainController#start -> TaskList#add -> generate add -> call Render.append(todo)
What is Render?
It’s not part of Sirius
framework, it’s only object which contain methods which convert Task
into html
presentation. But I do not like create html by hand, therefore i use javascript template engine: EJS
.
Renderer =
todo_template: new EJS({url: 'js/todo.ejs'}) // 1
view: new Sirius.View("#todo-list") // 2
append: (todo) ->
template = @todo_template.render({todo: todo})
@view.render(template).append() // 3
1
it’s standart EJS object, where i point path for ejs
template file.
This file it’s simple:
<li id="<%= todo.id() %>">
<div class="view">
<input class="toggle" type="checkbox" />
<label><%= todo.title() %></label>
<button class="destroy" data-id="<%= todo.id() %>"></button>
</div>
<input class="edit"
data-id='<%= todo.id() %>' value="<%= todo.title() %>"
/>
</li>
As you can see this only contain html.
2
it’s more interesting, this is Sirius.View
object. When we work with Rails for example, view it’s a page (for simplification). But when we work with client side framework view it’s might be any element. Sirius.View
it’s abstraction over HTMLElement, and it contain some necessary methods.
3
in this line, firstly we pass compiled template into render
method, think of it as a method of preparation, it’s like a complile template, and then we call append
, this method modify element content and add a new content. If we create it with plain jquery, it would look like this
template = todo_template.render({todo: todo})
$("#todo-list").append(template)
Ok, when application start we add in task list new tasks, and user might see this tasks.
But one as you can see we create one task with complete: true
, but in html does not differ from other. For it’s we need apply binding. We should bind model to view. And then if model mark as completed, then html the same will be mark as completed.
For binding in Sirius
need add some attributes for html:
<li id="<%= todo.id() %>">
<div class="view">
<input class="toggle" type="checkbox"
data-bind-view-to="checked"
data-bind-view-from="completed">
<label><%= todo.title() %></label>
<button class="destroy" data-id="<%= todo.id() %>"></button>
</div>
<input class="edit"
data-id='<%= todo.id() %>' value="<%= todo.title() %>" />
</li>
For model to view binding we need add attributes into html: data-bind-view-from
- this attribute specify model attribute (completed
at current case). data-bind-view-to
- this attribute specify html attribute which will be modified, when model attribute will be changed.
Control flow: Task#completed=true
-> add 'checked' property for class
.
Modify Rendere#append
for bind model to view:
append: (todo) ->
template = @todo_template.render({todo: todo})
@view.render(template).append()
todo_view = new Sirius.View("li\##{todo.id()}") # find our li element
todo.bind(todo_view)
Ok, now when we add Task where complete
is true
, then input checkbox will be set check.
But it’s mark checkbox as completed, but does nothing with the LI
element itself. Therefore we need add data-bind-view
for LI
:
<li id="<%= todo.id() %>"
data-bind-view-to="class"
data-bind-view-transform="mark_as_completed"
data-bind-view-from="completed" class="">
...
</li>
You already see data-bind-view-to
and data-bind-view-from
, new attribute is data-bind-view-transfrom
. I will explain what it means.
complete
attribute might be only true
or false
. Yes, we might set up class
attribute as true
or as false
, but it’s not correct. And therefore we need convert our attribute into valid value, for it’s need transform
function.
append: (todo) ->
template = @todo_template.render({todo: todo})
@view.render(template).append()
todo_view = new Sirius.View("li\##{todo.id()}")
todo.bind(todo_view, {
transform:
mark_as_completed: (t) -> if t then "completed" else "" // 1
})
1
- we take a completed
attribute (true or false) and if true
convert to compeleted
. All simple. The same in jQuery:
template = todo_template.render({todo: todo})
$("#todo-list").append(template)
var transform = function(todo) {
if (todo.completed) {
$("li#" + todo.id()).addClass("completed")
}
};
transform(todo);
+ code for watch changes in model, and on each changes need call `transform` method.
When click on checkbox
then task should mark as completed
I.e when we click on checkbox
then model completed
attributes should be set in true
.
For it, need bind view
with model
. Firsly modify Render#append
:
append: (todo) ->
template = @todo_template.render({todo: todo})
@view.render(template).append()
todo_view = new Sirius.View("li\##{todo.id()}")
todo.bind(todo_view, {
transform:
mark_as_completed: (t) -> if t then "completed" else ""
})
todo_view.bind(todo)
I only add todo_view.bind(todo)
line, where bind todo_view
- LI
element with current Task model.
When click on destroy button then should remove task
It’s more complicated task, because destroy
method we must remove task from collection, and should remove task from UL
todo tasks. For it, i will add a new route, which will react when user click on button
.
routes =
# ... other routes
"click button.destroy" : {controller: TodoController, action: "destroy", data: "data-id"}
With controller
and action
we are already familiar. A new property is data
.
When we click on element, it’s generate MouseEvent
which contain target
property. This is element, where we click. This element contain different attributes like class
, id
, type
, data-*
and others. And data
property tell what attributes from HTMLElement need, extract it and pass it into controller action.
TodoController =
destroy: (e, id) -> // 1
todo = TodoList.filter((t) -> t.id() == id)[0] // 2
TodoList.remove(todo) // 3
1
- method take two arguments, first it’s MouseEvent (it’s first argument for all actions which work with mouse, key, or custom events). Second argument it’s our data-id
from button element, if you forgot, our template:
<button class="destroy" data-id="<%= todo.id() %>"></button>
And if we rewrite this without data
:
destroy: (e) ->
id = $(e.target).data('data-id')
todo = TodoList.filter((t) -> t.id() == id)[0]
TodoList.remove(todo)
2
- find task by id. filter
.
3
- remove from collection.
Ok, we remove task from collection, but also need remove from html, for it i use subscribe
method for check if task remove from collection:
TodoList.subscribe('remove', (todo) -> $("\##{todo.id()}").remove())
Find element in html, and remove it.
We might add new Task with input
Consider following code:
MainController =
start: () ->
view = new Sirius.View("#todoapp") // 1
model = new Task() // 2
view.bind2(model) // 3
view.on("#new-todo", "keypress", "todo:create", model) // 4
TodoList.add(new Task({title : "Create a TodoMVC template", completed: true}))
TodoList.add(new Task(title: "Rule the web"))
This is our old method, which will be called when application run.
In 1
i create new Sirius.View
for this element.
Then in 2
i create new Task
model.
And in 3
i call bind2
method for view, this double side binding, it’s the same as
model.bind(view)
view.bind(model)
Becuase i bind view and model, i modify html code for input
element:
<input id="new-todo" data-bind-view-from='title' data-bind-to="title" name="title" placeholder="What needs to be done?" autofocus>
I add data-bind-view-from
- it’s for model to view binding, and data-bind-to
- view to model binding. With binding we already familiar.
More interesting it’s 4
- view.on("#new-todo", "keypress", "todo:create", model)
Here we bind event keypress
for “#new-todo”. When this event occurs then will be generate a new event todo:create
, and for method will related for todo:create
we pass model
. If we write this in more simple:
model = new Task()
$("#new-todo").on('keypres', (key_event) ->
adapter.fire(document, 'todo:create', key_event, model)
)
Also we must write route for handle todo:create
event:
routes =
"todo:create" : {controller: TodoController, action: "create", guard: "is_enter"}
New property is a guard
. In our case, when we change input, then will be call keypress
event, right? But we need create new model, only when user press enter key. Therefore TodoController#create
call only after is_enter
return true.
TodoController =
is_enter: (custom_event, original_event) ->
return true if original_event.which == 13 // 1
false
create: (custom_event, original_event, model) -> // 2
todo = new Task(title: model.title()) // 3
TodoList.add(todo)
model.title("") // 4
1
- must return true
only when it’s enter
key.
2
- take a custom event from todo:create
, then original event from keypress
, and our argument: model
.
3
- create a new task with title which from model, this model from MainController#start
.
4
- update title, you remember we bind model with view, and when we update title in model, then reset text for view.
When double click on task, then should be display input and we might modufy task title
For it action add event for task in Renderer#append
:
Renderer =
append: (todo) ->
# ...
todo_view =
# ...
todo_view.on('div', 'dblclick', (x) -> # 1
todo_view.render("editing").swap('class') # 2
)
When double click on div, need add editing
class for current todo_view
i.e. for LI
element 2
.
When click on toggle-all
then all task should be marked as completed
For this actions let’s add a new route:
routes =
"click #toggle-all" : {controller: TodoController, action: "mark_all", data: 'class'}
TodoController =
mark_all: (e, state) -> # 1
if state == 'completed'
TodoList.filter((t) -> t.is_completed()).map((t) -> t.completed(false)) # 2
else
TodoList.filter((t) -> t.is_active()).map((t) -> t.completed(true)) # 3
$("#toggle-all").toggleClass('completed')
When we click on #toggle-all
then need add completed
class for toggle-all
.
In mark_all
method (1
), i pass state
, this is extract from class
attribute for #toggle-all
.
And in 2
and 3
i mark all model completed, or reset completed attribute. Because we bind model with view, then all changes in model will be pass into view.
When click on clear completed
then should remove completed tasks
Add a new route:
routes =
"click #clear-completed": {controller: BottomController, action: "clear"}
And controller:
BottomController =
clear: () ->
TodoList.filter((t) -> t.is_completed()).map((t) -> TodoList.remove(t)) # 1
Renderer.clear(0) # 2
In 1
i find all completed model and then remove them. Are you remember that we remove from collection, we remove from html?
About 2
in next section.
When set Task as completed, need update clear task counter in bottom
Counter should contain all tasks. For it i add callback for model:
class Task extends Sirius.BaseModel
# ...
after_update: (attribute, newvalue, oldvalue) -> # 1
if attribute == "completed" || newvalue == true
Sirius.Application.get_adapter().and_then((adapter) -> adapter.fire(document, "collection:length")) # 2
1
- after_update
method take a attribute
which update, new value
for model, and last value for model.
2
- and then we get current adapter for application, and then fire new event.
For this custom event need add a new route:
routes =
"collection:length" : {controller: BottomController, action: "change"}
In controller:
BottomController =
change: () ->
Renderer.clear(TodoList.filter((t) -> t.is_completed()).length) # 1
1
- find all completed tasks.
In Renderer
new method:
Renderer =
clear_view: new Sirius.View("#clear-completed", (size) -> "Clear completed (#{size})") # 1
clear: (size) ->
if size != 0
@clear_view.render(size).swap() # 2
else
@clear_view.render().clear() # 3
1
- view which contain element, and method, wrap function
2
- update text for element
3
- clear text for element
in other words this work like:
wrap = (size) -> "Clear completed (#{size})"
if size != 0
$("#clear-completed").text(wrap(size))
else
$("#clear-completed").text("")
A small note about perfomance.
When we have many tasks, then need walk through the collection and find all completed
tasks. It can be slowly. So other way for check changes in collection, we might write the next code:
class MyCollection extends Sirius.Collection
constructor: (klass, args...) ->
super(klass, args)
@length_of_completed_tasks = 0 # 1
add: (model) ->
super(model)
if model.is_completed()
@length_of_completed_tasks ++ # 2
remove: (model) ->
super(model)
if model.is_completed()
@length_of_completed_tasks -- # 3
We create own class for collection which extends Sirius.Collection
and redefine some method for work with necessary property, in constructor 1
we create instance variable which start with 0, and in add 2
method we increment it property if task completed. In 3
we decrement, when task completed.
When add a new Task need update task count in bottom
When we add a new Task then counter should update, in previous section we talk only about completed
tasks. In this work with all tasks. Sirius.Collection
contain length
property, and for Sirius.View
available bind with any javascript property.
MainController =
start: () ->
length_view = new Sirius.View("#todo-count strong")
length_view.bind(TodoList, 'length') # 1
1
- as you can see we bind view with TodoList.lenght
property, when we add a new Task to Collection, then length changes and view updated.
When application not contain Task need hide bottom
It’s some like previous code, we need bind collection length and view.
MainController =
start: () ->
footer = new Sirius.View("#footer")
footer.bind(TodoList, 'length', {
to: 'class'
transform: (x) ->
if x == 0
"hidden"
else
""
})
When length will be equal 0, then need add hidden
class for footer
. Because not possible add data-view-*
for property
, then we add it with parameters.
When click on ‘active’, ‘completed’ or ‘all’ then application should display task for this property (completed)
For it actions need add routes:
routes =
"/" : {controller: MainController, action: "root"}
"/active" : {controller: MainController, action: "active"}
"/completed" : {controller: MainController, action: "completed"}
And actions for controller:
MainController =
root: () ->
Renderer.render(TodoList.all())
active: () ->
Renderer.render(TodoList.filter((t) -> t.is_active()))
completed: () ->
Renderer.render(TodoList.filter((t) -> t.is_completed()))
And in depends what action we must display different tasks.
Renderer =
view: new Sirius.View("#todo-list")
render: (todo_list) ->
@view.render().clear() # 1
for todo in todo_list
@append(todo)
In 1
we clear task list and then add tasks in html.
small note about routes, we might rewrite completed
and active
actions in one route:
routes =
"/:display" : {controller: MainController, action: "display"}
then in controller:
MainController =
display: (url_part) ->
if url_part == 'active'
# ...
else
# ...
Another part of this task, need add selected
class for A
link, when click on it.
Sirius.Application.run
route : routes
adapter : new JQueryAdapter()
class_name_for_active_link: 'selected'
That is all.