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 Apply, App.userEngine.save() is called.
If the user clicks Cancel, App.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 …