Sproutcore V2 CRUD Tutorial Part 2 – Read

The Listing Page

The user is able to type in a user name or email address and click Search. The list of matching user records will then be displayed.

Upon clicking New User, a modal dialog will be displayed to allow the user to create a new user.

Upon double clicking a listed user record, a modal dialog will be displayed to allow the user to view and edit the user details, or to delete the user.

Engine

On load or when the user clicks Search, the following calls are made:

  • App.userEngine.clearData() to delete all records
  • App.userEngine.Search() to retrieve all user records from the server matching the specified criteria (if any).  The user records will be placed in the data store.  These will be the ONLY records in the data store.
  • App.userEngine.getRecords() to return a SC.RecordArray containing records in the data store to use for binding to a view.

I found it easier to do this than to try to sync the entire user database table into the data store. Every time the user clicks search, a maximum of 50 records are returned. This is relatively quick and the records are “fresh”. I don’t have to worry about stale records in the data store or running background jobs to sync the data with the server.

If 50 records are returned, a Show More button is displayed. Clicking this button calls App.userEngine.Search() to fetch the next 50 records from the server and add it to in the data store. Note that the records in the data store are not cleared when showing more.

/** @class
 *
 * Manages user records and keeps them in sync with the server
 *
 * @extends SC.Object
 */
App.userEngine = SC.Object.create(App.EngineMixin, {
 /**
   * Removes all repository meta info records in the data store
   */
  clearData: function() {
    var records = App.store.find(App.UserRecord);
    records.forEach(function(record) {
      record.destroy()
    });
    App.store.commitRecords();
  },

  ...

  /**
   * Retrieves user information from the server and loads it into the local store
   *
   * @param {Hash} criteria Search criteria. Object hash containing: username, email, role, status,
   *  records_per_page, start_page, do_page_count. These values are converted into querystring parameters.
   * @param {Object} [callbackTarget] Optional callback object
   * @param {Function} [callbackFunction] Optional callback function in the callback object.
   * Signature is: function(callbackParams, error) {}.
   * If there is no error, error will be set to null.
   * @param {Hash} [callbackParams] Optional Hash to pass into the callback function.
   */
  search: function(criteria, callbackTarget, callbackFunction, callbackParams) {

    // Build query string
    var qs = '?ts=' + new Date().getTime();
    for (var p in criteria) {
      if (!SC.empty(criteria[p])) {
        qs = qs + '&' + p + '=' + encodeURIComponent(criteria[p]);
      }
    }

    // Get data
    var context = { callbackTarget: callbackTarget, callbackFunction: callbackFunction, callbackParams: callbackParams };
    $.ajax({
      type: 'GET',
      url: '/api/users' + qs,
      dataType: 'json',
      contentType: 'application/json; charset=utf-8',
      context: context,
      headers: this._createAjaxRequestHeaders(),
      error: this._ajaxError,
      success: this._endSearch
    });
  },

  /**
   * Process data that the server returns
   *
   * The 'this' object is the context data object.
   *
   * @param {Object} data Deserialized JSON returned form the server
   * @param {String} textStatus Hash of parameters passed into SC.Request.notify()
   * @param {jQueryXMLHttpRequest}  jQuery XMLHttpRequest object
   * @returns {Boolean} YES if successful and NO if not.
   */
  _endSearch: function(data, textStatus, jqXHR) {
    var error = null;
    try {
      App.userEngine._convertApiObjectsToRecords(data, App.UserRecord);
    }
    catch (err) {
      error = err;
      SC.Logger.error('userEngine.endLoad: ' + err.message);
    }

    // Callback
    if (!SC.none(this.callbackFunction)) {
      this.callbackFunction.call(this.callbackTarget, this.callbackParams, error);
    }

    // Return YES to signal handling of callback
    return YES;
  },

  /**
   * Returns
   * @param [conditions] Optional conditions
   * @param [orderBy] Optional order by property names. Defaults to 'username' if not supplied.
   * @returns {SC.RecordArray} Returns an array of matching records
   */
  getRecords: function(conditions, orderBy) {
    var params = {};
    if (!SC.empty(conditions)) {
      params['conditions'] = conditions;
    }
    if (SC.empty(orderBy)) {
      orderBy = 'username';
    }
    params['orderBy'] = orderBy;

    var query = SC.Query.local(App.UserRecord, params);
    return App.store.find(query);
  }
});

State Chart

The state chart for this page is located in admin_users_page.js and defined as App.statechart.  This diagram illustrates the states applicable to searching:

  • We start at Not Searching
  • The Search actions will trigger a state change to Searching
  • Once the search is finished, a state change will automatically be triggered to return the state to Not Searching.
  • The Show More actions will trigger a state change to Showing More
  • Once the show more is finished, a state change will automatically be triggered to return the state to Not Searching.

You may wonder why bother with declaring a state of “Searching” and “Showing More”? We are only in these states temporarily.

Well, while we are searching, the page is not blocking and can accept events. I wanted to declare a formal state to trigger the disabling of input controls and the display of the the spinner animated GIF by way of setting isSearching property in App.pageController.

Template

The handlebar template is in admin_users.html. The section pertaining to listing is:

<form class="alert-message block-message info form-stacked">
  <fieldset>
	{{view App.UsernameField id="usernameField" }}
	{{view App.EmailAddressField id="emailAddressField" }}
	{{view App.SearchButton id="searchButton" class="btn primary"}}
	{{view App.WorkingImage id="workingImage"}}
	{{view App.AddButton id="addButton" class="btn success"}}
	<div class="clearfix"></div>
  </fieldset>
</form>
{{view App.ErrorMessage id="errorMessage"}}
{{view App.NoRowsMessage id="noRowsMessage"}}
{{#view App.Results id="results"}}
  <table class="zebra-striped">
	<thead>
	  <tr>
		<th>{{usernameLabel}}</th>
		<th>{{displayNameLabel}}</th>
		<th>{{emailAddressLabel}}</th>
		<th>{{currentStatusLabel}}</th>
	<em>  </tr>
	</thead>
	{{#collection CollectionView tagName="tbody"}}
	  <td>{{content.username}}</td>
	  <td>{{content.displayName}}</td>
	  <td>{{content.emailAddress}}</td>
	  <td>{{content.currentStatusText}}</td>
	{{/collection}}
  </table>
{{/view}}
{{#view App.BottomBar id="bottombar" }}
  {{view App.WorkingImage id="workingImage2"}}
  {{view App.ShowMoreButton id="showMoreButton" class="btn primary"}}
{{/view}}

The CSS is Twitter Bootstrap mixed with jQuery UI.

Table Collection View

I’ve defined App.Results as a view for displaying search results. The CollectiveView is a sub view of App.Results and is used to render each row.

  • The tagName property of the collection view must be set to tr for table row.
  • I’ve used jQuery to add a double click event handler to send the showUser action to the state chart.
/**
 * @class
 * Container view for the ShowMore button
 */
App.Results = SC.View.extend({
  isVisibleBinding: SC.Binding.from('App.pageController.showResults').oneWay().bool(),

  usernameLabel: '_admin.user.username'.loc(),
  displayNameLabel: '_admin.user.displayName'.loc(),
  emailAddressLabel: '_admin.user.emailAddress'.loc(),
  currentStatusLabel: '_admin.user.currentStatus'.loc(),

  CollectionView : SC.CollectionView.extend({
    contentBinding: 'App.resultsController',

    itemViewClass: SC.View.extend({
      tagName: 'tr',

      // Spit out the content's index in the array proxy as an attribute of the tr element
      attributeBindings: ['contentIndex'],

      willInsertElement: function() {
        this._super();

        // Add handler for double clicking
        var id = this.$().attr('id');
        this.$().dblclick(function() {
          App.statechart.sendAction('showUser', $('#' + this.id).attr('contentIndex'));
        });
      }
    })

  })
});

Field Views

I’ve defined a field to be a label and a data control. For example, the username field is defined as:

App.UsernameField = App.StackedFieldView.extend({
  label: '_admin.user.username'.loc(),

  DataView : App.TextBoxView.extend(App.CriteriaFieldDataMixin, {
    classNames: 'large'.w(),
    valueBinding: 'App.pageController.username',
    disabledBinding: SC.Binding.from('App.pageController.isSearching').oneWay().bool()
  })
});

All fields are derive from App.StackedFieldView is defined in app_views.js.

  • It allows me to define common template and behaviour for all fields.
  • The field template inline compiled: SC.Handlebars.compile(‘{{view LabelView}}{{view DataView}}’).
  • The DataView is left to be defined the child class. This allows me define different data control for different fields. For username, it is App.TextBoxView. Other data controls include App.TextAreaView, App.SelectView, etc.
/**
 * @class
 * Defines a fields where label is on top of the data
 */
App.StackedFieldView = SC.View.extend({
  classNames: 'field floating'.w(),

  /**
   * Template is just the message
   * @Type SC.Handlebars
   */
  defaultTemplate: SC.Handlebars.compile('{{view LabelView}}{{view DataView}}'),

  /**
   * Label to display to let the user know what this field is about
   * @type String
   */
  label: '',

  /**
   * Flag indicating if the field is a required field. If so, an '*' is placed in the label text
   * @type Boolean
   */
  isRequired: NO,

  /**
   *
   */
  LabelView: SC.View.extend({
    tagName: 'label',

    attributeBindings: ['for'],

    /**
     * ID of data element
     * @type String
     */
    'for': '',

    /**
     * Text to display the user
     * @type String
     */
    textBinding: 'parentView.label',

    /**
     * The required symbol
     * @type String
     */
    required: function() {
      return (this.getPath('parentView.isRequired')) ? '*' : '';
    }.property('parentView.isRequired').cacheable(),

    /**
     * Handlebar template for this label
     * @type SC.Handlebars
     */
    defaultTemplate: SC.Handlebars.compile('{{text}}{{required}}')
  }),

  /**
   * Class representing the data capture control. To be defined by the child class
   * @type SC.View
   */
  DataView: null,

  /**
   * Set the 'for' attribute for the label to that of the data view
   */
  willInsertElement: function() {
    this._super();

    var childViews = this.get('childViews');
    var labelView = childViews[0];
    var dataView = childViews[1];
    labelView.set('for', dataView.$().attr('id'));
  }

});

Data Controls are also defined in app_views.js. I’ve extended the standard SC controls to handle a few more properties and behaviours. For example, the text box now supports name, readonly, etc.

/** @Class
 *
 * Our own text box supports additional attributes on the textbox
 */
App.TextBoxView = SC.TextField.extend({
  /**
   * Specify additional attributes
   */
  attributeBindings: ['type', 'placeholder', 'value', 'name', 'tabindex', 'disabled', 'readonly'],

  /**
   * Name of the text box
   */
  name: '',

  /**
   * Tabindex
   */
  tabindex: '1',

  /**
   * Flag to indicate if this is disabled or not
   */
  disabled: NO,

  /**
   * Read only text box
   */
  readonly: NO
});

Each data control is bound to a property I’ve defined in App.pageController. In the case of username, the text box is bound to the username property. What ever the user types into the textbox, its value will be put into that property.

In my state chart, when I need to access what the user has typed, I need only call App.pageController.get(‘username’). Using a property in the page controller is a great way of separating the view (DOM) from the state chart. I can change the name, layout and other properties of the view without ever affecting the start chart code as long as I keep the same property binding.

  _startSearch: function() {
        try {
          // Clear previous log entries
          App.userEngine.clearData();

          // Final criteria
          var criteria = {
            username: App.pageController.getPath('username'),
            email: App.pageController.get('emailAddress'),
            startPage: 1,
            recordsPerPage: App.pageController.get('rowsPerSearch'),
            doPageCount: 'false'
          };
          App.userEngine.search(criteria, this, this._endSearch);

          // Save criteria for show more
          App.pageController.set('previousSearchCriteria', criteria);
        }
        catch (err) {
          // End search with error
          this._endSearch(null, err);
        }
      }

Next

Next up, creating a new user record …

Categories: Sproutcore

One Comment

  1. Christopher says:

    Great stuff! Keep it coming!

Leave a Reply