Sproutcore Tutorial: Custom ListView with Reordering

I’ve been reading a few different blog posts on creating a custom list view and re-ordering. I’ve decided to consolidate what I’ve learnt into this tutorial.

Get the code here.

See it in action here.

This sample app allows you to:

  1. View a list with custom row views.
  2. Reorder items in the list by way of drag and drop.
  3. Select and delete by pressing the DELETE key.

This sample app demonstrates:

  1. How to implement a custom SC.ListView row.
  2. How to hi-light an item when hovering with a mouse.
  3. How to implement drag-and-drop re ordering.

1. Prerequisite Reading

  • ToDo tutorial on the Sproutcore wiki
  • My CRUD tutorial

2. Model

The app simulate my NBA power ranking. Guess which team I like!

The data model is really simple. There is only a single team record containing the following attributes:

  • Ranking: numeric ranking – 1 is the best team.
  • Name: name of team.
  • Reason: why the team is ranked so.

The team record is defined in reorder_sample/models/team_record.js.

ReorderSample.TeamRecord = SC.Record.extend(
  /** @scope ReorderSample.Team.prototype */ {

  ranking: SC.Record.attr(Number, { isRequired: YES }),
  name: SC.Record.attr(String, { isRequired: YES }),
  reason: SC.Record.attr(String, { isRequired: YES })

});

Fixtures for 5 teams is defined in reorder_sample/fixtures/team_record.js.

sc_require('models/team_record');

ReorderSample.TeamRecord.FIXTURES = [
  {
    guid: 1,
    ranking: 1,
    name: 'LA Lakers',
    reason: 'KB24. Enough said.'
  },

  {
    guid: 2,
    ranking: 2,
    name: 'Boston Celtics',
    reason: "Aren't these guys getting a bit old?"
  },

  {
    guid: 3,
    ranking: 3,
    name: 'Miami Heat',
    reason: 'The three headed monster has moved to South Beach.'
  },

  {
    guid: 4,
    ranking: 4,
    name: 'OKC Thunder',
    reason: 'The Durantula is hungry.'
  },

  {
    guid: 5,
    ranking: 5,
    name: 'Dallas Mavericks',
    reason: "Mark Cuban's cheering must be worth some points."
  }
];

3. Controller

This is where the majority of the work gets done.

  • The teamArrayController instances SC.ArrayController with the SC.CollectionViewDelegate mixin.
  • The SC.CollectionViewDelegate mixin provides the functionality we need to handle delete and reordering
  • Delete is handled in the collectionViewDeleteContent() method. Details are provided in the Sproutcore wiki.
  • Reordering is handled by collectionViewDragDataTypes(), collectionViewDragDataForType() and collectionViewPerformDragOperation().
  • collectionViewDragDataTypes() is called when drag is started to check if the row being dragged is “draggable”. The type of record contained in the row must be in the array passed back by this method.
  • collectionViewDragDataForType() is called next if the above check was passed. This method allows us to return some contextual data associated with the drag event. In this case, we return a SC.SelectionSet containing the selected team record. Because we have disallowed multiple selections, we should only ever have 1 team record in the selection set.
  • Finally collectionViewPerformDragOperation() is called when the user drops the row. We retrieve our selection set saved in collectionViewDragDataForType() and extract the record to reorder. We then change the rankings accordingly. Note that we turn off property change notifications during the reorder to improve performance.

The code is located in reorder_sample/controllers/team_array.js.

ReorderSample.teamArrayController = SC.ArrayController.create(SC.CollectionViewDelegate, {

  /**
   * Only allow 1 row to be selected at anyone time.
   * SC.CollectionView looks for this variable in it's content (which is this array)
   */
  allowsMultipleSelection: NO,

  /**
   * Allows this controller to properly respond to ListView delete
   * See http://wiki.sproutcore.com/Todos+05-Finishing+the+UI
   * @param view View calling the delete
   * @param content
   * @param indexes Indexes of the item to be deleted
   */
  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));
  },

  /**
   * Specifies that we are allowed to drag teams
   */
  collectionViewDragDataTypes: function(view) {
    return [ReorderSample.TeamRecord];
  },

  /**
   Called by a collection view when a drag concludes to give you the option
   to provide the drag data for the drop.

   This method should be implemented essentially as you would implement the
   dragDataForType() if you were a drag data source.  You will never be asked
   to provide drag data for a reorder event, only for other types of data.

   The default implementation returns null.

   @param view {SC.CollectionView}
   the collection view that initiated the drag

   @param dataType {String} the data type to provide
   @param drag {SC.Drag} the drag object
   @returns {Object} the data object or null if the data could not be provided.
   */
  collectionViewDragDataForType: function(view, drag, dataType) {
    var ret = null;

    if (dataType === ReorderSample.TeamRecord) {
      return view.get('selection');
    }

    return ret;
  },

  /**
   Called by the collection view to actually accept a drop.  This method will
   only be invoked AFTER your validateDrop method has been called to
   determine if you want to even allow the drag operation to go through.

   You should actually make changes to the data model if needed here and
   then return the actual drag operation that was performed.  If you return
   SC.DRAG_NONE and the dragOperation was SC.DRAG_REORDER, then the default
   reorder behavior will be provided by the collection view.

   @param view {SC.CollectionView}
   @param drag {SC.Drag} the current drag object
   @param op {Number} proposed logical OR of allowed drag operations.
   @param proposedInsertionIndex {Number} an index into the content array representing the proposed insertion point.
   @param proposedDropOperation {String} the proposed drop operation.  Will be one of SC.DROP_ON, SC.DROP_BEFORE, or SC.DROP_ANY.
   @returns the allowed drag operation.  Defaults to proposedDragOperation
   */
  collectionViewPerformDragOperation: function(view, drag, op, proposedInsertionIndex, proposedDropOperation) {
    // content is just a reference to this object.
    var content = view.get('content');
    var ret = SC.DRAG_NONE;

    // Continue only if data is available from drag
    var selectionSet = drag.dataForType(ReorderSample.TeamRecord);
    if (!selectionSet) {
      return ret;
    }

    // Get our record - there should only be 1 selection
    var record = selectionSet.firstObject();

    // Suspend notifications for bulk changes to properties
    content.beginPropertyChanges();

    // Re ordering
    var oldIndex = record.get('ranking') - 1;  // -1 to convert from ranking # to index
    if (proposedInsertionIndex < oldIndex) {
      // Move up list
      for (var i = proposedInsertionIndex; i < oldIndex; i++) {
        this.objectAt(i).set('ranking', i + 1 + 1);  // add 1 to convert from ranking to sequence #
      }
    } else {
      // Move down list
      for (var i = oldIndex + 1; i <= proposedInsertionIndex; i++) {
        this.objectAt(i).set('ranking', i - 1 + 1);  // add 1 to convert from ranking to sequence #
      }
    }
    record.set('ranking', proposedInsertionIndex + 1);

    // Restart notifications
    content.endPropertyChanges();

    // Return the requested op, usually SC.DRAG_REORDER, to flag that the event has been handled
    return op;
  },

  /**
   * Denotes if the data source is ready
   */
  isReady: function() {
    var status = this.get('status');
    return status & SC.Record.READY;
  }.property('status').cacheable(),

  /**
   * Provides a summary of the status of the controller.
   */
  summary: function() {
    var ret = '';

    var status = this.get('status');
    if (status & SC.Record.READY) {
      var len = this.get('length');
      if (len && len > 0) {
        ret = len === 1 ? "1 team" : "%@ teams".fmt(len);
      } else {
        ret = "No teams";
      }
    }
    if (status & SC.Record.BUSY) {
      ret = "Loading..."
    }
    if (status & SC.Record.ERROR) {
      ret = "Error"
    }

    return ret;
  }.property('length', 'status').cacheable()  

});

4. View

4.1 Main Page and ListView

The main page and pane is setup as per the ToDo example in the Sproutcore wiki.

  • The contentView is a SC.ListView that is bound to our teamArrayController.
  • The rowHeight is set to 91. This is 90px for the custom row view and 1 px in order for the bottom border of each row to show.
  • The exampleView is set to our custom row view class: ReorderSample.TeamView
  • The canReorderContent and isEditable is set to YES to enable reorder functionality in the SC.CollectionView from which SC.ListView is derived.

The code can be found at reorder_sample/resources/main_page.js.

ReorderSample.mainPage = SC.Page.design({

  // The main pane is made visible on screen as soon as your app is loaded.
  // Add childViews to this pane for views to display immediately on page
  // load.
  mainPane: SC.MainPane.design({
    childViews: 'middleView topView bottomView'.w(),

    topView: SC.ToolbarView.design({
      layout: { top: 0, left: 0, right: 0, height: 36 },
      childViews: 'titleLabel'.w(),
      anchorLocation: SC.ANCHOR_TOP,

      titleLabel: SC.LabelView.design({
        layout: { centerY: 0, height: 24, left: 8, width: 300 },
        controlSize: SC.LARGE_CONTROL_SIZE,
        fontWeight: SC.BOLD_WEIGHT,
        value: 'Veeb\'s Power Rankings'
      })
    }),

    middleView: SC.ScrollView.design({
      hasHorizontalScroller: NO,
      layout: { top: 36, bottom: 32, left: 0, right: 0 },
      backgroundColor: 'white',

      contentView: SC.ListView.design({
        layout: { left: 15, right: 15, top: 15, bottom: 15 },
        contentBinding:   'ReorderSample.teamArrayController.arrangedObjects',
        selectionBinding: 'ReorderSample.teamArrayController.selection',
        selectOnMouseDown: YES,
        canDeleteContent: YES,
        rowHeight: 91,
        exampleView: ReorderSample.TeamView,
        recordType: ReorderSample.TeamRecord,
        canReorderContent: YES,
        isEditable: YES
      })
    }),

    bottomView: SC.ToolbarView.design({
      layout: { bottom: 0, left: 0, right: 0, height: 32 },
      childViews: 'summaryLabel'.w(),
      anchorLocation: SC.ANCHOR_BOTTOM,

      summaryLabel: SC.LabelView.design({
        layout: { centerY: 0, height: 18, left: 20, right: 20 },
        valueBinding: 'ReorderSample.teamArrayController.summary'
      })
    })

  })

});

4.2 Custom ListView Row

ReorderSample.TeamView is where we implement our custom row view.

  • contentDisplayProperties lists all the properties in our content (TeamRecord) that we are going to display in our custom view. It allows the row to be re-rendered if any of the listed properties changes.
  • displayProperties lists the properties of this custom view that requires the row the be re-rendered if the property is changed. We’ve listed the isHovering property because if that changes, we will have to change the background of this row to show/not show hi-lighting.
  • Hovering is triggered when the user moves the mouse over/out of the row. As such, we handle the mouseEnteredand mouseExited events and set the isHovering property accordingly. Because isHovering is listed in displayProperties, it will cause the render() method to be called.
  • The render() method gets the data for the row from the content property (set by the ListView). The context is used to render the html.
  • Other useful properties set by ListView are: contentIndex, layerId, isEnabled, isSelected, outlineLevel, disclosureState, isVisibleInWindow, isGroupView, page and parentView. See SC.CollectionView.itemViewForContentIndex().

The code can be found at reorder_sample/views/team.js.

ReorderSample.TeamView = SC.View.extend(SC.ContentDisplay, {

  layout: { left: 10 },

  classNames: ['team-view'],

  /**
   * List of content (TeamRecord) properties that needs to trigger a re-rendering when the property is changed
   *
   * Add an array with the names of any property on the content object that
   * should trigger an update of the display for your view.  Changes to the
   * content object will only invoke your display method once per runloop.
   *
   * @property {Array}
   */
  contentDisplayProperties: 'ranking name reason'.w(),

  /**
   * Flag so that we know if the mouse if hovering over this item or not
   */
  isHovering: NO,

  /**
   * List of view properties that needs to trigger a re-rendering when the property is changed.
   * We want to re-render when hovering so that we can hi-light the row
   */
  displayProperties: 'isHovering isSelected'.w(),

  /**
   * When the mouse is over this view, set the isHovering flag in order to trigger hi-lighting
   */
  mouseEntered: function() {
    this.set('isHovering', YES);
  },

  /**
   * When the mouse leaves this view, set the isHovering flag in order to turn off hi-lighting
   */
  mouseExited: function() {
    this.set('isHovering', NO);
  },

  /**
   * Render our row
   *
   * @param context
   * @param firstTime
   */
  render: function(context, firstTime) {
    var ranking = '';
    var name = '';
    var reason = '';
    var content = this.get('content');
    if (content != null) {
      ranking = content.get('ranking') + '.';
      name = content.get('name');
      reason = content.get('reason');
    }

    // If hovering, add the hovering CSS class to the DIV that is this view
    if (this.get('isSelected')) {
      context.setClass('team-view-selected', this.get('isSelected'));
    } else {
      context.setClass('team-view-hover', this.get('isHovering'));
    }

    // Output HTML. Create inner DIV to show our data
    context = context.begin('div').addClass('team-view-topfiller').push('&nbsp;').end();
    context = context.begin('div').addClass('team-view-line1').push(ranking + ' ' + name).end();
    context = context.begin('div').addClass('team-view-line2').push(reason).end();
    context = context.begin('div').addClass('team-view-bottomfiller').push('&nbsp;').end();

    sc_super();
  }
});

5. Starting Up

The last thing we need to do is to hook up the data to our controller in reorder_sample/main.js.

Note that we order by ranking. When the ranking of a record is changed during a reorder, the ListView will automatically re-render each row because the order of each item in the SC.RecordArray returned by the query would have changed.

ReorderSample.main = function main() {

  // Step 1: Instantiate Your Views
  // The default code here will make the mainPane for your application visible
  // on screen.  If you app gets any level of complexity, you will probably
  // create multiple pages and panes.
  ReorderSample.getPath('mainPage.mainPane').append();

  // Step 2. Set the content property on your primary controller.
  // This will make your app come alive!
  var query = SC.Query.local(ReorderSample.TeamRecord, {orderBy: 'ranking'});
  var teamRecords = ReorderSample.store.find(query);
  ReorderSample.teamArrayController.set('content', teamRecords);
};

function main() {
  ReorderSample.main();
}

Sproutcore: Cannot call method ‘create’ of undefined

I was playing around with a SC.View (MyApp.AView) which has a SC.ListView with a custom exampleView (MyApp.BView).

I kept getting the error when SC.ListItem was trying to render the child view.

Cannot call method 'create' of undefined

Turned out to be a script sequencing problem. MyApp.BView was instanced AFTER MyApp.AView. It needed to be instanced before MyApp.AView.

The fix is to use sc_require to make sure that dependencies are enforced.

sc_require('views/b');

That was a good morning’s work!

Thoughts on Node.js

Here’s my research and thoughts on Node.js to date.

Some details of the Ryan Dahl, creator of Node.js here. Here are the 9 biggest challenges for Node.js from Ryan from his JSConf 2010 EU talk.

Best article that explains event loop based web servers and why you can get more performance out of them compared to thread based web servers.

Some Thread Based Web Servers

  • Apache
  • Microsoft IIS
  • Tomcat (however there is a comet event based module that can be used)

Some Event Based Web Servers

Am I ready to use Node.js?

I really want to use Node.js. It seems right to be using javascript on the client (with Sproutcore) and server side.

However, I’ve turned into a soft programmer that needs a visual debugger. My excuse is that I’m now on the wrong side of 40. I’ll wait it out a bit long to see if a javascript visual debugger for Node.js script eventuates before switching over.

In the meantime, I think I will look into using Twisted and Grizzly (and the associated Jersey project).

What’s with the guid/uuid directory created by sc-build?

When I run sc-build, the output is placed in ./tmp/build/static/app_name/en/b82a3bf91217954903a4fd7da14707caa9f47520.

This page in the Sproutcore wiki suggests that the guid/uuid directory is the build number.

Deploying

  • When you are ready to deploy your app, all you need to do is run sc-build.
  • sc-build generates a bundle of HTML, JS, and CSS inside a directory with a MD5 hash code as a build number.
  • The hash code is based on the contents of the files, so changing your files will change the hash code.
  • When you deploy your app, you just need to copy the entire contents of the tmp/build directory to a static web server.
  • Your web server should be configured to serve everything EXCEPT for the index.html file with a 1-year expiration header. This will allow your assets to be cached “permanently” on browsers once they load them.
  • Since the index.html file references the resources it needs by build number, deploying a new version of your app will automatically load the new assets.
  • Once you’ve copied your files out, you should symlink the build directory for the app you want to load to the actual URL you want the user to visit to load your app.
  • Example: if you want ppl to visit your app at http://myapp.com/appname and your built project has a directory called static/appname/123efab45aeb29c3de4, then you should symlink /appname -> /static/appname/123efab45aeb29c3de4.
  • You can actually have multiple built versions of your app deployed this way since each one will have a different build number. Just create a different symlink to each version you want to load.

So, if you specify a build number when using sc-build, the guid/uuid will be replaced with a nice looking build number.

For example, sc-build –build=0001 -rcv –languages=en will create the directory structure:

./tmp/build/static/app_name/en/0001.

Sproutcore CRUD tutorial using SC.TableView

Note

This tutorial is for Sproutcore 1.X. I’ve written a similar tutorial for Sproutcore 2 here.

OK. I’ve reach the point in my Sproutcore understanding where I feel I can attempt to write a CRUD demo app.

Get the code here.

See it in action here.

The demo app allows the user to:

  1. Create a user record via a modal “details” pane
  2. Read user records by listing them in username order in a SC.TableView
  3. Update a user record via a modal “details” pane
  4. Delete a user record via pressing the DELETE key in SC.TableView or via a button on the modal “details” pane

The demo app implements:

  1. Field level validation – username must be [a-z]
  2. Page level validation – username is required
  3. Nested stores so that local changes can be discarded before commit.
  4. Simulated server response with 1 second latency
  5. Simulated server error – check for duplicate keys (username)
  6. Datasource error handling
  7. Display field, page and data source (server) errors to users

Here’s some screenshots.

 

 

1. Pre-requisites

Do the ToDo tutorial as well as read the programming guide (especially 03. DataStore) in wiki.sproutcore.com.

I’m assuming you have a basic knowledge of SC.Records, SC.Store and SC.DataSource.

Also, to use tables, you will need to modify your Buildfile as follows.

config :all, :required =>  [:sproutcore, 'sproutcore/table']

2. Data Model

The data model is represented by CrudSample.UserRecord. Points to note:

  • I am using the userID property as the primary key rather than the standard ‘guid’. To do this, I’ve set the primaryKey property to the name of my primary key.
  • The isAdminString property is required to help bind the isAdmin property to a SelectFieldView at the UI layer. It is stored as a boolean and this computed property allows us to convert a boolean to a string that can be used to bind to a SelectFieldView.
  • I’ve added the function to backup and restore properties. I found I needed this feature to restore a record to its original state if there was an error after committing the record. For example, when a user edits a record, changes the username to one that already exists, and commits the record the data source will throw an exception because of a duplicate username. If I then present the user record to the user to edit again and the user clicks Discard, I want to restore the original values to the record in the store.
CrudSample.UserRecord = SC.Record.extend({

  properties: 'userID username department userStatus isAdminString lastLoggedInDate'.w(),

  primaryKey: 'userID',

  userID: SC.Record.attr(Number),     //not required on create

  username: SC.Record.attr(String, { isRequired: YES }),

  department: SC.Record.attr(String),

  userStatus: SC.Record.attr(String, { isRequired: YES, key: "status" }),

  isAdmin: SC.Record.attr(Boolean, { defaultValue: NO,  isRequired: YES }),

  isAdminString: function(key, value) {
    // writing
    if (value !== undefined) {
      this.writeAttribute('isAdmin', value == 'YES' ? YES : NO);  // write into data hash
    }

    // reading
    value = this.readAttribute('isAdmin');
    value = value ? 'YES' : 'NO';

    return value;
  }.property().cacheable(),

  lastLoggedInDate: SC.Record.attr(SC.DateTime, {userIsoDate: YES}),

  /**
   * Return an object containing a backup of the properties
   * @returns SC.Object object containing properties to backup
   */
  backupProperties: function() {
    var backup = SC.Object.create();
    for (var i = 0; i < this.properties.length; i++) {
      var p = this.properties[i];
      backup.set(p, this.get(p));
    }
    return backup;
  },

  /**
   * Restores properties from a backup crated by backupProperties().
   */
  restoreProperties: function(backup) {
    for (var i = 0; i < this.properties.length; i++) {
      var p = this.properties[i];
      this.set(p, backup.get(p));
    }
    return;
  }
});

3. Fixtures and Data Source

3.1 Patch Sproutcore 1.4.2 Bug

There is a small bug around the caching of record status. I’ve submitted a pull request to fix it. In the mean time, you will have to edit veebs-sproutcore-samples/frameworks/sproutcore/frameworks/datastore/models/record.js. Don’t make the status property cacheable as below.

  status: function() {
    return this.store.readStatus(this.storeKey);
  }.property(),   //.property('storeKey').cacheable(),

3.2 Create our own Data Source to simulate calling remote server

I created numeric_id_fixtures.js and extended SC.FixturesDataSource to get it to simulate a server executing server side business rules.

  • I override the simulateRemoteResponse and latency properties to simulate a delay of 1 second. I like to set a longer delay so we can see what happens at the UI layer in a worse case scenario.
  • I override the generateIdFor() function so we allocate numbers for our primary key rather than a string (the default FixturesDataSource behaviour). This will mimic a database auto increment primary key.
  • I override _createRecords() and _updateRecords() functions to allow checking for unique usernames. In these functions, we call store.dataSourceDidComplete() if update was successful and store.dataSourceDidError() if there was an error. Both these store functions sets the user record’s status property to READY_CLEAN or ERROR respectively.
CrudSample.NumericIdFixturesDataSource = SC.FixturesDataSource.extend(
  /** @scope CrudSample.AutoIdFixturesDataSource.prototype */ {

  /**
   * Let's simulate calling a remote server for CRUD operations
   */
  simulateRemoteResponse: YES,

  /**
   * Assume we have a slow server that takes 1 second to respond
   */
  latency: 1000,

  /**
   * The next number to allocate to a primary key
   */
  nextNumber: 1000000,

  /**
   * Override this method so that we can allocate ID based on a number that starts at 1,000,000.  We don't start at
   * 1 because that is within range of our primary key in our fixtures.  We also want to return a number and not a
   * string.
   *
   * @param recordType
   * @param dataHash
   * @param store
   * @param storeKey
   */
  generateIdFor: function(recordType, dataHash, store, storeKey) {
    return this.nextNumber++;
  },

  /**
   * Override _createRecords so that we can check for unique usernames
   *
   * @param store
   * @param storeKeys
   */
  _createRecords: function(store, storeKeys) {
    storeKeys.forEach(function(storeKey) {
      try {
        var id = store.idFor(storeKey),
          recordType = store.recordTypeFor(storeKey),
          dataHash = store.readDataHash(storeKey),
          fixtures = this.fixturesFor(recordType);

        this.validateUniqueUsername(dataHash);

        if (!id) {
          id = this.generateIdFor(recordType, dataHash, store, storeKey);
        }
        this._invalidateCachesFor(recordType, storeKey, id);
        fixtures[id] = dataHash;
        store.dataSourceDidComplete(storeKey, null, id);

      } catch (e) {
        // We have an error
        store.dataSourceDidError(storeKey, e);
      }
    }, this);
  },

  /**
   * Override _updateRecords so that we can check for unique usernames
   * @param store
   * @param storeKeys
   */
  _updateRecords: function(store, storeKeys) {
    storeKeys.forEach(function(storeKey) {
      try {
        var hash = store.readDataHash(storeKey);
        this.validateUniqueUsername(hash);
        this.setFixtureForStoreKey(store, storeKey, hash);
        store.dataSourceDidComplete(storeKey);
      } catch (e) {
        // We have an error
        store.dataSourceDidError(storeKey, e);
      }
    }, this);
  },

  /**
   * Checks if the username is unique in local store
   * This simulates checking on the server side
   *
   * @param storeKey Store key of the user record to check
   * @throws SC.Error
   */
  validateUniqueUsername: function(dataHash) {
    var username = dataHash.username;
    var query = SC.Query.local(CrudSample.UserRecord, {
      conditions: '(username = {name})',
      name: username
    });
    var userRecords = CrudSample.store.find(query);
    var count = userRecords.get('length');
    if (count == 0) {
      return;
    } else {
      if (count == 1) {
        // Check that we are not matching ourselves
        var dataHashPrimaryKey = dataHash.userId;
        if (dataHashPrimaryKey && dataHashPrimaryKey != undefined) {
          var primaryKey = userRecords.objectAt(0).get('id');
          if (dataHashPrimaryKey != primaryKey) {
            throw SC.Error.desc('Username already exists', 'username');
          }
        }
      } else {
        // Error - more than 1 match for whatever reason
        throw SC.Error.desc('Username already exists', 'username');
      }
    }
  }
});

3.3 Create Store using Fixture Data

I create my sample initial user data in fixtures/user.js. These entries simulate what will be returned from a real life server.

CrudSample.UserRecord.FIXTURES = [

  {
    userID: 1,
    username: "michael",
    department: "Accounts",
    status: "Active",
    isAdmin: YES,
    lastLoggedInDate: "2010-01-01T10:20:30Z"
  },

  ... more records ...

];

We then load the fixture data into our store in core.js.

CrudSample = SC.Application.create(
  /** @scope CrudSample.prototype */ {

  NAMESPACE: 'CrudSample',
  VERSION: '0.1.0',
  store: SC.Store.create().from('CrudSample.NumericIdFixturesDataSource')

});

4. Controllers

Controllers is where I keep the client side business rules.

4.1 Controller for List of User Records

controllers/user_array.js is where I’ve defined CrudSample.userRecordArrayController.

  • This is a standard record array controller like the one in the ToDo tutorial in the wiki.
  • I’ve added the isReady property to allow us to check if our data source is busy waiting for records to be returned from the server. If busy, I notify the user appropriately in the view layer.
  • I’ve added the summary property to return the latest status to the user. If we are busy waiting on the data source to fetch data from the server, we show “Loading…”. If the data source is ready and has fetched records, show the number of users.

4.2 Controller for Reading a Single User Record

controllers/user.js is where I’ve defined CrudSample.userRecordController.

It is a standard object controller (like the one in the ToDo tutorial) bound to the selected user record in CrudSample.userRecordArrayController.

Every time the selected record is changed in the userRecordArrayController, the userRecordController will point to the selected record.

4.3 Controller for Creating or Updating a Single User Record

If I display and edit the selected user record in the userRecordController, every time the user makes a change, all UI elements bound to the record will be updated. I don’t want this to happen until the user saves the record.

Also, if the user decides to cancel editing a record, it is difficult to rollback changes.

controllers/user.js is where I’ve defined CrudSample.userViewController to help overcome these problems. I’ve called it a view controller because it is used by the view layer to create/update records. The view controller is my wrapper around the nested data store which gives us the ability to make batch changes before committing.

  • I’m using a nested store so that that I can batch commit/rollback changes. You can see in the createNew() and updateCurrent() functions, a nested store is created.
  • I’ve implemented callback functionality upon saving. The view layer just needs to pass a target object and method to the save() function and it will be called back when saving is complete. This simplifies the view layer.
  • To trigger the callback, I’ve implemented the savingUserRecordStatusDidChange property that observes the status property of the user record being saved. I will invoke the callback when the record is no longer busy – i.e. the status property is set to READY_CLEAN or ERROR
  • If there was an error during saving, I need to modify the status property of the record so that the user can make further changes to the record and fix the user. This is where fixSaveError() comes in. If the user was creating a record, I destroy the previously committed record, create a new record and restore the data so the user does not have to re-key. If the user was updating the previously committed record, I change the status property of the record from ERROR to READ_DIRTY.
  • If the user decides to discard changes, I only need to discard the record in the nested store. However, the user was updating the record and there was an error during save, the record in the store will be left with a status of READY_DIRTY. I need to restore the properties and reset the record status to READY_CLEAN. A better way will be to reload the record from the server but this is not possible in this simulated demo app.
  • I’ve added validateUsername() and validateRecord() provide sample record and field level validation.
  • Note that in the updateCurrent() function, I used the id property to retrieve the primary key rather than the userId property. This is because Sproutcore does not keep the primary key in the record up to date with that has been set in the store. See here.
CrudSample.userViewController = SC.ObjectController.create({

  /**
   * Boolean flag to denote if the record has change (is dirty)
   */
  contentIsChanged: NO,

  /**
   * Nested store that was used to retrieve the record
   */
  nestedStore: null,

  /**
   * Empty object that we use to set the content so that any views bound to this content will not throw an
   * exception like "cannot call method 'get' of null" when it tries to get a property value
   */
  emptyContent: SC.Object.create(),

  /**
   * Current nested content we are proxying
   */
  content: this.emptyContent,

  /**
   * Target to callback when saving is finished
   */
  savingCallbackTarget: null,

  /**
   * Method of target to callback when saving is finished
   */
  savingCallbackMethod: null,

  /**
   * The user record in the store that in being saved. NOTE - this is the actual record out of the store and not
   * the temporary record from the nested store.
   */
  savingUserRecord: null,

  /**
   * If editing, we store the original properties just in case saving fails and we have to restore the original values
   */
  originalProperties: null,

  /**
   * Sets up the content of this controller with a new user record to create
   */
  createNew: function() {
    var ns = CrudSample.store.chain();
    var nsUserRecord = ns.createRecord(CrudSample.UserRecord, {
      status: 'Active',
      isAdmin: NO
    });

    this._reset(ns, nsUserRecord, null);
  },

  /**
   * Sets up the content of this controller with the current record in userController
   * for updating.
   */
  updateCurrent: function() {
    // Get the primary key using the 'id' property and NOT 'userID' because sproutcore caches the primary key in an
    // array within the store. When records are created,the primary key in the array is updated but the primary key
    // property in the record is NOT!
    var ns = CrudSample.store.chain();
    var pk = CrudSample.userRecordController.get('id');
    var nsUserRecord = ns.find(CrudSample.UserRecord, pk);

    this._reset(ns, nsUserRecord, nsUserRecord.backupProperties());
  },

  /**
   * Reset our state so that we are ready to add or edit a record
   *
   * @param {SC.NestedStore} Nested Data store for this editing session
   * @param {CrudSample.UserRecord} user record from the nested store that we are going to edit. This will be set
   *  as the content of this controller.
   * @param {SC.Object} object containing properties of the original record we are editing
   */
  _reset: function(ns, nsUserRecord, originalProperties) {
    this.set('savingUserRecord', null);
    this.set('savingCallbackTarget', null);
    this.set('savingCallbackMethod', null);
    this.set('nestedStore', ns);
    this.set('content', nsUserRecord);
    this.set('originalProperties', originalProperties);
  },

  /**
   * Saves changes back to the parent store
   *
   * @param {Object} callbackTarget Insurance of object containing the callbackMethod
   * @param {Method} callbackMethod Method to callback when save is complete.
   */
  save: function(callbackTarget, callbackMethod) {
    this.validateRecord();

    var nsUserRecord = this.get('content');
    var ns = this.get('nestedStore');
    ns.commitChanges();
    ns.destroy();

    var userRecord = CrudSample.store.find(nsUserRecord);
    this.set('savingUserRecord', userRecord);
    this.set('savingCallbackTarget', callbackTarget);
    this.set('savingCallbackMethod', callbackMethod);
    userRecord.commitRecord();
  },

  /**
   * Wait for user record being save to be saved and check for error
   */
  savingUserRecordStatusDidChange: function() {
    var userRecord = this.get('savingUserRecord');
    if (userRecord != null) {
      var callbackTarget = this.get('savingCallbackTarget');
      var callbackMethod = this.get('savingCallbackMethod');
      var status = userRecord.get("status");
      if (status === SC.Record.READY_CLEAN) {
        // Saved OK - select object in UI
        CrudSample.userRecordArrayController.selectObject(userRecord);

        // Callback UI to clean up
        callbackMethod.call(callbackTarget, null);

        // Init variables to get read for next view session
        this._reset(null, this.emptyContent, null);
      } else {
        // Error
        if (userRecord.get('isError')) {
          callbackMethod.call(callbackTarget, userRecord.get('errorObject'));
          this.fixSaveError(userRecord);
        }
      }
    }
  }.observes('*savingUserRecord.status'),

  /**
   * Sets up the content of this controller with the current record for which there's been an error.
   * When an error happens, the error record is sitting in our main store.
   *
   * @param {CrudSample.UserRecord} userRecord in the store that is to be loaded for fixing
   */
  fixSaveError: function(userRecord) {
    var store = userRecord.get('store');
    var isCreating = SC.none(userRecord.get('id'));
    var nsUserRecord;

    var ns = CrudSample.store.chain();

    if (isCreating) {
      // Was adding. Delete from store and try again.
      // Backup and restore properties so the user don't have to re-key
      var backup = userRecord.backupProperties();
      userRecord.destroy();
      nsUserRecord = ns.createRecord(CrudSample.UserRecord, {});
      nsUserRecord.restoreProperties(backup);
    } else {
      // Was updating, we change status to READY_DIRTY and try again
      nsUserRecord = ns.materializeRecord(userRecord.get('storeKey'));
      store.writeStatus(userRecord.get('storeKey'), SC.Record.READY_DIRTY);
      userRecord.propertyDidChange('status');
    }

    this._reset(ns, nsUserRecord, this.get('originalProperties'));
  },

  /**
   * Discard changes
   */
  discard: function() {
    var ns = this.get('nestedStore');
    ns.discardChanges();
    ns.destroy();

    // If updating and there was an error in the save, we have to restore our properties and reset the
    // record to clean (alternatively, we can reload the record from the data store)
    var nsUserRecord = this.get('content');
    if (!SC.none(nsUserRecord.get('id'))) {
      var userRecord = CrudSample.store.find(nsUserRecord);
      var store = userRecord.get('store');
      var status = userRecord.get("status");
      if (status === SC.Record.READY_DIRTY) {
        userRecord.restoreProperties(this.get('originalProperties'));
        store.writeStatus(userRecord.get('storeKey'), SC.Record.READY_CLEAN);
        userRecord.propertyDidChange('status');
      }
    }

    // Init for next time
    this._reset(null, this.emptyContent, null);
  },

  /**
   * Listens to changes in content and/or content status property of the ObjectController by observing the
   * pattern "*content.status".  We set the contentIsChanged property to YES if the record is dirty or new.
   */
  contentStatusDidChange: function() {
    var userRecord = this.get('content');
    if (userRecord == null) {
      this.set('contentIsChanged', NO);
    } else {
      var status = userRecord.get("status");
      if (status === SC.Record.READY_DIRTY || status === SC.Record.READY_NEW) {
        this.set('contentIsChanged', YES);
      } else {
        this.set('contentIsChanged', NO);
      }
    }
  }.observes('*content.status'),

  /**
   * Checks if the current user's username is valid.  This is an example of a field level check.
   *
   * SC.Error Exception thrown if error.
   */
  validateUsername: function() {
    var pattern = /^[a-z]+$/
    var nsUserRecord = this.get('content');
    var username = nsUserRecord.get('username');
    if (!SC.empty(username)) {
      if (!pattern.test(username)) {
        throw SC.Error.desc('Username can only contain a-z in lower case and no spaces.', 'username');
      }
    }
    return;
  },

  /**
   * Checks if the current user record is valid. This is an example of a page check.
   *
   * SC.Error Exception thrown if error.
   */
  validateRecord: function() {
    var nsUserRecord = this.get('content');

    var userName = nsUserRecord.get('username');
    if (SC.empty(userName)) {
      throw SC.Error.desc('Username is required', 'username');
    } else {
      this.validateUsername();
    }

    var userStatus = nsUserRecord.get('userStatus');
    if (SC.empty(userStatus)) {
      throw SC.Error.desc('Status is required', 'userStatus');
    }

    var isAdmin = nsUserRecord.get('isAdmin');
    if (userStatus == 'InActive' && isAdmin) {
      throw SC.Error.desc('Administrators must be active', 'isAdmin');
    }

    return;
  }
});

5. Views

I’ve implemented my views in resources/main_page.cs.

5.1 SC.TableView to List User Records

The main page is split into 3 sections

  • topView – contains the title and the add button.
  • middleView – contains a table listing our user records.
  • bottomView – contains a label letting the user know the current data source status from the userRecordArrayController.

I’ve used SC.TableView to display the user records.

  • I’ve bound the contents of the table and the selected object to the userRecordArrayController.
  • I want the user to be able to press the DELETE key. SC.TableView extends SC.ListView so I’ve followed the example in the ToDo tutorial. I’ve set the canDeleteContent property and implemented the collectionViewDeleteContent() delegate in the userRecordArrayController.
  • When the user double clicks on a record, I want the details pane to be displayed. As such, I set the target and action properties to point to the detailPane.showForUpdate() function.
CrudSample.mainPage = SC.Page.design({

  mainPane: SC.MainPane.design({
    childViews: 'middleView topView bottomView'.w(),

    topView: SC.ToolbarView.design({
      layout: { top: 0, left: 0, right: 0, height: 36 },
      childViews: 'titleLabel addButton'.w(),
      anchorLocation: SC.ANCHOR_TOP,

      titleLabel: SC.LabelView.design({
        layout: { centerY: 0, height: 24, left: 8, width: 200 },
        controlSize: SC.LARGE_CONTROL_SIZE,
        fontWeight: SC.BOLD_WEIGHT,
        value:   'Users'
      }),

      addButton: SC.ButtonView.design({
        layout: { centerY: 0, height: 24, right: 12, width: 100 },
        title:  'Add User',
        target: 'CrudSample.mainPage.detailPane',
        action: 'showForCreate',
        isEnabledBinding: 'CrudSample.userRecordArrayController.isReady'
      })
    }),

    middleView: SC.ScrollView.design({
      hasHorizontalScroller: NO,
      layout: { top: 36, bottom: 32, left: 0, right: 0 },
      backgroundColor: 'white',

      contentView: SC.TableView.design({
        layout: { left: 15, right: 15, top: 15, bottom: 15 },
        backgroundColor: "white",
        columns: [
          SC.TableColumn.create({
            key:   'username',
            label: 'Username',
            width: 100
          }),
          SC.TableColumn.create({
            key:   'department',
            label: 'Department',
            width: 200
          }),
          SC.TableColumn.create({
            key:   'userStatus',
            label: 'User Status',
            width: 100
          }),
          SC.TableColumn.create({
            key:   'isAdmin',
            label: 'Is Admin?',
            formatter: function(v) {
              return v ? 'Yes' : 'No';
            },
            width: 100
          }),
          SC.TableColumn.create({
            key:   'lastLoggedInDate',
            label: 'Last Logged In Date',
            formatter: function(v) {
              return v == null ? '' : v.toFormattedString('%Y-%m-%d %H:%M:%S %Z');
            },
            width: 300
          })
        ],
        contentBinding:   'CrudSample.userRecordArrayController.arrangedObjects',
        selectionBinding: 'CrudSample.userRecordArrayController.selection',
        selectOnMouseDown: YES,
        canDeleteContent: YES,
        exampleView: SC.TableRowView,
        recordType: CrudSample.UserRecord,
        target: "CrudSample.mainPage.detailPane",
        action: "showForUpdate"
      })
    }),

    bottomView: SC.ToolbarView.design({
      layout: { bottom: 0, left: 0, right: 0, height: 32 },
      childViews: 'summaryLabel'.w(),
      anchorLocation: SC.ANCHOR_BOTTOM,

      summaryLabel: SC.LabelView.design({
        layout: { centerY: 0, height: 18, left: 20, right: 20 },
        valueBinding: 'CrudSample.userRecordArrayController.summary'
      })
    })

   ... more code ...

});

5.2 Details Pane to Create and Update a User Record

When the user clicks the Add button or double clicks an existing record, the modal details page is displayed over the top of the mainPane.

  • To make the detailPane modal, I extend from SC.PanelPane.
  • All the fields in detailPane are bound to the userViewController – NOT the userRecordController. This is because we want to use the nested store’s batch commit/discard functionality.
  • To display the detailPane, call showForCreate() or showForUpdate(). This sets the detailsIsVisible property which will trigger detailIsVisibleDidChange to append this panel to the page. It also sets up the userViewController with a new record in a nested store if adding or an existing record in a nested store if updating.
  • When saving, I supply saveComplete() as the callback method. When called, saveComplete() will display the error or hide the details pane if no error.
  • Errors are displayed using the showError() function. It uses SC.AlertPane to popup a nice looking modal dialog rather than a bland javascript alert(). One trickly feature I’ve added is to place the cursor on the field that triggered the error. E.g. if username is required, then put the cursor on the username. This is achieved by, as a convention, putting the name of the property that triggered the error in the SC.Error.label property. I then lookup the property in the view and set focus by calling the field’s becomeFirstResponder() method.
CrudSample.mainPage = SC.Page.design({
 ... some code ...
 detailPane: SC.PanelPane.create({
    layout: { width:400, height:250, centerX:0, centerY:-50},

    contentView: SC.View.extend({
      childViews: 'title username department userStatus isAdmin lastLoggedInDate savingImage savingMessage deleteButton saveButton cancelButton'.w(),

      title: SC.LabelView.design({
        layout: { left: 17, right: 17, top: 17, height: 26 },
        value: 'User Details',
        textAlign: SC.ALIGN_CENTER,
        fontWeight: SC.BOLD_WEIGHT
      }),

      username: SC.View.design({
        layout: { left: 17, right: 17, top: 44, height: 26 },
        childViews: 'label field'.w(),

        label: SC.LabelView.design({
          layout: { left: 0, width: 95, height: 18, centerY: 0 },

          value: 'Username',
          textAlign: SC.ALIGN_RIGHT,
          fontWeight: SC.BOLD_WEIGHT
        }),

        field: SC.TextFieldView.design({
          layout: { width: 250, height: 22, right: 3, centerY: 0 },
          valueBinding: 'CrudSample.userViewController.username',
          isEnabledBinding: 'CrudSample.mainPage.detailPane.isEnabled'
        })
      }),

      department: SC.View.design({
        layout: { left: 17, right: 17, top: 71, height: 26 },
        childViews: 'label field'.w(),

        label: SC.LabelView.design({
          layout: { left: 0, width: 95, height: 18, centerY: 0 },

          value: 'Department',
          textAlign: SC.ALIGN_RIGHT,
          fontWeight: SC.BOLD_WEIGHT
        }),

        field: SC.TextFieldView.design({
          layout: { width: 250, height: 22, right: 3, centerY: 0 },
          valueBinding: 'CrudSample.userViewController.department',
          isEnabledBinding: 'CrudSample.mainPage.detailPane.isEnabled'
        })
      }),

      userStatus: SC.View.design({
        layout: { left: 17, right: 17, top: 98, height: 26 },
        childViews: 'label field'.w(),

        label: SC.LabelView.design({
          layout: { left: 0, width: 95, height: 18, centerY: 0 },

          value: 'User Status',
          textAlign: SC.ALIGN_RIGHT,
          fontWeight: SC.BOLD_WEIGHT
        }),

        field: SC.SelectFieldView.design({
          layout: { width: 250, height: 22, right: 3, centerY: 0 },

          objects: [
            {name:'Active', value: 'Active'},
            {name:'InActive', value: 'InActive'},
            {name:'Locked', value: 'Locked;'},
          ],
          nameKey: 'name',
          valueKey: 'value',

          acceptsFirstResponder: function() {
            return this.get('isEnabled');
          }.property('isEnabled'),

          valueBinding: 'CrudSample.userViewController.userStatus',
          isEnabledBinding: 'CrudSample.mainPage.detailPane.isEnabled'
        })

      }),

      isAdmin: SC.View.design({
        layout: { left: 17, right: 17, top: 125, height: 26 },
        childViews: 'label field'.w(),

        label: SC.LabelView.design({
          layout: { left: 0, width: 95, height: 18, centerY: 0 },

          value: 'Is Admin',
          textAlign: SC.ALIGN_RIGHT,
          fontWeight: SC.BOLD_WEIGHT
        }),

        field: SC.SelectFieldView.design({
          layout: { width: 250, height: 22, right: 3, centerY: 0 },

          objects: [
            {name:'Yes', value: 'YES'},
            {name:'No', value: 'NO'},
          ],
          nameKey: 'name',
          valueKey: 'value',

          acceptsFirstResponder: function() {
            return this.get('isEnabled');
          }.property('isEnabled'),

          valueBinding: 'CrudSample.userViewController.isAdminString',
          isEnabledBinding: 'CrudSample.mainPage.detailPane.isEnabled'
        })
      }),

      lastLoggedInDate: SC.View.design({
        layout: { left: 17, right: 17, top: 152, height: 26 },
        childViews: 'label field'.w(),

        label: SC.LabelView.design({
          layout: { left: 0, width: 95, height: 18, centerY: 0 },

          value: 'Last Logged In Date',
          textAlign: SC.ALIGN_RIGHT,
          fontWeight: SC.BOLD_WEIGHT
        }),

        field: SC.LabelView.design({
          layout: { width: 250, height: 22, right: 3, centerY: 0, top: 4 },
          valueBinding: 'CrudSample.userViewController.lastLoggedInDate',
          formatter: function(v) {
            if (SC.kindOf(v, SC.DateTime)) {
              return v == null ? '' : v.toFormattedString('%Y-%m-%d %H:%M:%S %Z');
            } else {
              return '';
            }
          }
        })
      }),

      savingImage: SC.ImageView.design({
        layout: { bottom: 15, left: 175, height:16, width: 16 },
        value: sc_static('images/loading'),
        useImageCache: NO,
        isVisibleBinding: SC.Binding.from('CrudSample.mainPage.detailPane.isEnabled').bool().transform(
          function(value, isForward) {
            return !value;
          })
      }),

      savingMessage: SC.LabelView.design({
        layout: { bottom: 8, left: 195, height:24, width: 100 },
        value: 'Saving ...',
        classNames: ['saving-message'],
        isVisibleBinding: SC.Binding.from('CrudSample.mainPage.detailPane.isEnabled').bool().transform(
          function(value, isForward) {
            return !value;
          })
      }),

      deleteButton: SC.ButtonView.design({
        layout: {bottom: 10, left: 20, height:24, width:80},
        title: 'Delete',
        action: 'deleteRecord',
        isVisibleBinding: SC.Binding.from('CrudSample.userViewController.contentIsChanged').bool().transform(
          function(value, isForward) {
            return !value;
          })
      }),

      saveButton: SC.ButtonView.design({
        layout: {bottom: 10, right: 110, height:24, width:80},
        title: 'Save',
        action: 'save',
        isDefault: YES,
        isEnabledBinding: 'CrudSample.userViewController.contentIsChanged',
        isVisibleBinding: 'CrudSample.mainPage.detailPane.isEnabled'
      }),

      cancelButton: SC.ButtonView.design({
        layout: {bottom: 10, right: 20, height:24, width:80},
        title: 'Cancel',
        action: 'cancel',
        isCancel: YES,
        isVisibleBinding: 'CrudSample.mainPage.detailPane.isEnabled'
      })
    }),

    /**
     * Methods to show/hide the details pane
     * Thanks Charles:
     * http://markmail.org/message/miobpqe7y34w7rht#query:sproutcore%20panelpane+page:1+mid:miobpqe7y34w7rht+state:results
     */
    detailIsVisible: NO,

    /**
     * observer - show/hide panel
     */
    detailIsVisibleDidChange: function() {
      var panel = CrudSample.mainPage.get('detailPane');
      if (this.get('detailIsVisible')) {
        // Show
        panel.append();
        // Set focus on the username field
        CrudSample.mainPage.detailPane.contentView.username.field.becomeFirstResponder();
      }
      else {
        // Hide
        panel.remove();
      }
    }.observes('detailIsVisible'),

    /**
     * Show this form for a new user
     */
    showForCreate: function() {
      this.set('detailIsVisible', YES);
      CrudSample.userViewController.createNew();
    },

    /**
     * Show this form and load details of the current user for editing
     */
    showForUpdate: function() {
      this.set('detailIsVisible', YES);
      CrudSample.userViewController.updateCurrent();
    },

    /**
     * Save changes
     * Note that the save button is only visible if there has been changes in the current user record
     */
    save: function() {
      try {
        this.set('isEnabled', NO);
        CrudSample.userViewController.save(this, this.saveComplete);
      } catch (e) {
        this.showError(e);
        this.set('isEnabled', YES);
      }
    },

    /**
     * Check if saving has finished
     */
    saveComplete: function(errorObject) {
      this.set('isEnabled', YES);
      if (SC.none(errorObject)) {
        this.set('detailIsVisible', NO);
      } else {
        this.showError(errorObject);
      }
    },

    /**
     * Discard changes
     */
    cancel: function() {
      CrudSample.userViewController.discard();
      this.set('detailIsVisible', NO);
    },

    /**
     * Delete current user.
     * Note that the delete button is only visible if there are no changes to the current user being edited
     */
    deleteRecord: function() {
      CrudSample.userViewController.discard();
      CrudSample.mainPage.mainPane.middleView.contentView.deleteSelection();
      this.set('detailIsVisible', NO);
    },

    /**
     * Show an error message
     * @param e Error object to show
     */
    showError: function(e) {
      if (SC.instanceOf(e, SC.Error)) {
        SC.AlertPane.error(e.message);
        if (!SC.empty(e.label)) {
          var view = CrudSample.mainPage.detailPane.contentView.getPath(e.label);
          if (view) {
            view.field.becomeFirstResponder();
          }
        }
      } else {
        SC.AlertPane.error(e);
      }

    }

  })  //detailPane

});

5.3 Show Main Page

To kick start the app, I show the main page and load records into the userRecordArrayController in main.js.

CrudSample.main = function main() {

  // Step 1: Instantiate Your Views
  // The default code here will make the mainPane for your application visible
  // on screen.  If you app gets any level of complexity, you will probably
  // create multiple pages and panes.
  CrudSample.getPath('mainPage.mainPane').append();

  // Step 2. Set the content property on your primary controller.
  var query = SC.Query.local(CrudSample.UserRecord, {orderBy: 'username'});
  var users = CrudSample.store.find(query);
  CrudSample.userRecordArrayController.set('content', users);
};

Sproutcore RC.Record primary keys, guids and ids

Every Sproutcore record needs a primary key.

By default, the primary key is named guid.

TableSample.User = SC.Record.extend(
/** @scope TableSample.User.prototype */ {
  firstName: SC.Record.attr(String),
  lastName: SC.Record.attr(String)
}) ;

TableSample.User.FIXTURES = [
   { guid: 1,
     firstName: "Michael",
     lastName: "Scott" },

   { guid: 2,
     firstName: "Dwight",
     lastName: "Schrute" },

   { guid: 3,
     firstName: "Jim",
     lastName: "Halpert" },
];

You can change the name of your primary key by using the primaryKey attribute. For example, below we’ve changed the name of our primary key to userID.

TableSample.User = SC.Record.extend(
/** @scope TableSample.User.prototype */ {
  primaryKey: 'userID',
  userID: SC.Record.attr(Number),
  firstName: SC.Record.attr(String),
  lastName: SC.Record.attr(String)
}) ;

TableSample.User.FIXTURES = [
   { userID: 1,
     firstName: "Michael",
     lastName: "Scott" },

   { userID: 2,
     firstName: "Dwight",
     lastName: "Schrute" },

   { userID: 3,
     firstName: "Jim",
     lastName: "Halpert" },
];

Here’s the catch. If you need to retrieve the value of your primaryKey, always retrieve it using the id attribute no matter if you are using the default ‘guid’ primary key property name or not.

  var userRecord = TableSample.store.find(TableSample.User, 1);

  // Correct way of getting the primary key
  var pk = userRecord.get('id');

  // This is incorrect
  var wrongWayToGetPrimaryKey = userRecord.get('userID');

Getting the value of the primary key using the primary key attribute will only work for existing records retrieved from a data source or instanced in a fixture.

I found that it does NOT work for records created in sproutcore and then pushed for saving on your server (i.e. new records that a user may have added).

This is because sproutcore keeps the primary key in an array called SC.Store.idsByStoreKey. When the primary key is updated after a SC.Store.commitRecords(), the array is updated but the primary key property of the record is not.

    var userRecord = { userID: null,
     firstName: "Sponge",
     lastName: "Bob"
    };

    store.createRecord(TableSample.User, userRecord) ;
    store.commitRecords();

    // Correct way of getting the primary key.
    // pk will equals the newly allocated primary key.
    // If you are using fixtures data source, it will be something like "@id4".
    var pk = userRecord.get('id');

    // This is incorrect
    // wrongWayToGetPrimaryKey will be null.
    // It is unchanged from the userRecord declaration.
    var wrongWayToGetPrimaryKey = userRecord.get('userID');

In the above example, userID is left unchanged as null. I think this is because doing so would change the record status from READ_CLEAN to READ_DIRTY which is not what we want after a commitRecords().

So, the lesson for newbies, always read your record’s primary key using the id attribute. Also, never change your primary key except after saving a newly created record in your data source.

Using the latest Sproutcore code from the master branch

Got this tip from Colin Codes.

Standard Sproutcore Install

I’ve installed Sproutcore using

gem install sproutcore

However, this is NOT the latest development code. It is the latest released code.

Using the Latest Code from the Master Branch

All you have to do is to clone the latest sproutcore code from github into a frameworks directory within your Sproutcore project directory.

cd my_project_directory
mkdir -p frameworks
cd frameworks
git clone git://github.com/sproutcore/sproutcore.git

Following this, you should have the following directory structure

  • my_project_directory
    • apps
    • frameworks
      • sproutcore
    • tmp

The Sproutcore build tools (and sc-server) will use the Sproutcore code found under your project’s frameworks/sproutcore directory rather than the one that came with the standard install of Sproutcore.

If you wish to use an even later version of sproutcore, e.g. Quilmes branch, you just need to get that version into your frameworks/sproutcore directory.

cd my_project_directory/frameworks/sproutcore
git fetch
git checkout -b quilmes origin/quilmes

Using External 3rd Party Frameworks

For a list of frameworks, see http://wiki.sproutcore.com/Other-Frameworks.

Basically, get the source into the my_project_directory/frameworks/external_framework_name directory.

Change your Buildfile to include the new external framework.


config :all, :required => [:sproutcore, :external_framework_name]

Sproutcore sc-server undefined method ‘empty?’ (NoMethodError)

Over the weekend, I took the latest copy of Sproutcore Abbot build tools following the instructions provided in http://wiki.sproutcore.com/Abbot-Setting+Up.

Upon starting sc-server, I get the error


./sc-server:9:in `': undefined method `empty?' for nil:NilClass (NoMethodError)

I checked the ruby code inside sc-server:

#!/usr/bin/env ruby
# ===========================================================================
# Project:   Abbot - SproutCore Build Tools
# Copyright: ©2009 Apple Inc.
#            portions copyright @2006-2009 Sprout Systems, Inc.
#            and contributors
# ===========================================================================

if caller.empty?
  puts "FATAL: You need to invoke sc-server from an installed gem or through bundler. For more information, please visit http://github.com/sproutit/sproutcore-abbot/wiki/Using-Abbot-1.4-From-Source"
  exit
end

require "sproutcore"

SC::Tools.invoke 'server'

# EOF

I think Abbot 1.4 has changed so that it can only be run from a gem or bundler.

See instructions at http://github.com/sproutit/sproutcore-abbot/wiki/Using-Abbot-1.4-From-Source on how to use it.

Sproutcore: Login Logout Sample

Now it is time to extend the login sample further using what I’ve learnt so far.

Once again, the code can be found here.

See it in action here.

This walk through assumes that you are familiar with the Login Sample walk through.

Use Case

If the user is not logged in, present the user with the login screen

  1. After the user logs in, store the login details in a cookie
  2. After login, the user is able to navigate between pages.
  3. If the cookie expires during the session, the user is taken back to the login screen. After logging in, the user is taken back to the screen they were on.
  4. If the user clicks the Logout button, the user is taken to the login screen.

Step 1. Create Login Controller

We crate a login controller like we did in the Login sample.

LoginLogoutSample.loginPageController = SC.ObjectController.create(
  /** @scope LoginLogoutSample.loginPageController.prototype */ {

  // View-Model
  // I have used page level properties for the control to bind to  because I generally see the "model" as data
  // in a datastore.
  // As a personal preference, I like to separate data displayed on pages (View-Model) from data in the datastore
  // (Model) because I've found on complex pages, the data on pages may
  username: '',
  password: '',
  rememberMe: '',
  errorMessage: '',
  isLoggingIn: NO,
  returnRoute: '',

  /**
   Start async login process

   @returns YES if async call successfully started, NO if it failed. If error, the error message
   will be placed in the 'errorMessage' property.
   */
  beginLogin: function() {
    try {
      // Get our data from the properties using the SC 'get' methods
      // Need to do this because these properties have been bound/observed.
      var username = this.get('username');
      if (username == null || username == '') {
        throw SC.Error.desc('Username is required');
      }

      var password = this.get('password');
      if (password == null || password == '') {
        throw SC.Error.desc('Password is required');
      }

      // Start login
      this.set('isLoggingIn', YES);

      // Simulate a HTTP call to check our data.
      // If the credentials not admin/admin, then get a bad url so we get 404 error
      var url = '/login_logout_sample/en/current/source/resources/login_page.js';
      if (username != 'admin' || password != 'admin') {
        url = '/login_logout_sample/en/current/source/resources/bad_url.js';
      }

      SC.Request.getUrl(url)
        .notify(this, 'endLogin')
        .send();

      return YES;
    }
    catch (err) {
      // Set Error
      this.set('errorMessage', err.message);

      // Finish login processing
      this.set('isLoggingIn', NO);

      return NO;
    }
  },

  /**
   Callback from beginLogin() after we get a response from the server to process
   the returned login info.

   @param {SC.Response} response The HTTP response
   @param {function} callback A function taking SC.Error as an input parameter. null is passed if no error.
   */
  endLogin: function(response) {
    try {
      // Flag finish login processing to unlock screen
      this.set('isLoggingIn', NO);

      // Check status
      SC.Logger.info('HTTP status code: ' + response.status);
      if (!SC.ok(response)) {
        // Error
        throw SC.Error.desc('Invalid username or password. Try admin/admin ;-) ');
      }

      // Set cookie
      var rememberMe = this.get('rememberMe');
      var authCookie = SC.Cookie.create();
      authCookie.name = 'LoginLogoutSampleCookie';
      authCookie.value = 'sometoken passed back in the response';
      if (rememberMe == '3seconds') {
        // Cookie is saved for 3 seconds
        var d = new Date();
        d.setTime(d.getTime() + 3000);
        authCookie.expires = d;
      } else if (rememberMe == 'closeBrowser') {
        // Cookie removed when browser closed
        authCookie.expires = null;
      } else {
        // Cookie is saved for 1 year
        var d = new Date();
        d.setFullYear(d.getYear() + 1);
        authCookie.expires = d;
      }
      authCookie.write();

      // clear data
      this.set('errorMessage', '');

      // Go to next page
      var returnRoute = LoginLogoutSample.loginPageController.get('returnRoute');
      if (returnRoute != undefined && returnRoute != null && returnRoute != '') {
        SC.routes.set('location', returnRoute);
      }
    }
    catch (err) {
      this.set('errorMessage', err.message);
      SC.Logger.info('Error in endLogin: ' + err.message);
    }
  }

});

However, this time, we are going to set a cookie if we successfully login.

We have 3 types of cookie that we can set:

  1. The 3seconds cookie will expire after 3 seconds. This allows us to simulate in a testing environment a 20 minute timeout by just waiting 3 seconds.  Navigating between pages 3 seconds after login will force you back to the login screen.
  2. The closeBrowser cookie will expire when the browser is closed.  Next time the user visits our sproutcore app, they will have to login.
  3. The 1 year cookie will expire after 1 year.  This allows the user to close the browser and when they come back another day, they will still be logged in.

By setting the expiry date, we can control when the cookie is invalidated.  Then on navigating and/or performing other actions, we can check to see if the cookie exists.  If the cookie exists then the user is logged in.  If the cookie does not exist, the user is not logged in.

Step 2. Routes

By using Sproutcore routes when we move between pages, we can check to see if the user is logged in everytime there is a navigation event.

LoginLogoutSample.routes = SC.Object.create({

  /**
   Property to store the main pane of the page that is currently shown to the user
   */
  currentPagePane: null,

  /**
   Navigate to the specified route

   @param {Object} routeParams route parameters are set as properties of this
   object. The parameters are specified when registering the route using
   SC.routes.add() in main.js.
   */
  gotoRoute: function(routeParams) {
    // Default to page 1
    var pageName = routeParams.pageName;
    if (pageName == undefined || pageName == '') {
      pageName = 'onePage';
    }
    var paneName = routeParams.paneName;
    if (paneName == undefined || paneName == '') {
      paneName = 'onePane';
    }

    // If authentication cookie not found or expired, then go to login page
    if (pageName != 'loginPage' && paneName != 'loginPane') {
      var authCookie = SC.Cookie.find('LoginLogoutSampleCookie');
      if (authCookie == null) {
        LoginLogoutSample.loginPageController.set('username', '');
        LoginLogoutSample.loginPageController.set('password', '');
        LoginLogoutSample.loginPageController.set('returnRoute', pageName + '/' + paneName);
        SC.routes.set('location', 'loginPage/loginPane');
        return;
      }
    }

    // If this is the special logout out, then log out
    if (pageName == 'logoutPage' && paneName == 'logoutPane') {
      var authCookie = SC.Cookie.find('LoginLogoutSampleCookie');
      if (authCookie != null) {
        authCookie.destroy();
      }
      LoginLogoutSample.loginPageController.set('username', '');
      LoginLogoutSample.loginPageController.set('password', '');
      LoginLogoutSample.loginPageController.set('returnRoute', 'onePage/onePane');
      SC.routes.set('location', 'loginPage/loginPane');
      return;
    }

    // If there is a current pane, remove it from the screen
    if (this.currentPagePane != null) {
      this.currentPagePane.remove();
    }

    // Show the specified pane
    var pagePanePath = pageName + '.' + paneName;
    var pagePane = LoginLogoutSample.getPath(pagePanePath);
    pagePane.append();

    // Save the current pane so we can remove it when process the next route
    this.currentPagePane = pagePane;
  }

});

We use SC.Cookie.find(‘LoginLogoutSampleCookie’) to locate the cookie. If the cookie exists, the user is logged in and we continue. If the cookie is not there, we redirect to the login page.

We also set the returnRoute property of our loginPageController so that it knows where to navigate after a successful login.

We also check for a special route called “logoutPage/logoutPane”. If this is passed in, then we logout and go to the login page.

Step 3. Login Screen

The login screen is essentially the same as for the login sample with the addition of a drop down list for the user to select how long before they have to login again. In a real world app, this would be like a “remember me” checkbox.

Note the use of acceptsFirstResponder in the rememberMe SelectFieldView.  This is needed to allow the SelectFieldView (drop down list) to accept focus when the user presses the TAB key. [Edit: now fixed in Sproutcore 1.4.2]

LoginLogoutSample.loginPage = SC.Page.design({

  loginPane:  SC.MainPane.design({
    layout: { width: 360, height: 160, centerX: 0, centerY: 0 },
    classNames: ['login-pane'],
    childViews: 'boxView'.w(),

    boxView: SC.View.design({
      childViews: 'username password rememberMe loginButton loadingImage errorMessage'.w(),

      username: SC.View.design({
        layout: { left: 17, right: 14, top: 17, height: 26 },
        childViews: 'label field'.w(),

        label: SC.LabelView.design({
          layout: { left: 0, width: 107, height: 18, centerY: 0 },

          value: '_Username',
          localize: YES,
          textAlign: SC.ALIGN_RIGHT
        }),

        field: SC.TextFieldView.design({
          layout: { width: 200, height: 22, right: 3, centerY: 0 },

          isEnabledBinding: SC.Binding.from("LoginLogoutSample.loginPageController.isLoggingIn")
            .bool()
            .transform(function(value, isForward) {
            return !value;
          }),
          valueBinding: 'LoginLogoutSample.loginPageController.username'
        })
      }),

      password: SC.View.design({
        layout: { left: 17, right: 14, top: 45, height: 26 },
        childViews: 'label field'.w(),

        label: SC.LabelView.design({
          layout: { left: 0, width: 107, height: 18, centerY: 0 },

          value: '_Password',
          localize: YES,
          textAlign: SC.ALIGN_RIGHT
        }),

        field: SC.TextFieldView.design({
          layout: { width: 200, height: 22, right: 3, centerY: 0 },

          isPassword: YES,
          isEnabledBinding: SC.Binding.from("LoginLogoutSample.loginPageController.isLoggingIn")
            .bool()
            .transform(function(value, isForward) {
            return !value;
          }),
          valueBinding: 'LoginLogoutSample.loginPageController.password'
        })
      }),

      rememberMe: SC.View.design({
        layout: { left: 17, right: 14, top: 72, height: 26 },
        childViews: 'label field'.w(),

        label: SC.LabelView.design({
          layout: { left: 0, width: 107, height: 18, centerY: 0 },

          value: '_RememberMe',
          localize: YES,
          textAlign: SC.ALIGN_RIGHT
        }),

        field: SC.SelectFieldView.design({
          layout: { width: 200, height: 22, right: 3, centerY: 0 },

          objects: [
            {name:'For 3 Seconds', value:'3seconds'},
            {name:'Until the browser is closed', value:'closeBrowser'},
            {name:'For 1 year', value:'1year'}
            ],
          nameKey: 'name',
          valueKey: 'value',

          isEnabledBinding: SC.Binding.from("LoginLogoutSample.loginPageController.isLoggingIn")
            .bool()
            .transform(function(value, isForward) {
            return !value;
          }),
          valueBinding: 'LoginLogoutSample.loginPageController.rememberMe'
        })
      }),

      loginButton: SC.ButtonView.design({
        layout: { height: 24, width: 80, bottom: 17, right: 17 },
        title: '_Login',
        localize: YES,
        isDefault: YES,
        isEnabledBinding: SC.Binding.from("LoginLogoutSample.loginPageController.isLoggingIn")
          .bool()
          .transform(function(value, isForward) {
          return !value;
        }),

        target: 'LoginLogoutSample.loginPageController',
        action: 'beginLogin'
      }),

      loadingImage: SC.ImageView.design({
        layout: { width: 16, height: 16, bottom: 20, right: 110 },
        value: sc_static('images/loading'),
        useImageCache: NO,
        isVisibleBinding: 'LoginLogoutSample.loginPageController.isLoggingIn'
      }),

      errorMessage: SC.LabelView.design({
        layout: { height: 40, width: 230, right: 120, bottom: 7 },
        classNames: ['error-message'],

        valueBinding: 'LoginLogoutSample.loginPageController.errorMessage'
      })

    })  //contentView

  })  //loginPane

}); //loginPage

Step 4. Other Pages

one_page and two_page are essentially the same as the one from the route sample. The have 2 buttons.

  • The first button lets the user navigate to the other page using the route.
  • The second button lets the user log out by navigating to the special ‘logoutPage/logoutPane’ route.
LoginLogoutSample.onePage = SC.Page.design({

  onePane: SC.MainPane.design({
    childViews: 'labelView buttonView logoutButtonView'.w(),

    labelView: SC.LabelView.design({
      layout: { top: 20, left: 27, width: 400, height: 20 },
      value: 'You are on page #1.',
      classNames: ['title1']
    }),

    buttonView: SC.ButtonView.design({
      layout: { top: 70, left: 27, width: 400 },
      title: 'Click to go to page #2.',
      action: 'go'
    }),

    logoutButtonView: SC.ButtonView.design({
      layout: { top: 110, left: 27, width: 400 },
      title: 'Logout',
      action: 'goLogout'
    }),

    go: function() {
      SC.routes.set('location', 'twoPage/twoPane');
    },

    goLogout: function() {
      SC.routes.set('location', 'logoutPage/logoutPane');
    }

  })

});

Final Words

In a proper real life implementation, you will most likely need your REST server to return a token. The token can be stored in the cookie and then passed back to the server with each call.

Checking if the user has logged in or not should also occur on every controller action. e.g. before saving a record. Currently, we only check when navigating between pages.

How to use Sproutcore SelectFieldView (HTML select)

        field: SC.SelectFieldView.design({
          layout: { width: 200, height: 22, right: 3, centerY: 0 },

          objects: [
            {name:'For 3 Seconds', value:'3seconds'},
            {name:'Until the browser is closed', value:'closeBrowser'},
            {name:'For 1 year', value:'1year'}
            ],
          nameKey: 'name',
          valueKey: 'value',

          acceptsFirstResponder: function() {
            return this.get('isEnabled');
          }.property('isEnabled'),

          isEnabledBinding: SC.Binding.from("LoginLogoutSample.loginPageController.isLoggingIn")
            .bool()
            .transform(function(value, isForward) {
            return !value;
          }),
          valueBinding: 'LoginLogoutSample.loginPageController.rememberMe'
        })

objects is an array of name (text to show to the user) and value (code to store in the database) pairs.

nameKey specifies the name of the property that contains the text that to show the user.

valueKey specifies the name of the property that contains the value to store.

For some reason, I could not tab onto the rendered select control (i.e. navigate using the keyboard and clicking the TAB key).

I found that you have to set acceptsFirstResponder. The sproutcore framework needs this function to be set before it will consider that a field is able to receive focus from the TAB key. TextFieldView has it but for some reason, SelectFieldView does not. I’ve just copied the above code from TextFieldView.