Next for Chililog

After a review of Chililog over the holiday period, I think the next steps is to make Chililog more scalable.

Firstly, I think we need to replace HornetQ with Flume. Flume allows us to funnel and aggregate log events. It also has agents to collect logs so that we don’t have to write our own.

Also, I think we need to replace mongoDB with the likes of Cassandra/DymanoDB. From my understanding, mongoDB is great if you have as much RAM as your data + indexes due to its use of memory mapped files. As such, storing a lot of log files is not a good fit.

Lastly, I need to upgrade the UI from Sproutcore V2 to EmberJS.

However, Flume has just release version 1.0.0. EmberJS Data is still at alpha and without the nested store functionality that I need. Cassandara and DynammoDb are also freshly minted. Because of this, I am going to let the dust settle for a few months before starting Chililog V2.

In the mean time, I am going to get started with the monitoring side of Chililog.

Sproutcore 2.0 > Amber > Ember

Looks like the new name for Sproutcore 2.0 is Ember.js.

http://yehudakatz.com/2011/12/12/amber-js-formerly-sproutcore-2-0-is-now-ember-js/

http://www.emberjs.com/

Sproutcore 2.0 moves to Amber

http://blog.sproutcore.com/changes-to-sproutcore/

Looking forward to the new project.

I guess this means that next version of Chililog will get a re-write using Amber too.

Heaps to do in the new year.

Rethink – Rewrite

It is getting towards the end of the year have I’ve been having a good think about Chililog.

Taking stock, I have:

  1. learnt something new – Sproutcore 2, HornetQ, Netty MongoDB
  2. been able to finally log javascript events over the net.
  3. created Chililog blog and web site.
  4. been invited to join the Netty team :-) .  Thanks Trustin.

So what next for Chililog?

I was going to start on monitoring logs.  But, in looking at the technology landscape, I think it is better to put time into hardening and scaling Chililog.

What that in mind, I’ve been reading up on Scala and Cassandra.  This has lead me to look at existing open source logging projects: flume, chukwa, scribe, logsandra, logstash, etc.  Wow – these projects have come on leaps and bounds since I last looked at them 12 months ago.

So over the next few months, I’m going to have a look at these projects and see if I can incorporate them in to Chililog.  These projects are already production tested and scalable so that is one less thing I have to do on Chililog.  If I can use one or more of these projects to collect log data, then I can focus on the part of Chililog that interests me most – analysing and presenting the log data.

Happy Holidays …

Sproutcore CRUD Tutorial – Part 5 Delete

Modal View/Edit Dialog with Delete button

To delete a record, the user must first view the record as detailed in the previous post.

The user is then presented with a red Delete button. If the user clicks the button, the user record is deleted and the modal dialog is closed.

Engine

If the user clicks DeleteApp.userEngine.removed() is called.

App.userEngine = SC.Object.create(App.EngineMixin, {

  // Code left out for brevity ...

  /**
   * Removes the user record on the server
   *
   * @param {String} documentID id of record to delete
   * @param {Object} [callbackTarget] Optional callback object
   * @param {Function} [callbackFunction] Optional callback function in the callback object.
   * Signature is: function(documentID, callbackParams, error) {}.
   * documentId is set to the id of the user to be deleted.
   * If there is no error, error will be set to null.
   * @param {Hash} [callbackParams] Optional Hash to pass into the callback function.
   */
  remove: function(documentID, callbackTarget, callbackFunction, callbackParams) {
    var url = '/api/users/' + documentID;
    var context = { documentID: documentID, callbackTarget: callbackTarget, callbackFunction: callbackFunction, callbackParams: callbackParams
    };

    $.ajax({
      type: 'DELETE',
      url: url,
      dataType: 'json',
      contentType: 'application/json; charset=utf-8',
      context: context,
      headers: this._createAjaxRequestHeaders(),
      error: this._ajaxError,
      success: this._endRemove
    });

    return;
  },

  /**
   * Callback from remove()
   *
   * 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.
   */
  _endRemove: function(data, textStatus, jqXHR) {
    var error = null;
    try {
      var record = App.store.find(App.UserRecord, this.documentID);
      record.destroy();
      App.store.commitRecords();
    }
    catch (err) {
      error = err;
      SC.Logger.error('userEngine.endErase: ' + err);
    }

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

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

  // Code left out for brevity ...

)};

Template

The handlebar template is in admin_users.html and is the same as for create user. The delete button is defined as App.DialogRemoveButton.

{{#view App.Dialog id="userDialog" style="display:none" class="admin ui-dialog ui-widget ui-widget-content ui-corner-all"}}
  <div class="dialogContainer">
    <div style="height:50px;">
      <ul id="dialogTabs" class="tabs">
       <li class="active"><a id="dialogGeneralTab" href="#dialogGeneralTabContent">General</a></li>
       <li><a id="dialogRolesTab" href="#dialogRolesTabContent">Roles</a></li>
      </ul>
    </div>
    <form id="dialogTabContent" class="tabContent" style="height:290px;">
      <fieldset id="dialogGeneralTabContent" class="active">
        {{view App.DialogUserNameField id="dialogUserNameField" }}
        {{view App.DialogDisplayNameField id="dialogDisplayNameField" }}
        {{view App.DialogEmailAddressField id="dialogEmailAddressField" }}
        {{view App.DialogStatusField id="dialogStatusField" }}
        {{view App.DialogPasswordField id="dialogPasswordField" }}
        {{view App.DialogConfirmPasswordField id="dialogConfirmPasswordField" }}
      </fieldset>
      <fieldset id="dialogRolesTabContent">
        {{view App.DialogIsSystemAdministratorField id="dialogIsSystemAdministratorField" }}
        {{#view App.DialogRepoAccessField id="dialogRepoAccessField" }}
          {{view LabelView}}
          <div class="input">
            {{view DataView}}
            <span class="help-inline">{{help}}</span>
            <div style="padding-top: 8px;">
              {{view AddButtonView id="dialogAddRepositoryAccessButton" class="btn"}}
              {{view RemoveButtonView id="dialogRemoveRepositoryAccessButton" class="btn"}}
            </div>
          </div>
        {{/view}}
      </fieldset>
    </form>
    <div class="clearfix dialogBottomBar">
      <div style="float: left;">
        {{view App.DialogPreviousButton id="dialogPreviousButton" class="btn"}}
        {{view App.DialogNextButton id="dialogNextButton" class="btn"}}
      </div>
      <div style="float: left; padding-left: 250px;">
        {{view App.DialogRemoveButton id="dialogRemoveButton" class="btn danger"}}
      </div>
      <div style="float: right;">
        {{view App.DialogWorkingImage id="workingImage3"}}
        {{view App.DialogOkButton id="dialogOkButton" class="btn primary"}}
        {{view App.DialogCancelButton id="dialogCancelButton" class="btn"}}
        {{view App.DialogApplyButton id="dialogApplyButton" class="btn"}}
      </div>
    </div>
  </div>
{{/view}}

Views

App.DialogRemoveButton is defined in admin_users_page.js:

/**
 * @class
 * Button to show next log entry
 */
App.DialogRemoveButton = App.ButtonView.extend({
  label: '_remove'.loc(),
  title: '_removeTooltip'.loc(),
  isVisibleBinding: SC.Binding.from('App.pageController.canRemove').oneWay().bool(),

  click: function() {
    App.statechart.sendAction('remove');
    return;
  }
});

Bindings

The isVisible property of App.DialogRemoveButton is bound to the canRemove property of App.pageController.

The delete button will only be visible if the record has not been modified and we are not saving or removing the record.

App.pageController = SC.Object.create({
  // Code not displayed for brevity ...

  /**
   * Flag to indicate if we can show the delete button
   *
   * @type Boolean
   */
  canRemove: function() {
    var recordStatus = this.getPath('selectedRecord.status');
    if (!SC.none(recordStatus) && recordStatus === SC.Record.READY_CLEAN && !this.get('isSavingOrRemoving')) {
      return YES;
    }

    return NO;
  }.property('selectedRecord.status', 'isSavingOrRemoving').cacheable(),

  // Code not displayed for brevity ...

)};

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 delete:

  • We start at Not Searching.
  • The Show User action triggers a state change to Showing Dialog.  The modal dialog is displayed.
  • If the user clicks Delete, the remove action is triggered. The state changes to Removing.  If the delete was successful, the state changes to Not Searching and the modal dialog is closed.  If error, the state changes to Showing Dialog and the error is displayed to the user.
  • Removing, the isSavingOrRemoving property of App.pageController is set to true. This will trigger the necessary changes in the views to let the user know that we are waiting on the server for a response.
App.statechart = SC.Statechart.create({
  rootState: SC.State.extend({
  initialSubstate: 'notSearching',

    /**
     * Prompt the user to enter criteria
     */
    notSearching: SC.State.extend({
      enterState: function() {
      },

      exitState: function() {
      },

      search: function() {
        App.pageController.set('errorMessage', '');
        this.gotoState('searching');
      },

      showMore: function() {
        App.pageController.set('errorMessage', '');
        this.gotoState('showingMore');
      },

      showUser: function(recordIndex) {
        recordIndex = parseInt(recordIndex);
        App.pageController.showDialog(recordIndex);
        this.gotoState('showingDialog');
      },

      createUser: function() {
        App.pageController.showDialog(-1);
        this.gotoState('showingDialog');
      }
    }),

    /**
     * Currently showing modal dialog containing selected record
     */
    showingDialog: SC.State.extend({
      enterState: function() {
      },

      exitState: function() {
      },

      /**
       * Delete clicked - delete and close dialog
       */
      remove: function() {
        if (confirm('_admin.user.confirmDelete'.loc())) {
          this.gotoState('removing');
        }
      },

      // More states not shown for brevity ...
    }),

    /**
     * Call server to delete our record
     */
    removing: SC.State.extend({

      enterState: function(ctx) {
        App.pageController.set('isSavingOrRemoving', YES);
        this._startRemove();
      },

      exitState: function() {
        App.pageController.set('isSavingOrRemoving', NO);
      },

      /**
       * Save selected record
       * @param {Boolean} closeWhenFinished If yes, we will exist the dialog of save is successful
       */
      _startRemove: function(closeWhenFinished) {
        try {
          var selectedRecord = App.pageController.get('selectedRecord');
          var documentID = selectedRecord.get(App.DOCUMENT_ID_RECORD_FIELD_NAME);
          App.userEngine.remove(documentID, this, this._endRemove);
        }
        catch (err) {
          // End search with error
          this._endRemove(null, null, err);
        }
      },

      /**
       * Called back when delete is finished
       * @param documentID DocumentID of the user record that was saved
       * @param params context params passed in startSave
       * @param error Error object. Null if no error.
       */
      _endRemove: function(documentID, params, error) {
        if (SC.none(error)) {
          App.pageController.hideDialog();
          this.gotoState('notSearching');
        } else {
          alert('Error: ' + error.message);
          this.gotoState('showingDialog');
        }
      }
    }),

    // More states not shown for brevity ...
  })
});

Sproutcore CRUD Tutorial – Part 4 Update

Modal View/Edit Dialog

Upon double clicking a user record on the listing page, the above modal dialog is displayed.

The user has the option to:

  • < – view previous record
  • > - view next record
  • OK - close the modal dialog
  • Delete - delete this user and close the modal dialog
  • Make changes to the record. If so the following options are available:
    • OK - to save changes and close the modal dialog
    • Cancel - to discard changes and close the modal dialog
    • Apply - to save changes and remain in the modal dialog

Engine

Upon double clicking a user record on the listing page, App.userEngine.edit() is called. This returns a nested SC.Record.

The record is then stored as the selectedRecord property of the App.pageController.  Views will be bound to this record so that data entered by the user will be populated in the record.

If the user clicks Save or ApplyApp.userEngine.save() is called.

If the user clicks CancelApp.userEngine.discardChanges() is called.

/** @class
 *
 * Manages user records and keeps them in sync with the server
 *
 * @extends SC.Object
 */
App.userEngine = SC.Object.create(App.EngineMixin, {

  ...

  /**
   * Returns an existing the user record for editing
   *
   * @param {String} documentID Document ID of the user record to edit
   * @returns {App.UserRecord}
   */
  edit: function(documentID) {
    var nestedStore = App.store.chain();
    var record = nestedStore.find(App.UserRecord, documentID);
    return record;
  },

  /**
   * Returns an existing the user record for editing
   *
   * @param {String} documentID Document ID of the user record to edit
   * @returns {App.UserRecord}
   */
  edit: function(documentID) {
    var nestedStore = App.store.chain();
    var record = nestedStore.find(App.UserRecord, documentID);
    return record;
  },

  /**
   * Saves the user record to the server
   * @param {App.UserRecord} record record to save
   * @param {Object} [callbackTarget] Optional callback object
   * @param {Function} [callbackFunction] Optional callback function in the callback object.
   * Signature is: function(documentID, callbackParams, error) {}.
   * The documentID will be set to the document ID of the saved record.
   * If there is no error, error will be set to null.
   * @param {Hash} [callbackParams] Optional Hash to pass into the callback function.
   */
  save: function(record, callbackTarget, callbackFunction, callbackParams) {
    var documentID = record.get(App.DOCUMENT_ID_RECORD_FIELD_NAME);
    var documentVersion = record.get(App.DOCUMENT_VERSION_RECORD_FIELD_NAME);
    var isAdding = (SC.none(documentVersion) || documentVersion === 0);
    var data = record.toApiObject();

    var url = '';
    var httpType = '';
    if (isAdding) {
      url = '/api/users/';
      httpType = 'POST';
    } else {
      url = '/api/users/' + documentID;
      httpType = 'PUT';
    }
    var context = { isAdding: isAdding, documentID: documentID, record: record,
      callbackTarget: callbackTarget, callbackFunction: callbackFunction, callbackParams: callbackParams
    };

    // Call server
    $.ajax({
      type: httpType,
      url: url,
      data: JSON.stringify(data),
      dataType: 'json',
      contentType: 'application/json; charset=utf-8',
      context: context,
      headers: this._createAjaxRequestHeaders(),
      error: this._ajaxError,
      success: this.endSave
    });

    return;
  },

  /**
   * Callback from save() after we get a response from the server to process
   * the returned info.
   *
   * 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.
   */
  endSave: function(data, textStatus, jqXHR) {
    var error = null;
    try {
      // Check
      var apiObject = data;
      if (this.isAdding) {
        this.documentID = apiObject[App.DOCUMENT_ID_AO_FIELD_NAME];
      } else if (this.documentID !== apiObject[App.DOCUMENT_ID_AO_FIELD_NAME]) {
        throw App.$error('_documentIDError', [ this.documentID, apiObject[App.DOCUMENT_ID_AO_FIELD_NAME]]);
      }

      // Remove temp record while creating/editing
      App.userEngine.discardChanges(this.record);

      // Save user details returned from server into the store
      App.userEngine._convertApiObjectsToRecords([data], App.UserRecord);

      // If we are editing the logged in user, then we better update the session data
      if (this.record.get(App.DOCUMENT_ID_RECORD_FIELD_NAME) ===
        App.sessionEngine.get('loggedInUser').get(App.DOCUMENT_ID_RECORD_FIELD_NAME)) {
        //TODO
      }
    }
    catch (err) {
      error = err;
      SC.Logger.error('userEngine.endSaveRecord: ' + err);
    }

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

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

  /**
   * Discard changes by removing nested store
   *
   * @param {App.UserRecord} record User record to discard
   */
  discardChanges: function(record) {
    if (!SC.none(record)) {
      var store = record.get('store');
      store.destroy();
    }
    return;
  }

  ...

});

Template

The handlebar template is in admin_users.html and is the same as for create user.

{{#view App.Dialog id="userDialog" style="display:none" class="admin ui-dialog ui-widget ui-widget-content ui-corner-all"}}
  <div class="dialogContainer">
    <div style="height:50px;">
      <ul id="dialogTabs" class="tabs">
       <li class="active"><a id="dialogGeneralTab" href="#dialogGeneralTabContent">General</a></li>
       <li><a id="dialogRolesTab" href="#dialogRolesTabContent">Roles</a></li>
      </ul>
    </div>
    <form id="dialogTabContent" class="tabContent" style="height:290px;">
      <fieldset id="dialogGeneralTabContent" class="active">
        {{view App.DialogUserNameField id="dialogUserNameField" }}
        {{view App.DialogDisplayNameField id="dialogDisplayNameField" }}
        {{view App.DialogEmailAddressField id="dialogEmailAddressField" }}
        {{view App.DialogStatusField id="dialogStatusField" }}
        {{view App.DialogPasswordField id="dialogPasswordField" }}
        {{view App.DialogConfirmPasswordField id="dialogConfirmPasswordField" }}
      </fieldset>
      <fieldset id="dialogRolesTabContent">
        {{view App.DialogIsSystemAdministratorField id="dialogIsSystemAdministratorField" }}
        {{#view App.DialogRepoAccessField id="dialogRepoAccessField" }}
          {{view LabelView}}
          <div class="input">
            {{view DataView}}
            <span class="help-inline">{{help}}</span>
            <div style="padding-top: 8px;">
              {{view AddButtonView id="dialogAddRepositoryAccessButton" class="btn"}}
              {{view RemoveButtonView id="dialogRemoveRepositoryAccessButton" class="btn"}}
            </div>
          </div>
        {{/view}}
      </fieldset>
    </form>
    <div class="clearfix dialogBottomBar">
      <div style="float: left;">
        {{view App.DialogPreviousButton id="dialogPreviousButton" class="btn"}}
        {{view App.DialogNextButton id="dialogNextButton" class="btn"}}
      </div>
      <div style="float: left; padding-left: 250px;">
        {{view App.DialogRemoveButton id="dialogRemoveButton" class="btn danger"}}
      </div>
      <div style="float: right;">
        {{view App.DialogWorkingImage id="workingImage3"}}
        {{view App.DialogOkButton id="dialogOkButton" class="btn primary"}}
        {{view App.DialogCancelButton id="dialogCancelButton" class="btn"}}
        {{view App.DialogApplyButton id="dialogApplyButton" class="btn"}}
      </div>
    </div>
  </div>
{{/view}}

Views

Views are identical to create user.

Bindings

Data and error message bindings are identical to create user.

Because the table CollectionView used to list user records (see Post #2 – Listing) is bound to the data store, the changes we push into the data store after saving are automatically applied to the DOM in the collection view. Try changing a user’s email address – after saving, the user’s email address in the listing table is updated as well.

I do want to discuss the enabling and disabling of the buttons on the modal dialog. Notice certain buttons are enabled or disabled depending on if the user had modified data or not. This is implemented using bindings from the button view to a property on the App.pageController which in turn observes the required data for changes.

For example, the disabled property of the Cancel and Apply buttons is bound to the canSave property of the App.pageController. It is only enabled if the user has changed the record (recordStatus !== SC.Record.READY_CLEAN) and we are not in the process of saving or removing a record.

/**
 * @class
 * Button to discard changes and close window
 */
App.DialogCancelButton = App.ButtonView.extend({
  label: '_cancel'.loc(),
  disabledBinding: SC.Binding.from('App.pageController.canSave').oneWay().bool().not(),

  click: function() {
    App.statechart.sendAction('cancel');
    return;
  }
});

/**
 * @class
 * Mediates between state charts and views for the main page
 */
App.pageController = SC.Object.create({
  // Code left out for brevity ...

  /**
   * Flag to indicate if we can show the cancel or apply buttons
   *
   * @type Boolean
   */
  canSave: function() {
    var recordStatus = this.getPath('selectedRecord.status');
    if (!SC.none(recordStatus) && recordStatus !== SC.Record.READY_CLEAN && !this.get('isSavingOrRemoving')) {
      return YES;
    }

    return NO;
  }.property('selectedRecord.status', 'isSavingOrRemoving').cacheable(),

  // Code left out for brevity ...
});

Another example are the next and previous buttons. The disabled property of the Next button is bound to the canShowNextRecord property of the App.pageController. It is only enabled if the user has not changed the record (recordStatus === SC.Record.READY_CLEAN) and we are not in the process of saving or removing a record and we are not at the last record.

/**
 * @class
 * Button to show next log entry
 */
App.DialogNextButton = App.ButtonView.extend({
  label: '_next'.loc(),
  title: '_nextTooltip'.loc(),
  disabledBinding: SC.Binding.from('App.pageController.canShowNextRecord').oneWay().bool().not(),

  click: function() {
    App.statechart.sendAction('showNextRecord');
    return;
  }
});

/**
 * @class
 * Mediates between state charts and views for the main page
 */
App.pageController = SC.Object.create({
  // Code left out for brevity ...

  /**
   * Flag to indicate if we can show the next button
   *
   * @type Boolean
   */
  canShowNextRecord: function() {
    var selectedRecordIndex = App.pageController.get('selectedRecordIndex');
    if (selectedRecordIndex === App.resultsController.get('length') - 1) {
      return NO;
    }

    var recordStatus = this.getPath('selectedRecord.status');
    if (!SC.none(recordStatus) && recordStatus !== SC.Record.READY_CLEAN && !this.get('isSavingOrRemoving')) {
      return NO;
    }

    return YES;
  }.property('selectedRecordIndex', 'selectedRecord.status', 'isSavingOrRemoving').cacheable(),

  // Code left out for brevity ...
});

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 update:

  • We start at Not Searching.
  • The Show User action triggers a state change to Showing Dialog.  The modal dialog is displayed.
  • If the user clicks OK, the OK action is triggered. If the record has not been modified, we simulate the cancel event. If the record has been modified, and the state changes to Saving.  If the save was successful, the state changes to Not Searching and the modal dialog is closed.  If error, the state changes to Showing Dialog and the error is displayed to the user.
  • If the user clicks Apply, the Apply action is triggered and the state changes to Saving.   If the save was successful,  the state changes to Showing Dialog. If error, the state also changes to Showing Dialog and the error is displayed to the user.
  • If the user clicks <, the showPreviousRecord action is triggered. We deselect the current record in the data store and select the previous one.
  • If the user clicks >, the showNextRecord action is triggered. We deselect the current record in the data store and select the next one.
App.statechart = SC.Statechart.create({
  rootState: SC.State.extend({
  initialSubstate: 'notSearching',

    /**
     * Prompt the user to enter criteria
     */
    notSearching: SC.State.extend({
      enterState: function() {
      },

      exitState: function() {
      },

      search: function() {
        App.pageController.set('errorMessage', '');
        this.gotoState('searching');
      },

      showMore: function() {
        App.pageController.set('errorMessage', '');
        this.gotoState('showingMore');
      },

      showUser: function(recordIndex) {
        recordIndex = parseInt(recordIndex);
        App.pageController.showDialog(recordIndex);
        this.gotoState('showingDialog');
      },

      createUser: function() {
        App.pageController.showDialog(-1);
        this.gotoState('showingDialog');
      }
    }),

    /**
     * Currently showing modal dialog containing selected record
     */
    showingDialog: SC.State.extend({
      enterState: function() {
      },

      exitState: function() {
      },

      /**
       * OK clicked - save and close dialog
       */
      ok: function() {
        // If record has not changed, then don't save
        var recordStatus = App.pageController.getPath('selectedRecord.status');
        if (!SC.none(recordStatus) && recordStatus === SC.Record.READY_CLEAN) {
          this.cancel();
          return;
        }

        var ctx = { action: 'ok' };
        this.gotoState('saving', ctx);
      },

      /**
       * Cancel clicked - discard and close dialog
       */
      cancel: function() {
        App.pageController.hideDialog();
        this.gotoState('notSearching');
      },

      /**
       * Apply clicked - save and keep dialog open
       */
      apply: function() {
        var ctx = { action: 'apply' };
        this.gotoState('saving', ctx);
      },

      /**
       * Show prior to the selected record
       */
      showPreviousRecord: function() {
        var recordIndex = App.pageController.get('selectedRecordIndex');
        if (recordIndex === 0) {
          return;
        }

        // Discard selected record
        App.pageController.deselectRecord();

        // Show previous
        recordIndex = recordIndex - 1;
        App.pageController.selectRecord(recordIndex);
      },

      /**
       * Show record after the selected record
       */
      showNextRecord: function() {
        var recordIndex = App.pageController.get('selectedRecordIndex');
        if (recordIndex === App.resultsController.get('length') - 1) {
          return;
        }

        // Discard selected record
        App.pageController.deselectRecord();

        // Show next
        recordIndex = recordIndex + 1;
        App.pageController.selectRecord(recordIndex);
      }
    }),
      // More actions not display here for brevity
    }),

    /**
     * Call server to save our record
     */
    saving: SC.State.extend({
      enterState: function(ctx) {
        App.pageController.set('isSavingOrRemoving', YES);
        this._startSave(ctx.action === 'ok');
      },

      exitState: function() {
        App.pageController.set('isSavingOrRemoving', NO);
      },

      /**
       * Save selected record
       * @param {Boolean} closeWhenFinished If yes, we will exist the dialog of save is successful
       */
      _startSave: function(closeWhenFinished) {
        try {
          var selectedRecord = App.pageController.get('selectedRecord');
          if (!App.pageController.validateDialog()) {
            this.gotoState('showingDialog');
            return;
          }

          // Call server
          var params = {closeWhenFinished: closeWhenFinished};
          App.userEngine.save(selectedRecord, this, this._endSave, params);
        }
        catch (err) {
          // End search with error
          this._endSave(null, null, err);
        }
      },

      /**
       * Called back when save is finished
       * @param documentID DocumentID of the user record that was saved
       * @param params context params passed in startSave
       * @param error Error object. Null if no error.
       */
      _endSave: function(documentID, params, error) {
        if (SC.none(error)) {
          // Find the correct index and select record again
          for (var i = 0; i < App.resultsController.get('length'); i++) {
            var userRecord = App.resultsController.objectAtContent(i);
            if (userRecord.get(App.DOCUMENT_ID_RECORD_FIELD_NAME) === documentID) {
              App.pageController.selectRecord(i);
              break;
            }
          }

          if (params.closeWhenFinished) {
            App.pageController.hideDialog();
            this.gotoState('notSearching');
          } else {
            this.gotoState('showingDialog');
          }
        } else {
          alert('Error: ' + error.message);
          this.gotoState('showingDialog');
        }
      }
    }),

    // More states not shown for brevity ...
  })
});

Next

Next up, delete

Sproutcore CRUD Tutorial – Part 3 Create

Modal Create Dialog

Upon clicking the green New User button, the above modal dialog is displayed. The user enters the data and then clicks:

  • OK - to save and close the modal dialog
  • Cancel - to discard changes and close the modal dialog
  • Apply - to save and remain in the modal dialog

Engine

On load or when the user clicks New User, App.userEngine.create() is called. This returns a nested SC.Record.

The record is then stored as the selectedRecord property of the App.pageController.  Views will be bound to this record so that data entered by the user will be populated in the record.

If the user clicks Save or ApplyApp.userEngine.save() is called.

If the user clicks CancelApp.userEngine.discardChagnes() is called.

/** @class
 *
 * Manages user records and keeps them in sync with the server
 *
 * @extends SC.Object
 */
App.userEngine = SC.Object.create(App.EngineMixin, {

  ...

  /**
   * Returns a new user record for editing
   *
   * @returns {App.UserRecord}
   */
  create: function() {
    var nestedStore = App.store.chain();
    var record = nestedStore.createRecord(App.UserRecord, {});
    record.set(App.DOCUMENT_VERSION_RECORD_FIELD_NAME, 0);
    return record;
  },

  /**
   * Returns an existing the user record for editing
   *
   * @param {String} documentID Document ID of the user record to edit
   * @returns {App.UserRecord}
   */
  edit: function(documentID) {
    var nestedStore = App.store.chain();
    var record = nestedStore.find(App.UserRecord, documentID);
    return record;
  },

  /**
   * Saves the user record to the server
   * @param {App.UserRecord} record record to save
   * @param {Object} [callbackTarget] Optional callback object
   * @param {Function} [callbackFunction] Optional callback function in the callback object.
   * Signature is: function(documentID, callbackParams, error) {}.
   * The documentID will be set to the document ID of the saved record.
   * If there is no error, error will be set to null.
   * @param {Hash} [callbackParams] Optional Hash to pass into the callback function.
   */
  save: function(record, callbackTarget, callbackFunction, callbackParams) {
    var documentID = record.get(App.DOCUMENT_ID_RECORD_FIELD_NAME);
    var documentVersion = record.get(App.DOCUMENT_VERSION_RECORD_FIELD_NAME);
    var isAdding = (SC.none(documentVersion) || documentVersion === 0);
    var data = record.toApiObject();

    var url = '';
    var httpType = '';
    if (isAdding) {
      url = '/api/users/';
      httpType = 'POST';
    } else {
      url = '/api/users/' + documentID;
      httpType = 'PUT';
    }
    var context = { isAdding: isAdding, documentID: documentID, record: record,
      callbackTarget: callbackTarget, callbackFunction: callbackFunction, callbackParams: callbackParams
    };

    // Call server
    $.ajax({
      type: httpType,
      url: url,
      data: JSON.stringify(data),
      dataType: 'json',
      contentType: 'application/json; charset=utf-8',
      context: context,
      headers: this._createAjaxRequestHeaders(),
      error: this._ajaxError,
      success: this.endSave
    });

    return;
  },

  /**
   * Callback from save() after we get a response from the server to process
   * the returned info.
   *
   * 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.
   */
  endSave: function(data, textStatus, jqXHR) {
    var error = null;
    try {
      // Check
      var apiObject = data;
      if (this.isAdding) {
        this.documentID = apiObject[App.DOCUMENT_ID_AO_FIELD_NAME];
      } else if (this.documentID !== apiObject[App.DOCUMENT_ID_AO_FIELD_NAME]) {
        throw App.$error('_documentIDError', [ this.documentID, apiObject[App.DOCUMENT_ID_AO_FIELD_NAME]]);
      }

      // Remove temp record while creating/editing
      App.userEngine.discardChanges(this.record);

      // Save user details returned from server into the store
      App.userEngine._convertApiObjectsToRecords([data], App.UserRecord);

      // If we are editing the logged in user, then we better update the session data
      if (this.record.get(App.DOCUMENT_ID_RECORD_FIELD_NAME) ===
        App.sessionEngine.get('loggedInUser').get(App.DOCUMENT_ID_RECORD_FIELD_NAME)) {
        //TODO
      }
    }
    catch (err) {
      error = err;
      SC.Logger.error('userEngine.endSaveRecord: ' + err);
    }

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

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

  /**
   * Discard changes by removing nested store
   *
   * @param {App.UserRecord} record User record to discard
   */
  discardChanges: function(record) {
    if (!SC.none(record)) {
      var store = record.get('store');
      store.destroy();
    }
    return;
  }

  ...

});

Template

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

{{#view App.Dialog id="userDialog" style="display:none" class="admin ui-dialog ui-widget ui-widget-content ui-corner-all"}}
  <div class="dialogContainer">
    <div style="height:50px;">
      <ul id="dialogTabs" class="tabs">
       <li class="active"><a id="dialogGeneralTab" href="#dialogGeneralTabContent">General</a></li>
       <li><a id="dialogRolesTab" href="#dialogRolesTabContent">Roles</a></li>
      </ul>
    </div>
    <form id="dialogTabContent" class="tabContent" style="height:290px;">
      <fieldset id="dialogGeneralTabContent" class="active">
        {{view App.DialogUserNameField id="dialogUserNameField" }}
        {{view App.DialogDisplayNameField id="dialogDisplayNameField" }}
        {{view App.DialogEmailAddressField id="dialogEmailAddressField" }}
        {{view App.DialogStatusField id="dialogStatusField" }}
        {{view App.DialogPasswordField id="dialogPasswordField" }}
        {{view App.DialogConfirmPasswordField id="dialogConfirmPasswordField" }}
      </fieldset>
      <fieldset id="dialogRolesTabContent">
        {{view App.DialogIsSystemAdministratorField id="dialogIsSystemAdministratorField" }}
        {{#view App.DialogRepoAccessField id="dialogRepoAccessField" }}
          {{view LabelView}}
          <div class="input">
            {{view DataView}}
            <span class="help-inline">{{help}}</span>
            <div style="padding-top: 8px;">
              {{view AddButtonView id="dialogAddRepositoryAccessButton" class="btn"}}
              {{view RemoveButtonView id="dialogRemoveRepositoryAccessButton" class="btn"}}
            </div>
          </div>
        {{/view}}
      </fieldset>
    </form>
    <div class="clearfix dialogBottomBar">
      <div style="float: left;">
        {{view App.DialogPreviousButton id="dialogPreviousButton" class="btn"}}
        {{view App.DialogNextButton id="dialogNextButton" class="btn"}}
      </div>
      <div style="float: left; padding-left: 250px;">
        {{view App.DialogRemoveButton id="dialogRemoveButton" class="btn danger"}}
      </div>
      <div style="float: right;">
        {{view App.DialogWorkingImage id="workingImage3"}}
        {{view App.DialogOkButton id="dialogOkButton" class="btn primary"}}
        {{view App.DialogCancelButton id="dialogCancelButton" class="btn"}}
        {{view App.DialogApplyButton id="dialogApplyButton" class="btn"}}
      </div>
    </div>
  </div>
{{/view}}

Modal Dialog View

The modal dialog view is implemented as App.Dialog in admin_users_page.js.

I’ve hooked up a standard SC.View (which is a div) with jQuery UI modal. In the didInsertElement() event handler which gets called just after the div is inserted into the DOM, I added the jQuery hook to make the div a modal dialog.

I’ve also setup an observer on the title property, didTitleChange(), to make that when the title property changes, the DOM is updated with the new title.

To make sure the div is not displayed until triggered by clicking a button, the style attribute in the template is set with “display:none”.

/**
 * @class
 * Dialog div
 */
App.Dialog = SC.View.extend({
  attributeBindings: ['title'],

  title: '',

  didTitleChange: function() {
    var title = App.pageController.get('dialogTitle');
    this.$().dialog('option', 'title', title);
  }.observes('App.pageController.dialogTitle'),

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

    // JQuery UI dialog setup
    this.$().dialog({
      autoOpen: false,
      height: 440,
      width: 850,
      resizable: false,
      modal: true,
      close: function(event, ui) {
        // For when the X is clicked
        App.statechart.sendAction('cancel');
      }
    });

    // Add event handler to tab <a>. Delegate does it for current and future tabs
    this.$().delegate('ul.tabs li > a', 'click', function(e) {
      var $this = $(this);
      var href = $this.attr('href');

      if (/^#\w+/.test(href)) {
        e.preventDefault();
      }

      App.viewUtils.activateTab($this);
    });
  }
});

The showing/hiding of the dialog is encapsulated in App.pageController.showDialog() and App.pageController.hideDialog() respectively. These function are called by the state chart as required.

Field Views

Field views are much like that described in the previous post.

For example, the App.DialogUserNameField field, is defined as:

App.DialogUserNameField = App.FieldView.extend({
  label: '_admin.user.username'.loc(),
  isRequired: YES,
  help: '_admin.user.username.help'.loc(),
  helpMessageDidChange: function() {
    var msg = App.pageController.get('usernameErrorMessage');
    if (SC.empty(msg)) {
      this._updateHelp('_admin.user.username.help'.loc(), NO);
    } else {
      this._updateHelp(msg, YES);
    }
  }.observes('App.pageController.usernameErrorMessage'),

  DataView : App.TextBoxView.extend(App.DialogFieldDataMixin, {
    classNames: 'medium'.w(),
    valueBinding: 'App.pageController.selectedRecord.username'
  })
});

Note that I am not using App.StackedFieldView but App.FieldView. The field view has the lable next to the data control rather than on top. App.FieldView is located in app_views.js.

/**
 * @class
 * Defines a fields where label and data are on the same line
 */
App.FieldView = SC.View.extend({
  classNames: 'field clearfix'.w(),

  /**
   * Template is just the message
   * @Type SC.Handlebars
   */
  defaultTemplate: SC.Handlebars.compile('{{view LabelView}}<div class="input">{{view DataView}}<span class="help-inline">{{help}}</span></div>'),

  /**
   * 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,

  /**
   * Help text
   * @type String
   */
  help: '',

  /**
   * Show the specified message to the user
   * @param msg Message to display to the user
   * @param {Boolean} isError YES if this is an error message, NO if not.
   */
  _updateHelp: function(msg, isError) {
    this.set('help', msg);
    this.$().removeClass('error');
    if (isError && !SC.empty(msg)) {
      this.$().addClass('error');
    }
  },

  /**
   *
   */
  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. Must 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 Bindings

The data controls in the modal dialog must be bound to a property of App.pageController.selectedRecord. We assume that App.pageController.selectedRecord will always be the SC.Record that is to be displayed for the user to edit. The state chart takes care of this.

For example, the user name is defined to be a textbox and is bound to App.pageController.selectedRecord.username.

Error Message Bindings

An observer called helpMessageDidChange() in each field view is setup to monitor an error message property defined in the App.pageController. For example, the username field’s observes App.pageController.usernameErrorMessage.

When App.pageController.usernameErrorMessage changes, the App.DialogUserNameField.helpMessageDidChange() method fires to display the error message if present.

Validators

Page level validation is implemented in App.pageController.validateDialog().

  /**
   * Validate the dialog data
   * @returns Boolean YES if ok, NO if error
   */
  validateDialog: function() {
    App.pageController.clearDialogErrors();

    var isError = NO;

    var selectedRecord = App.pageController.get('selectedRecord');
    var username = selectedRecord.get('username');
    if (SC.empty(username)) {
      App.pageController.set('usernameErrorMessage', '_admin.user.username.required'.loc());
      isError = YES;
    } else if (!App.viewValidators.checkCode(username)) {
      App.pageController.set('usernameErrorMessage', '_admin.user.username.invalid'.loc(username));
      isError = YES;
    }

    // More checks....

    // If error, then return to tab #1
    if (isError) {
      App.viewUtils.activateTab($('#dialogGeneralTab'));
    }

    return !isError;
  },

If there is an error, the corresponding error message property in the page controller is set. For example, “usernameErrorMessage” is used for the username. Setting this will trigger the view to display the error message next to the data control.

Field level validation (e.g. cannot type a letter in to a year field) should be implemented in data controls. However, I’ve not had the chance to implement that as yet.

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 adding:

  • We start at Not Searching.
  • The Create User action triggers a state change to Showing Dialog.  The modal dialog is displayed.
  • If the user clicks OK, the OK action is triggered and the state changes to Saving.  If the save was successful, the state changes to Not Searching and the modal dialog is closed.  If error, the state changes to Showing Dialog and the error is displayed to the user.
  • If the user clicks Apply, the Apply action is triggered and the state changes to Saving.   If the save was successful,  the state changes to Showing Dialog. If error, the state also changes to Showing Dialog and the error is displayed to the user.
App.statechart = SC.Statechart.create({
  rootState: SC.State.extend({
  initialSubstate: 'notSearching',

    /**
     * Prompt the user to enter criteria
     */
    notSearching: SC.State.extend({
      enterState: function() {
      },

      exitState: function() {
      },

      search: function() {
        App.pageController.set('errorMessage', '');
        this.gotoState('searching');
      },

      showMore: function() {
        App.pageController.set('errorMessage', '');
        this.gotoState('showingMore');
      },

      showUser: function(recordIndex) {
        recordIndex = parseInt(recordIndex);
        App.pageController.showDialog(recordIndex);
        this.gotoState('showingDialog');
      },

      createUser: function() {
        App.pageController.showDialog(-1);
        this.gotoState('showingDialog');
      }
    }),

    /**
     * Currently showing modal dialog containing selected record
     */
    showingDialog: SC.State.extend({
      enterState: function() {
      },

      exitState: function() {
      },

      /**
       * OK clicked - save and close dialog
       */
      ok: function() {
        // If record has not changed, then don't save
        var recordStatus = App.pageController.getPath('selectedRecord.status');
        if (!SC.none(recordStatus) && recordStatus === SC.Record.READY_CLEAN) {
          this.cancel();
          return;
        }

        var ctx = { action: 'ok' };
        this.gotoState('saving', ctx);
      },

      /**
       * Cancel clicked - discard and close dialog
       */
      cancel: function() {
        App.pageController.hideDialog();
        this.gotoState('notSearching');
      },

      /**
       * Apply clicked - save and keep dialog open
       */
      apply: function() {
        var ctx = { action: 'apply' };
        this.gotoState('saving', ctx);
      },

      // More actions not display here for brevity
    }),

    /**
     * Call server to save our record
     */
    saving: SC.State.extend({
      enterState: function(ctx) {
        App.pageController.set('isSavingOrRemoving', YES);
        this._startSave(ctx.action === 'ok');
      },

      exitState: function() {
        App.pageController.set('isSavingOrRemoving', NO);
      },

      /**
       * Save selected record
       * @param {Boolean} closeWhenFinished If yes, we will exist the dialog of save is successful
       */
      _startSave: function(closeWhenFinished) {
        try {
          var selectedRecord = App.pageController.get('selectedRecord');
          if (!App.pageController.validateDialog()) {
            this.gotoState('showingDialog');
            return;
          }

          // Call server
          var params = {closeWhenFinished: closeWhenFinished};
          App.userEngine.save(selectedRecord, this, this._endSave, params);
        }
        catch (err) {
          // End search with error
          this._endSave(null, null, err);
        }
      },

      /**
       * Called back when save is finished
       * @param documentID DocumentID of the user record that was saved
       * @param params context params passed in startSave
       * @param error Error object. Null if no error.
       */
      _endSave: function(documentID, params, error) {
        if (SC.none(error)) {
          // Find the correct index and select record again
          for (var i = 0; i < App.resultsController.get('length'); i++) {
            var userRecord = App.resultsController.objectAtContent(i);
            if (userRecord.get(App.DOCUMENT_ID_RECORD_FIELD_NAME) === documentID) {
              App.pageController.selectRecord(i);
              break;
            }
          }

          if (params.closeWhenFinished) {
            App.pageController.hideDialog();
            this.gotoState('notSearching');
          } else {
            this.gotoState('showingDialog');
          }
        } else {
          alert('Error: ' + error.message);
          this.gotoState('showingDialog');
        }
      }
    }),

    // More states not shown for brevity ...
  })
});

Note the use of a context in the ok and apply actions: this.gotoState(‘saving’, ctx);. This context argument allows us to distinguish an ok for an apply as saves us defining a global variable or another state.

Next

Next up, update

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 …

Sproutcore V2 CRUD Tutorial Part 1

Some people found my CRUD tutorial with SC.TableView on SC1 useful.

Here’s how I’ve done the same thing for SC2.

The use case is the same – User Maintenance.

  1. Create a user record via a modal “details” pane
  2. Read user records by listing them in username order using Handlebar Templates
  3. Update a user record via a modal “details” pane
  4. Delete a user record via a button on the modal “details” pane

Unfortunately, because User Maintenance is an administration function, we have disabled it on our demo site.  However, you can download and install Chililog on your local machine.  It wont take you long.

You can get the code here.

Here are some screen shots.



Directory Structure

I’ve structured the code as follows:

  • root – .html template files are in the virtual root folder
    • template – html fragments for which I use apply.sh shell script to inject into .html files
    • js – javascript files for this app
      • lib – 3rd party javascript libraries
    • images – pictures
    • css – style sheets

Every page includes the following javascript file

  • js/libs/jquery-1.6.2.min.js
  • js/libs/jquery-ui-1.8.16.custom.min.js
  • js/libs/sproutcore-2.0.beta.3.js
  • js/libs/sproutcore-datetime.js
  • js/libs/sproutcore-utils.js
  • js/libs/sproutcore-statechart.js
  • js/libs/sproutcore-datastore.js
  • js/app.js – namespace declaration and global functions
  • js/app_strings.js – definition of strings table so that in the future, I can localize
  • js/app_views.js – commonly used views like fields and validators
  • js/app_datastore.js – data model definitions
  • js/app_engine.js – handles communication with server
  • js/<page name>.js – this file contains page specific code including page controller and state chart

I know that there are a lot of files here and we can optimize by consolidation and minification.   This will remain a TO DO until SC2 release candidate and BPM release candidate are released.

For now, I just downloaded all the required SC2 files from github and manually consolidated the files.

Data Model

The data model is defined in app_datastore.js.

I’ve defined the record in the standard way as recommend by the guides.

App.UserRecord = SC.Record.extend({

  primaryKey: App.DOCUMENT_ID_RECORD_FIELD_NAME,

  documentID: SC.Record.attr(String),
  documentVersion: SC.Record.attr(Number, { defaultValue: 0 }),
  username: SC.Record.attr(String, { defaultValue: '', isRequired: YES }),
  emailAddress: SC.Record.attr(String, { defaultValue: '' }),
  password: SC.Record.attr(String, { defaultValue: '' }),
  roles: SC.Record.attr(Array, { defaultValue: [] }),
  currentStatus: SC.Record.attr(String, { defaultValue: App.USER_STATUS_ENABLED }),
  displayName: SC.Record.attr(String, { defaultValue: '' }),
  gravatarMD5Hash: SC.Record.attr(String),

  /**
   * Returns the text description of the current status
   * @type String
   */
  currentStatusText: function() {
    var currentStatus = this.get('currentStatus');
    return App.USER_STATUS_MAP[currentStatus];
  }.property('currentStatus').cacheable(),

  /**
   * Cached system.administrator role
   */
  isSystemAdministrator: SC.Record.attr(Boolean, { defaultValue: NO }),

  /**
   * Cached array of data hash {repository: repoName, role: roleName} representing the repositories that can be
   * accessed by this user and the role
   */
  repositoryAccesses: SC.Record.attr(Array, { defaultValue: [] }),

  /**
   * Flag to be set so that we can trigger saving when adding/deleting repository access items to the array
   * This affects our flagging of if a record has changed
   */
  repositoryAccessesChanged: SC.Record.attr(Boolean, { defaultValue: NO }),

  /**
   * Maps server api data into this user record
   *
   * @param {Object} userAO
   */
  fromApiObject: function(userAO) {
    // Code removed for brevity
  },

  /**
   * Maps user record data to api object
   *
   * @returns {Object} userAO
   */
  toApiObject: function() {
    // Code removed for brevity
  }

});

Note the methods toApiObject() and fromApiObject().  These methods maps our record with the JSON data objects returned by our server API (hence the name API object).

This mapping is performed in the data store.

Data Store

The data store is also defined in app_datastore.js.

The conventional approach is to define a data source for the data store.  See section 3 of my SC1 tutorial.  The basic flow is:

  1. Create a nested record. The record status is READY_NEW.
  2. Add data to the nested record. The record status is READY_DIRTY.
  3. Commit changes.  The record status is BUSY_COMMITTING.
  4. Data store will use the data source to create an AJAX call to the server.  Record status will be set to READY_CLEAN when done.

For SC2, I’ve used a different approach – the “reference” data store.  I was introduced to this method by Erich Ocean when I was contributing to the guides.  Not sure why but the page is now hidden in the guides but it can be found here.  Refer to section 2.1.

Basically, I define a fixtures like data source that does NOT call the server.  The AJAX call to the server is handled in the view layer.  The flow is:

  1. Create a nested record.
  2. Add data to the nested record.
  3. Send nested changes to the server using AJAX code
  4. If successful, server returns record data. Use the data from the server to create a new record and insert into the data store.
  5. Discard nested record.

Why did I chose this approach?

  1. I wanted my user to wait while records are being saved/deleted on the server.  While waiting, I would have spinning animated GIF showing in the modal dialog.  To me, it seems easier to do this in another layer compared to observing the record status.
  2. If there was an error from the server (e.g. duplicate username), it was easier to handle because the record was not in the data store yet.

Here’s the code for the do nothing data source:

App.store = SC.Store.create().from('App.DataSource');

/** @class
 *
 * The Chililog data source is a bit like Seinfeld show. It is a data source that does NOTHING.
 *
 * This is because it is a read only copy of the data that is on the server.  It is the responsibility of the engines
 * to sync the data source with the data on the server.
 *
 * This data source is a big copy of SC.FixturesDataSource.
 *
 * @extends SC.DataSource
 */
App.DataSource = SC.DataSource.extend({

  fetch: function(store, query) {
    return NO; // return YES if you handled the query
  },

  retrieveRecord: function(store, storeKey) {
    return NO; // return YES if you handled the storeKey
  },

  createRecord: function(store, storeKey, params) {
    var id = store.idFor(storeKey);
    var recordType = store.recordTypeFor(storeKey);
    this._invalidateCachesFor(recordType, storeKey, id);

    store.dataSourceDidComplete(storeKey);

    return YES; // return YES if you handled the storeKey
  },

  updateRecord: function(store, storeKey, params) {
    var id = store.idFor(storeKey);
    var recordType = store.recordTypeFor(storeKey);
    this._invalidateCachesFor(recordType, storeKey, id);

    store.dataSourceDidComplete(storeKey);

    return YES; // return YES if you handled the storeKey
  },

  destroyRecord: function(store, storeKey, params) {
    var id = store.idFor(storeKey);
    var recordType = store.recordTypeFor(storeKey);
    this._invalidateCachesFor(recordType, storeKey, id);

    store.dataSourceDidDestroy(storeKey);

    return YES;  // return YES if you handled the storeKey
  },

  _invalidateCachesFor: function(recordType, storeKey, id) {
    var cache = this._storeKeyCache;
    if (cache) {
      delete cache[SC.guidFor(recordType)];
    }
    return this;
  }

});

The Engine

The engine layer is the glue between the javascript app and the server. To me, it is like a data controller.

The engine is coded in app_engine.js.

For the user engine, the main methods are:

  • create() – Returns a new nested record for editing
  • edit() – Returns an existing nested record for editing
  • save() – Saves a nested record returned by create() or edit()
  • discardChanges() – Discards a nested record returned by create() or edit()
  • remove() – Deletes a record
  • search() – Retrieveds records from the server matching the specified criteria and puts then in the data store
  • getRecords() – Returns records in the data store matching the specified criteria

I will explain the workings of the engine layer in the context of CRUD in up coming posts.

Next

Next up, listing records

Streaming using WebSockets and Sproutcore

This is a quick brain dump on how I the Chililog Workbench Stream page works.

I don’t profess to be a Sproutcore guru so if you know of a better way to do what I’ve done, please let me know!

The code is located here: https://github.com/chililog/chililog-server/tree/master/src/main/sc2

Stream.html Page

The stream page is just a standard HTML page.  No tricky server side code here.  I’ve used Sproutcore V2 to do all the UI rendering.

The main action happens with this bit of html:

<script type="text/javascript">

<div class="container">

<div class="title">

<h1>Stream</h1>

Watch real time log entries as they come in. Select your repository and click Start.</div>

<div id="streamContent" class="clearfix">

<form class="alert-message block-message info form-stacked">

<fieldset>
        {{view App.RepositoryField id="repositoryField" }}
        {{view App.SeverityField id="severityField" }}
        {{view App.SourceField id="sourceField" }}
        {{view App.HostField id="hostField" }}
        {{view App.ActionButton id="actionButton" class="btn primary"}}&nbsp;&nbsp;&nbsp;
        {{view App.ClearButton id="clearButton" class="btn" isVisibleBinding="App.pageController.showActionButton2" }}

<div class="clearfix"></div>

</fieldset>

    </form>

    {{view App.ErrorMessage id="errorMessage"}}

<div id="results" style="display: none;">

<div class="heading ui-corner-all">Log Entries</div>

</div>

    {{#view id="bottombar" class="clearfix"}}

<div style="float: right;">
        {{view App.ClearButton id="clearButton2" class="btn" isVisibleBinding="App.pageController.showActionButton2" }}
        &nbsp;&nbsp;&nbsp;
        {{view App.ActionButton id="actionButton2" class="btn primary" isVisibleBinding="App.pageController.showActionButton2" }}</div>

<div style="float: left;">
        {{view App.TestMessageButton id="testMessageButton" class="btn" isVisibleBinding="App.pageController.isStreaming" }}</div>

    {{/view}}</div>

  ...
</script>

Points to note:

  • The form #streamContent > form renders the streaming criteria.
  • #results is where incoming log entries will be rendered.
  • #bottombar holds the buttons that appear at the bottom of the page.
  • The “code behind” specific to this html file is stream.js.
  • The CSS and styling mainly comes from Twitter Bootstrap

Views

Fields

I’ve got views named like App.RepositoryField and App.SourceField.

I’ve taken a field to be a label, a data entry control and optional help.

To stop repetition, I found it easy to define a generic field views (in app_views.js) :

  • App.FieldView – label and data control on the same line
  • App.StackedFieldView - label on top of data control.

I then inherit and customise these generic fields as so:

/**
 * @class
 * Source field
 */
App.SourceField = App.StackedFieldView.extend({
  label: '_stream.source'.loc(),

  DataView : App.TextBoxView.extend(App.CriteriaFieldDataMixin, {
    classNames: 'medium'.w(),
    valueBinding: 'App.pageController.source',
    name: 'source',
    disabledBinding: SC.Binding.from('App.pageController.isStreaming').oneWay().bool()
  })
});

Simple Bindings

I’ve set my fields and other views to bind to values in the App.pageController (see below).

To bind a property’s value, just append the word “Binding” to the property name and set the value of the property to the name of the object containing the desired value.

In the above example, valueBinding  is set to App.pageController.source. This means that every time the value of App.pageController.source changes, the value property my text box will be set to new value.  The browser will then render the new value.

Complex Bindings

Sometimes, you need more than a simple 1-1 mapping.  Sproutcore supports binding transformation.

In the above example, disableBinding is set to SC.Binding.from(‘App.pageController.isStreaming’).oneWay().bool().

This means :

  • the property disable will be set to the contents of App.pageController.isStreaming.  
  • oneWay() flags that App.pageController.isStreaming NOT be set to the value of disabled.
  • bool() transforms the data content into a true/false.

For more on Sproutcore transformation, see http://wiki.sproutcore.com/w/page/12412963/Runtime-Bindings.

I know it is old documentation. I’ve tried to submit this to the guides but my pull request has not been accepted as yet.

Controllers

I’ve tried to make sure that views and business logic do not directly communicate with each other.  I feel this separation of concern makes the app easier to maintain.

They communicate via App.pageController and App.statechart.

  • Business logic changes properties in the App.pageController  and views react to that
  • Views updates  App.pageController  with data and triggers business logic by sending actions to App.statechart.
/**
 * @class
 * Mediates between state charts and views
 */
App.pageController = SC.Object.create({
  /**
   * Selected item of the repository field
   *
   * @type App.RepositoryStatusRecord
   */
  repository: null,

  /**
   * Selected item of the severity field
   *
   * @type SC.Object
   */
  severity: null,

  /**
   * Value of the source field
   *
   * @type String
   */
  source: '',

  /**
   * Value of the host field
   *
   * @type String
   */
  host: '',

  /**
   * Error message to display
   *
   * @type String
   */
  errorMessage: '',

  /**
   * Indicates if we are currently streaming or not
   *
   * @type Boolean
   */
  isStreaming: NO,

  /**
   * Maximum number of log entries displayed. If this is exceeded, the earliest entries are deleted
   *
   * @type int
   */
  maxRowsToDisplay: 1000,

  /**
   * Options for displaying in the repository dropdown
   *
   * @type SC.ArrayProxy of SC.RepositoryStatusRecord
   */
  repositoryOptions: SC.ArrayProxy.create(),

  /**
   * Options for displaying in the severity dropdown
   *
   * @type Array of SC.Object
   */
  severityOptions: [
    SC.Object.create({label: '_repositoryEntryRecord.Severity.Emergency'.loc(), value: '0'}),
    SC.Object.create({label: '_repositoryEntryRecord.Severity.Action'.loc(), value: '1'}),
    SC.Object.create({label: '_repositoryEntryRecord.Severity.Critical'.loc(), value: '2'}),
    SC.Object.create({label: '_repositoryEntryRecord.Severity.Error'.loc(), value: '3'}),
    SC.Object.create({label: '_repositoryEntryRecord.Severity.Warning'.loc(), value: '4'}),
    SC.Object.create({label: '_repositoryEntryRecord.Severity.Notice'.loc(), value: '5'}),
    SC.Object.create({label: '_repositoryEntryRecord.Severity.Information'.loc(), value: '6'}),
    SC.Object.create({label: '_repositoryEntryRecord.Severity.Debug'.loc(), value: '7', selected: YES })
  ],

  /**
   * Flag to indicate if we want the bottom bar to show or not
   *
   * @type Boolean
   */
  showActionButton2: NO,

  /**
   * Writes a log entry to the results area
   * @param {Object} logEntry data to write to page
   * @param {Boolean} doSeverityCheck YES to perform severity check to decide if log entry is to be dispalyed or not
   */
  writeLogEntry: function (logEntry, doSeverityCheck) {
   ...
  }
});

State Chart

If you are developing an app using SC2, please consider using a State Chart.  I really helps order you thinking and your logic.

App.statechart = SC.Statechart.create({

  rootState: SC.State.extend({

    initialSubstate: 'notStreaming',

    /**
     * Prompt the user for criteria
     */
    notStreaming: SC.State.extend({
      enterState: function() {
      },

      exitState: function() {
      },

      doStart: function() {
        App.pageController.set('errorMessage', '');
        this.gotoState('streaming');
      }
    }),

    /**
     * Startup web socket and wait for incoming messages
     */
    streaming: SC.State.extend({
      enterState: function() {
        App.pageController.set('isStreaming', YES);
        App.streamingController.startStreaming();
      },

      /**
       * Stop streaming
       */
      doStop: function() {
        App.pageController.set('errorMessage', '');
        this.gotoState('notStreaming');
      },

      exitState: function() {
        App.streamingController.stopStreaming();
        App.pageController.set('isStreaming', NO);
      }
    })
  })
});

This is a simple state chart.

  1. Start state is notStreaming.
  2. If the you clicks Start button, it sends a doStart action to the state chart.
  3. This triggers the doStart() under notStreaming to execute. This moves the state to streaming.
  4. While streaming, if the user clicks Stop, it sends a doStop action to the state chart.
  5. This triggers the doStop() under streaming to execute. This moves the state back to notStreaming.

During the transitions, the isStreaming property in the App.pageController is updated.
This will trigger our views to enable/disable (like App.SourceField as detailed above) as well as change label (like App.ActionButton which changes between start and stop).

Receiving Log Entries

When the Start button is clicked, we start receiving log entries.

For simplicity, I’ve put the code in its own controller: App.streamingController.

App.streamingController = SC.Object.create({
  /**
   * Current Websocket being used to talk to the server
   *
   * @type WebSocket
   */
  webSocket: null,

  /**
   * Make web socket connection and wait for log entries to come down
   */
  startStreaming: function() {
    try {
      $('#results').css('display', 'block');
      var webSocket = new App.WebSocket('ws://' + document.domain + ':61615/websocket');

      webSocket.onopen = function () {
        SC.Logger.log('Socket opening');

        var request = {
          MessageType: 'SubscriptionRequest',
          MessageID: new Date().getTime() + '',
          RepositoryName: App.pageController.getPath('repository.name'),
          Source: App.pageController.getPath('source'),
          Host: App.pageController.getPath('host'),
          Severity: App.pageController.getPath('severity.value'),
          Username: App.sessionEngine.getPath('loggedInUser.username'),
          Password: 'token:' + App.sessionEngine.get('authenticationToken')
        };
        var requestJSON = JSON.stringify(request);
        webSocket.send(requestJSON);

        App.pageController.writeLogEntry({
          Timestamp: '',
          Source: 'workbench',
          Host: 'local',
          Severity: '6',
          Message: 'Started.  Waiting for messages ...'
        }, false);
      };

      webSocket.onmessage = function (evt) {
        SC.Logger.log('Socket received message: ' + evt.data);
        try {
          var response = JSON.parse(evt.data);
          if (!response.Success) {
            // Turn our error into a message for display
            response.LogEntry = {
              Timestamp: '',
              Source: 'Chililog',
              Host: 'Chililog',
              Severity: '3',
              Message: response.ErrorMessage
            };
          }
          App.pageController.writeLogEntry(response.LogEntry, true);
        }
        catch (exception) {
          SC.Logger.log('Error parsing log entry. ' + exception);
        }
      };

      webSocket.onclose = function (evt) {
        SC.Logger.log('Socket close');
        App.pageController.writeLogEntry({
          Timestamp: '',
          Source: 'workbench',
          Host: 'local',
          Severity: '6',
          Message: 'Stopped'
        }, false);
      };

      webSocket.onerror = function (evt) {
        SC.Logger.log('Socket error: ' + evt.data);
      };

      App.streamingController.set('webSocket', webSocket);
    } catch (exception) {
      App.pageController.errorMessage = exception;
      this.gotoState('notStreaming');
    }
  },

  /**
   * Stop streaming
   */
  stopStreaming: function() {
    try {
      App.streamingController.get('webSocket').close();
    } catch (exception) {
      SC.Logger.log('Error closing web socket: ' + exception);
    }
  }
});

When we start streaming, App.streamingController.startStreaming() is called. This establishes a web socket connect with the server.

When log entries are received, it uses App.pageController.writeLogEntry() to render it.  I’ve written directly to the DOM using jQuery rather than use a DataStore because:

  • I found that the data store performed very slowly compared to writing directly to the DOM when there is a large number of log entries to display
  • Because log entries are displayed as read only, I do not need CRUD functions of SC.Record and SC.DataStore
When set stop streaming, App.streamingController.stopStreaming() is called.

Sending Log Entries

To send test log entries, I setup another web socket.

I put this code local to the Send Test Message button.

App.TestMessageButton = App.ButtonView.extend({
  label: '_stream.test'.loc(),

  click: function() {
    this.set('disabled', YES);

    var username = App.sessionEngine.getPath('loggedInUser.username');
    var scDate = SC.DateTime.create({ timezone: 0});
    var ts = scDate.toFormattedString('%Y-%m-%dT%H:%M:%S.%sZ');
    var request = {
      MessageType: 'PublicationRequest',
      MessageID: new Date().getTime() + '',
      RepositoryName: App.pageController.getPath('repository.name'),
      Username: App.sessionEngine.getPath('loggedInUser.username'),
      Password: 'token:' + App.sessionEngine.get('authenticationToken'),
      LogEntries: [
        { Timestamp: ts, Source: 'workbench', Host: 'local', Severity: '7', Message: 'Test DEBUG message sent from browser with all sorts of funny characters !@#$%^&*()_+{}[]:";\'<>,.?/ ' + navigator.userAgent},
        { Timestamp: ts, Source: 'workbench', Host: 'local', Severity: '4', Message: 'Test WARNING message with a timestamp and a very long example of a java class path org.chililog.server.pubsub.websocket.AVeryLongClassName. The time is now ' + new Date() },
        { Timestamp: ts, Source: 'workbench', Host: 'local', Severity: '3', Message: 'Test ERROR message sent by ' + username}
      ]
    };

    try {
      var me = this;
      var webSocket = new App.WebSocket('ws://' + document.domain + ':61615/websocket');

      webSocket.onopen = function () {
        SC.Logger.log('Test Socket opening');
        var requestJSON = JSON.stringify(request);

        // Sent it
        webSocket.send(requestJSON);
      };

      webSocket.onmessage = function (evt) {
        SC.Logger.log('Socket received message: ' + evt.data);
        try {
          var response = JSON.parse(evt.data);
          if (!response.Success) {
            response.LogEntry = { Timestamp: '', Source: 'Chililog', Host: 'Chililog', Severity: '3', Message: response.ErrorMessage };
            App.pageController.writeLogEntry(response.LogEntry, true);
          }

          // Close after we get a response so that button is enabled and the user can send another message
          webSocket.close();
        }
        catch (exception) {
          SC.Logger.log('Error parsing log entry. ' + exception);
        }
      };

      webSocket.onclose = function (evt) {
        SC.Logger.log('Test Socket close');
        me.set('disabled', NO); //cannot use this because it is the websocket
      };

      webSocket.onerror = function (evt) {
        SC.Logger.log('Test Socket error: ' + evt.data);
        var logEntry = { Timestamp: '', Source: 'Chililog', Host: 'Chililog', Severity: '3', Message: evt.data };
        App.pageController.writeLogEntry(logEntry, true);
      };
    } catch (exception) {
      SC.Logger.log('Test Socket error: ' + evt.data);
      var logEntry = { Timestamp: '', Source: 'Chililog', Host: 'Chililog', Severity: '3', Message: exception };
      App.pageController.writeLogEntry(logEntry, true);
    }

    return;
  }
});