• If you are citizen of an European Union member nation, you may not use this service unless you are at least 16 years old.

  • You already know Dokkio is an AI-powered assistant to organize & manage your digital files & messages. Very soon, Dokkio will support Outlook as well as One Drive. Check it out today!

View
 

Todos 05-Finishing the UI

Page history last edited by Richard Hightower 13 years, 7 months ago

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

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.