TodoMVC with Silk

TodoMVC is one small application, a list of todos. Programmers have written it in many frameworks, so that the frameworks can be compared. Its specification says what the application does. The Amber contributors write it once per Amber view library, in the repository amber/amber-todomvc. This page walks through the variant written with Silk alone. It builds on Building the page with Silk, and does not repeat it. The code is quoted from the repository as it is.

The model

Every variant shows the same model, the package TodoMVC-Model. It has no DOM in it, so its tests run under Node.js with grunt test. It holds everything that the specification asks, short of rendering. Four classes do that:

The list trims a title and refuses an empty one. This is what the specification asks for a new todo, and the view need not check it:

add: aTitle
	| title todo |
	title := aTitle trimBoth.
	title ifEmpty: [ ^ nil ].
	todo := todos add: (Todo id: self nextId title: title).
	self save.
	^ todo

A new title that is empty removes the todo, which is what the specification asks for editing:

retitle: aTodo to: aString
	| title |
	title := aString trimBoth.
	title
		ifEmpty: [ self remove: aTodo ]
		ifNotEmpty: [ aTodo title: title. self save ]

The model words the counter too. activeCountLabel answers 'item left' for one active todo, and 'items left' for none or for many. allAreCompleted is false for an empty list. The specification clears the “Mark all as complete” checkbox after “Clear completed”. A checkbox bound to this answer clears itself.

The filter reads the route. TodoFilter fromRoute: '#/active' answers the active filter. So do '#!/active' and the bare path '/active', as a router hands it over. A route that selects nothing answers TodoFilter all.

A store is made with a storage and a key. In the browser the storage is localStorage. In the tests it is MemoryStorage, a small class of the tests package with getItem: and setItem:value:. A test creates a list, changes it, and creates a second list on the same storage. The second list sees the change:

testToggleIsKept
	| milk |
	milk := todoList add: 'Milk'.

	todoList toggle: milk.

	self assert: self reloaded todos first isCompleted

The store keeps the id, the title and completed of each todo, and nothing else. The filter is not kept. The page keeps it in its URL instead.

The page

silk.html is the markup of the TodoMVC app template, with the two stylesheets of todomvc-common and todomvc-app-css from npm. The body of the template changed in five places. The main section, the footer and the “Clear completed” button carry the hidden attribute. The page shows nothing of them until Amber has started. The counter reads “0 items left”, in the plural, as the model words it. The credit lines name the Amber contributors. The script at the end of the body is the one from amber init. It starts the application with amber.globals.SilkTodoApp._start(). And the sample items of the template are gone, so the list is empty:

<ul class="todo-list"></ul>

The view builds every item of the list itself.

The view

The view is the package TodoMVC-Silk, with two classes. SilkTodoApp shows the list and binds what the page holds. SilkTodoItem shows one todo. SilkTodoApp has three slots: todoList, the TodoList; app, a Silk on the .todoapp section; and listElement, a Silk on the ul. The class method start makes the store, the list and the view:

start
	| store |
	store := TodoStore storage: window localStorage key: 'todos-amber-silk'.
	(self on: (TodoList keptIn: store))
		attachTo: '.todoapp' asSilk;
		followRoute

The key is todos-amber-silk, in the form todos-[framework] that the specification asks for. attachTo: keeps the two Silks, binds three things, and renders:

attachTo: aSilk
	app := aSilk.
	listElement := app at: '.todo-list'.
	self
		bindNewTodo;
		bindToggleAll;
		bindClearCompleted;
		render

Adding

The input at the top has the autofocus attribute of the template, so it is focused when the page loads. Enter adds what was typed and clears the input:

bindNewTodo
	| input |
	input := app at: '.new-todo'.
	input on: #keydown bind: [ :event |
		event key = 'Enter' ifTrue: [
			todoList add: (input propAt: 'value').
			input propAt: 'value' put: ''.
			self render ] ]

The model trims the value and ignores an empty one, so the view does not look at it. Every change ends with self render, which the section on rendering shows.

Mark all, clear completed

The checkbox above the list sets every todo to its own state:

bindToggleAll
	| checkbox |
	checkbox := app at: '.toggle-all'.
	checkbox on: #change bind: [
		todoList markAllCompleted: (checkbox propAt: 'checked').
		self render ]

The button in the footer removes the completed todos:

bindClearCompleted
	(app at: '.clear-completed') on: #click bind: [
		todoList clearCompleted.
		self render ]

Routing

The specification asks for the framework’s own router. Silk has none, so the view listens to the fragment of the URL itself. routeTo: takes a fragment and sets the filter from it. followRoute sends it the fragment of the page, now and whenever the fragment changes:

routeTo: aString
	todoList filter: (TodoFilter fromRoute: aString).
	self render
followRoute
	Silk on: #hashchange bind: [ self routeTo: window location hash ].
	self routeTo: window location hash

The three links of the footer come from the template, with #/, #/active and #/completed as their targets. A click changes the fragment, the browser fires hashchange, and routeTo: runs. A reload keeps the fragment, so it keeps the filter. Sent to the class, on:bind: binds an event of the window. window itself is a JavaScript object, and the JavaScript page says how location reaches it. A test sends routeTo: itself, and never touches the URL of the page.

Rendering

After every change, the view renders the list again and the four things that depend on the todos:

render
	self
		renderList;
		renderToggleAll;
		renderCount;
		renderFilters;
		renderVisibility

The last of them is three points of the specification. The main section and the footer are hidden when there are no todos. The “Clear completed” button is hidden when no todo is completed. propAt:put: sets the hidden property of the element, as the Silk page shows:

renderVisibility
	(app at: '.main') propAt: 'hidden' put: todoList isEmpty.
	(app at: '.footer') propAt: 'hidden' put: todoList isEmpty.
	(app at: '.clear-completed') propAt: 'hidden' put: todoList hasCompleted not

The view empties the list and fills it with the todos that the filter shows:

renderList
	listElement resetContents.
	todoList shownTodos do: [ :each | self renderTodo: each ]

Each todo is a SilkTodoItem, and << puts it into the list:

renderTodo: aTodo
	listElement << (SilkTodoItem on: aTodo in: self)

Silk puts an object by sending it renderOnSilk:, as the Silk page shows. The next section shows the item.

The checkbox above the list is checked when every todo is completed. The model answers false for no todos, so “Clear completed” clears the checkbox when it empties the list:

renderToggleAll
	(app at: '.toggle-all') propAt: 'checked' put: todoList allAreCompleted

The counter is the number in a strong element, and the model’s wording after it:

renderCount
	(app at: '.todo-count')
		resetContents;
		STRONG: todoList activeCount;
		<< (' ', todoList activeCountLabel)

The link of the current filter gets the class selected. The view reads each link’s href and asks the model which filter it selects:

renderFilters
	(app allAt: '.filters a') do: [ :each |
		each
			domClass: 'selected'
			be: (TodoFilter fromRoute: (each attrAt: 'href')) == todoList filter ]

domClass:be: adds the class when its second argument is true, and removes it when false.

One todo

SilkTodoItem has two slots: todo, the Todo, and todoApp, the SilkTodoApp that made it. renderOnSilk: builds the item of the template with tag messages, and binds it:

renderOnSilk: aSilk
	| listItem view |
	listItem := aSilk LI: 'class' -> (todo isCompleted ifTrue: [ 'completed' ]).
	view := listItem DIV: 'class' -> 'view'.
	(view INPUT: { 'class' -> 'toggle'. 'type' -> 'checkbox'. 'checked' -> (todo isCompleted ifTrue: [ 'checked' ]) })
		on: #change bind: [ todoApp toggle: todo ].
	(view LABEL: todo title) on: #dblclick bind: [ self startEditingIn: listItem ].
	(view BUTTON: 'class' -> 'destroy') on: #click bind: [ todoApp remove: todo ].
	listItem INPUT: 'class' -> 'edit'

The method follows the markup of the template from the top. aSilk LI: puts a list item into the list, with the class completed for a completed todo. For an active todo the association has nil as its value, and the item gets no class. The checkbox gets checked the same way. DIV: with an association puts the div with its class into the item. The checkbox, the label and the button go into the div, and the edit input goes after it.

This method is four points of the specification. The item gets the class completed when its todo is completed, and its checkbox is checked. The checkbox toggles the todo. A double click on the label starts an edit. The destroy button removes the todo. The stylesheet shows that button when the mouse is over the item, so the item does nothing for that.

The item does not change the list itself. It tells the app what was done, and the app changes the list and renders again:

toggle: aTodo
	todoList toggle: aTodo.
	self render

remove: and retitle:to: send the same message to the list, and render too. Under the active filter, a todo that was just completed goes away. shownTodos leaves it out, and the app renders the list again. The old items go with the old list, and a new SilkTodoItem shows each todo that remains.

Editing

A double click on the label gives the item the class editing. The stylesheet then hides the div and shows the edit input, which gets the title and the focus:

startEditingIn: aListItem
	| edit |
	edit := aListItem at: '.edit'.
	aListItem addDomClass: 'editing'.
	edit propAt: 'value' put: todo title.
	self bindEdit: edit.
	edit asDomNode focus

addDomClass: adds the class and keeps the others. Silk has no word for the focus, so the item sends focus to the DOM element itself, which asDomNode answers.

The specification asks for a save on Enter and on blur, and for a cancel on Escape. The item binds the three to the input:

bindEdit: anEdit
	| save |
	save := [ todoApp retitle: todo to: (anEdit propAt: 'value') ].
	anEdit on: #blur bind: save.
	anEdit on: #keydown bind: [ :event |
		event key = 'Enter' ifTrue: [
			anEdit off: #blur unbind: save.
			save value ].
		event key = 'Escape' ifTrue: [
			anEdit off: #blur unbind: save.
			todoApp render ] ]

A blur saves, through retitle:to: of the app. Enter saves too, and Escape renders the list again, which drops the edit. Both keys take the blur handler off the input first, with off:unbind:. The input loses the focus when the app renders the list again, and a browser may fire a blur then. Without the unbinding, that blur would save a second time, after the todo was removed. The model trims the value, removes the todo when it is empty, and saves.

What each point costs

The specification says Where it is What it takes
Hide main and footer with no todos renderVisibility two propAt:put:
Focus the input on load silk.html autofocus, from the template
Enter adds, trimmed, empty ignored bindNewTodo, TodoList >> add: one binding; the model trims
Mark all as complete bindToggleAll, renderToggleAll one binding, one propAt:put:
Mark all cleared after Clear completed renderToggleAll, TodoList >> allAreCompleted nothing; the model answers false for no todos
Toggle, class completed SilkTodoItem >> renderOnSilk:, SilkTodoApp >> toggle: one binding, two associations
Destroy button on hover SilkTodoItem >> renderOnSilk:, SilkTodoApp >> remove: one binding; the stylesheet shows the button
Double-click edits SilkTodoItem >> renderOnSilk:, startEditingIn: one binding, one method
Enter or blur saves, Escape cancels, empty destroys SilkTodoItem >> bindEdit:, SilkTodoApp >> retitle:to:, TodoList >> retitle:to: two bindings; the model removes the todo
The counter, item or items renderCount, TodoList >> activeCountLabel one cascade; the model words it
Clear completed, hidden when none bindClearCompleted, renderVisibility one binding, one propAt:put:
Persistence under todos-amber-silk start, TodoStore one key; the model saves
Routing, class selected, kept on reload followRoute, routeTo:, renderFilters one listener, one fromRoute: per link

Silk has a word for most of this: at:, on:bind:, off:unbind:, propAt:put:, attrAt:, domClass:be:, addDomClass:, resetContents, << and the tag messages. renderOnSilk: lets an object put itself, and on:bind: sent to the class binds the window. Three things reach past it to the DOM. On an element it is focus, through asDomNode. On the window they are localStorage and location hash. The JavaScript page is enough for all three.

The tests of the view

The view has tests too, in the package TodoMVC-Tests. TodoAppTest builds the markup of the template in a fixture, with an empty list. Then it drives the view with DOM events. They are a keydown with Enter, a click on a checkbox, a double click on a label, and a blur. It is abstract. SilkTodoAppTest, in TodoMVC-Silk-Tests, answers one message, attach:to:, with a SilkTodoApp on the list and the element. Every later variant subclasses TodoAppTest the same way. These tests need a browser, so they run in the test runner of Helios, on the page. Under Node they count as abstract, and the runner skips them.

Running it

Clone the repository and run npm run init, then amber serve. Open http://localhost:4000/silk.html. Type Milk and press Enter, then Bread. Check Milk: the counter says “1 item left” and “Clear completed” appears. Click Active: Milk goes, and the URL ends in #/active. Reload: Bread is still there, and Active is still selected. The todos are in localStorage under todos-amber-silk, as [{"id":1,"title":"Milk","completed":true},{"id":2,"title":"Bread","completed":false}]. grunt test runs the model’s tests, 60 of them, under Node.js. The test runner of Helios runs the 25 tests of SilkTodoAppTest on the page.