Now that we can see our data, we need to finish out a few bits of the UI. We want to allow users to:
- Edit tasks
- Delete tasks
- Add new tasks
FOR SAFARI USERS: This section asks you to select tasks and navigate them with your keyboard. If you are using WebKit/Safari to follow this tutorial, note that the Web Inspector (the debugger at the bottom of the window) has a bad habit of stealing your keyboard focus. This is a bug you may see while developing your app but most users will never encounter since they won't have the inspector open. If you have trouble getting keyboard focus on your list of task in Safari, either click on the "Google" search box at the top then press "Tab" or alternatively press "Cmd-L" to focus the Safari location bar and then press "Tab" twice. This will put your web page in focus.
Editing Tasks
Editing tasks is easy because it is managed for you by the ListView automatically. Just open your main_page page design and edit the contentView declaration to include the following options:
in apps/todos/resources/main_page.js:
contentView: SC.ListView.design({
contentBinding: 'Todos.tasksController.arrangedObjects',
selectionBinding: 'Todos.tasksController.selection',
contentValueKey: "description",
contentCheckboxKey: "isDone",
rowHeight: 21,
canEditContent: YES
}),
Turning on this option will cause the collection view to automatically call the beginEditing() method on your list item view whenever you double click on a selected item title or select an item and press return. By default, list item views will show an inline text editor so you can modify the content value.
To test this out, reload your application, then double click on a line item. You should see an inline editor appear as shown below:
Deleting Tasks
Depending on the kind of content you are working with, enabling deleting of tasks can be just as easy as editing. To enable deleting, you just need to add the "canDeleteContent" property to the ListView. In your main_page design, add this option to the ListView like so:
in apps/todos/resources/main_page.js:
contentView: SC.ListView.design({
contentBinding: 'Todos.tasksController.arrangedObjects',
selectionBinding: 'Todos.tasksController.selection',
contentValueKey: "description",
contentCheckboxKey: "isDone",
rowHeight: 21,
canEditContent: YES,
canDeleteContent: YES
}),
If you reloaded your app now, selected an item and pressed "delete", your console would show you an error complaining that "SC.RecordArray is not editable". Why would you get this error?
Remember back in step 3, we set the content of the tasksController to a RecordArray retrieved from the store. (It's in your main.js if you want to check it out.) This RecordArray is dynamically populated with any Tasks that are loaded into memory. Because it updates automatically, you can't manually modify its contents.
When you press delete, the ListView's default behavior is to simply try to remove any selected items from the content array. Since your ListView is bound to the tasksController and the tasksController is proxying the RecordArray, it throws an error: because the RecordArray we created is not editable.
To work around this, we're going to need to give the ListView a little assistance when it tries to delete an item. We'll do this by implementing a special API called the CollectionViewDelegate. (ListViews are a type of CollectionView.) Delegates are commonly used in SproutCore whenever we want to give another object an ability to have some precision control over how some component behaves.
In this case, we will make the tasksController a delegate for the ListView by adding the CollectionViewDelegate mixin to it. Next, we will implement the collection view delegate method that is called by the ListView whenever it wants to delete: collectionViewDeleteContent(). Now when you press delete, the ListView will see this method and call it instead.
Here is the code you should add to your tasksController. Note the addition of the CollectionViewDelegate mixin at the top as well:
in app/todos/controllers/tasks.js:
Todos.tasksController = SC.ArrayController.create(
SC.CollectionViewDelegate,
/** @scope Todos.tasksController.prototype */{
...
,
collectionViewDeleteContent: function(view, content, indexes) {
// destroy the records
var records = indexes.map(function(idx) {
return this.objectAt(idx);
}, this);
records.invoke('destroy');
var selIndex = indexes.get('min')-1;
if (selIndex<0) selIndex = 0;
this.selectObject(this.objectAt(selIndex));
}
});
So what's happening in this method?
The "indexes" passed to this method will be an instance of SC.IndexSet. IndexSet's are simply collections of index numbers for use with an array. (i.e. 1,2,3...) SproutCore uses IndexSet's whenever it needs to pass around lists of array indexes because it is more efficient than simply creating arrays of numbers.
The code here maps the indexes in the set to actual Task records. Then it calls destroy() on each record. This will remove the record from the store and, eventually, notify your server of the same change. Since the RecordArray we fetched in the main() function updates dynamically, destroying these tasks will automatically remove them from the list of tasks on screen.
The next thing this method does is update the selection set. Since all of the selected tasks will be removed, we need to tell the ListView what item to select next. This code just tries to select the task just before the first item that was deleted. (indexes.min is the smallest index number in the set.)
OK. Now you can delete. Reload your page, try selecting some items and press delete. They should disappear. Great job! Of course, with only three tasks, you're going to need to create more once you delete them, which brings us to...
Adding Tasks
Unlike these other two action, we actually have a button for adding tasks. All we need to do is wire it up to some code that will actually add it for us. Not surprisingly, we're going to place this code in the tasksController just after our delete method. Let's start with the code. We'll call this method "addTask()":
in apps/todos/controllers/tasks.js:
Todos.tasksController = SC.ArrayController.create(
SC.CollectionViewDelegate,
/** @scope Todos.tasksController.prototype */ {
...
,
addTask: function() {
var task;
// create a new task in the store
task = Todos.store.createRecord(Todos.Task, {
"description": "New Task",
"isDone": false
});
// select new task in UI
this.selectObject(task);
// activate inline editor once UI can repaint
this.invokeLater(function() {
var contentIndex = this.indexOf(task);
var list = Todos.mainPage.getPath('mainPane.middleView.contentView');
var listItem = list.itemViewForContentIndex(contentIndex);
listItem.beginEditing();
});
return YES;
}
});
OK, this is the most code we've written so far at one time. Let's break down what this does.
First, we create a new task. As you might expect, since this was added to the store, the RecordArray we are using will update automatically to add it. The parameters passed here are the task type and a "starter" JSON data hash.
Now that we have a new task, we want to select it in the UI. We do this just by setting the selection to the new task. Remember the tasksController.selection is bound to the ListView.selection, so changing the selection here will change it in the UI as well.
Finally, since this is a new task, we want to start editing it right away. To do that, we need to call "beginEditing()" on the list item view for the new task just like the ListView does when you double click on an item. This is what the last bit of code is for. It simply retrieves the ListView from the mainPage, then gets the list item for the task and calls beginEditing().
Since we've just created the new task at this point and all of our layers are connected by bindings, the collection view will not actually know about the new task yet. This is why we use invokeLater(). invokeLater() works much like setTimeout() but it is much more efficient. In this call, the function we pass will be executed basically right away but only AFTER SproutCore has updated the ListView and RecordArray with the new task.
One last thing we need to do before we reload our app and try this out. We need to tell the "Add Task" button in our UI to invoke this method whenever we click on it. You do this in SproutCore by setting what's called a target and action on the button. The target is the object you want to call. The action is the method name.
Just open your main_page design, find the addButton, and add the following two lines of config:
in apps/todos/resource/main_page.js:
addButton: SC.ButtonView.design({
layout: { centerY: 0, height: 24, right: 12, width: 100 },
title: "Add Task",
target: "Todos.tasksController",
action: "addTask"
})
Now reload your application and press the "Add Task" button. If you've done it all right, you should see a shiny new task WITH the inline editor already activated for you to type. Enter your new task and press return. You're done!
Ordering Tasks
Currently, the tasks we display aren't listed in any particular order. (Actually they are sorted by ID but since IDs may not be stable, this is the same as having no order.) Since our task list is generated using a local find() we simply need to tell the query how to sort the Tasks before it displays them.
In your main.js file, you have a line that actually finds the tasks. It looks like this:
var tasks = Todos.store.find(Todos.Task);
Normally, the SC.Store find() method takes a Query object, which describes the types of records you want to retrieve. When you pass a simple Record like this, find() actually generates a local Query that returns all records of the specified type.
Since we want to provide more conditions to this query than before, we need to manually construct the Query object separately. Change the line above to the following:
var query = SC.Query.local(Todos.Task, { orderBy: 'isDone,description' });
var tasks = Todos.store.find(query);
The new line above manually creates the query object. If you called SC.Query.local() with only the record type, it would create a query that returns all Task records loaded into memory. The second parameter is a hash of extra conditions. In this case we passed the orderBy property, which contains a list of property keys that will be used to sort the tasks, separated by commas.
The query above will sort tasks by isDone (so that unfinished tasks come first) and then by description. To try it, reload your app and then change the completion status or edit the description. Nice!
Bonus: Triggering Boxes With the Space Bar
While we're working on keyboard control, one other nice feature would be to trigger changing the "isDone" property on selected tasks just by pressing the space bar. In SproutCore, CollectionViews (like ListView) have a target/action combo, just like buttons. A ListView's action is fired whenever you press the space bar or double click on an item.
Let's make the ListView's action toggle the "isDone" properties on our tasks.
First, let's add the action handler. Like before, this code is in the tasksController:
in app/todos/controllers/tasks.js:
Todos.tasksController = SC.ArrayController.create(
SC.CollectionViewDelegate,
/** @scope Todos.tasksController.prototype */ {
...
,
toggleDone: function() {
var sel = this.get('selection');
sel.setEach('isDone', !sel.everyProperty('isDone'));
return YES;
}
});
This little bit of enumerable-fu actually does quite a bit in only a few lines. The first line gets the current selection. The second line sets the 'isDone' property on every task in the selection. The everyProperty() method works just like the JavaScript standard every() method except it just gets a property value. It returns true only if EVERY isDone property in the selection is currently true.
Since we invert the everyProperty() call, the effect of this is action will be to set isDone to YES (i.e. true) if one or more of the selected tasks are NOT done. If ALL the tasks already have isDone set to YES, this method will set all of them to NO. It's really easier to see than explain.
Let's wire up this action so you can try it out. Open your main_page design again and this time add the action configs to the ListView:
in apps/todos/resources/main_page.js:
contentView: SC.ListView.design({
contentBinding: 'Todos.tasksController.arrangedObjects',
selectionBinding: 'Todos.tasksController.selection',
contentValueKey: "description",
contentCheckboxKey: "isDone",
rowHeight: 21,
canEditContent: YES,
canDeleteContent: YES,
target: "Todos.tasksController",
action: "toggleDone"
}),
That's all there is to it. Reload your application and select some tasks. Double click or press the space bar to fire your action.
Moving On
OK. try your handiwork. This is looking pretty good. You can add tasks, delete tasks, rename them, and, of course, check them off as done or not done. Great work!
Now that your SproutCore app is largely working, it's time to turn our attention to the backend. We need a server to work against.
Continue to next step: Step 6: Building the Backend »
Fork in the Road
Ok. Things are good, but it might be nice to explore things a bit more. Here is another fork in the road.
You can continue with the above link to build your back end now. You can even follow that trail all the way to building and deploying.
Or you can take a detour, and return back to that path later. In this detour we are going to explore dataSources, store, records, relationships and GUI components a bit more. This detour is based on step 5 before you add the backend.
Continue to next step: Alternate Step 6 Adding a SplitView, ObjectController »
Comments (27)
Pedro Luz said
at 12:23 pm on Sep 1, 2009
just changed this:
{description: "new task", "isDone":false, "order":1});
to this:
{"description": "new task", "isDone":false, "order":1});
fgoersdorf said
at 12:11 am on Sep 2, 2009
deleting objects seemed not to work for me. the items disappeared in the view but no call was made to my ruby-backend. to get destroy working correctly with the backend, i moved the "destroyOnRemoval: YES" out of the ListView.design and added it as an attribute into the tasksController.
CiiDub said
at 8:01 pm on Oct 1, 2009
Working through the tutorial and ran into a problem, that I could figure out. So I downloaded the sample code and ran the server from an untouched dir and the problem persisted, in firefox and safari.
At this stage the items in the listView will not reorder, or delete. The items are editable with a double click and I can add new items. When I try and delete, it preforms the browsers back command. When I try and reorder the listView items they are drag-able but nothing happens on the drop.
Charles Jolley said
at 12:51 am on Oct 3, 2009
Hello - I have updated this stage of the tutorial to reflect the new API. I took reordered out of this bit for now because it complicates the tutorial in the new API, but I did add some nifty actions on the CollectionView
Larry Siden said
at 8:08 am on Oct 27, 2009
Putting the "order_by" property in the query should order the records when they get pulled from the store, but what causes each task to drop to the bottom of the list when you check it at "done"? How could I make done tasks float to the top of the list? "orderBy : 'isDone, description DESC'" doesn't do it.
Charles Jolley said
at 11:56 am on Oct 27, 2009
try orderBy: 'isDone DESC, description'
Robert Feldt said
at 1:38 am on Oct 28, 2009
Unclear what is meant by "press delete" in this step. A reader might think you mean some button or what not. Do you mean "on the keyboard"?
cornbread said
at 12:32 am on Oct 31, 2009
found a bug...
If you doubleclick on a todo it will uncheck it and move it to the top and will duplicate it's name in the position it was in (so you will have a second one of the same name) until you click out of edit mode and it renames it.
Steve B. said
at 3:17 pm on Mar 3, 2010
I noticed the same behavior but it appears to be entire visual.
A fast double-click on an unselected task will cause the task to be marked as done which appears to move a copy of the task to the bottom of the list. The task in the current location becomes editable. This either resolves itself by clicking anywhere outside the edit zone or by actually editing the task and hitting return. In the latter case, the "copy" at the bottom of the list has the new, edited content. Additionally, the task that should have been displayed is revealed.
While no data is corrupted, this is certainly something that would appear to be visually confusing. However, since I'm working through a tutorial, I don't know enough about SproutCore to say where the bug is.
Richard Das said
at 3:26 am on Dec 1, 2009
just changed this:
added commas before function definitions in task controller, since they are elements of SC.ArrayController.create and will cause an error without. Perhaps it's obvious, but this was not clearly delineated. Sample code already includes commas.
Maik Gosenshuis said
at 5:33 pm on Dec 30, 2009
The ListView is not re-ordering the tasks when I mark one or more of them as done. Try this: make sure all tasks are set to unfinished, select the one at the top and then click its checkbox. You can see the selection switch to the bottom task (which is the correct behaviour), but the tasks themselves are not re-ordered. I tried this with both my own code and the samples-todos code from git. I'm running the latest gem of sproutcore (1.0.1042).
Is anyone else having this problem?
Fredrik W said
at 7:36 am on Jan 1, 2010
@Maik: I'm having the same problems. Files a ticket on lighthouse.
Fredrik W said
at 7:36 am on Jan 1, 2010
I filed a ticket, not files. Sorry.
mrtom said
at 5:37 pm on Jan 8, 2010
@Fredrik W, do you have a link to that ticket please? I'm having the same problem and would like to follow the progress.
Thanks,
mrtom
mrtom said
at 6:59 am on Jan 9, 2010
The ticket is here: https://sproutit.lighthouseapp.com/projects/11697/tickets/356-bug-local-querys-order-by-properties-doesnt-work-work-properly-with-bindings
Steve B. said
at 3:21 pm on Mar 3, 2010
I'm somewhat mystified by the "Bonus" section. As a Cocoa programmer, I understand the target/action mechanism...I just have no idea how I bound this to the space bar. What if I wanted to bind the action to the "d" key?
Sarah said
at 4:51 pm on May 14, 2010
Agreed, and anyway, this isn't working for me. Where is the code that says to watch for the spacebar?
Sarah said
at 5:06 pm on May 14, 2010
And to check I just downloaded the samples-todos from github. The space bar thing doesn't work on either Safari or Firefox (Mac), and in fact, the samples-todos app doesn't allow editing or adding new tasks - the only thing that seems to work on it is checking or un-checking, and even that doesn't re-sort.
Might need to check the sample files people.
Sarah said
at 5:11 pm on May 14, 2010
Update: I realised I hadn't checked out the step 5 sample, but when I did, even the stuff that had worked before stopped working. Now I get "loading..." then a blank blue screen. Ah well, I'll head on with the tutorial and come back to this later.
Sarah said
at 7:24 pm on May 16, 2010
Now it's working after a restart of the server & Safari....
So I guess it was a development glitch rather than a problem that could occur in a complete app, but I still would like to know what ties the space bar to this action.
HecHu said
at 2:19 pm on Apr 21, 2010
Hello , canEditContent: YES don't work, i am tried with double click and return, someone know fix it ?? Thanks !!!!
Kreiquadratur said
at 5:04 am on Apr 25, 2010
I have the same issue. I tried Chromium (5.0.382.0 (44892)), Safari (4.1), Firefox (3.6). Could there be a change of edit content in the 1.0 release?
Brandon said
at 9:48 pm on Apr 30, 2010
in sproutcore/desktop/frameworks/views/list_item.js, around line 702 (between the contentHitTest method and the beginEditing method), I added this:
inlineEditorShouldBeginEditing: function() {
return this.contentIsEditable();
},
This fixed the problem.
HecHu said
at 9:48 pm on May 31, 2010
Thanks, realy this fix the problem
Mike Pearson said
at 1:38 am on May 8, 2010
@Krelquadratur @Brandon I hit the same issue and Brandon's fix worked for me too. Now, where do we post it so it gets back into the distribution? GitHub?
Daniel said
at 7:09 am on Jul 9, 2010
As soon as I add canEditContent: YES it doesn't work. I can't find the file Brandon says so I can fix this. Anyone know what to do?
Daniel said
at 7:26 am on Jul 9, 2010
Never mind, figured it out
You don't have permission to comment on this page.