The source code for this application is posted on GitHub at https://github.com/hoitomt/Sencha-Tutorial
Sencha has formalized the MVC pattern recently as part of the ExtJS 4 release. Check out this link for more information.
Create the Sencha Application
I’m back with the Sencha portion of the application. We are going to create the application using the MVC (Model View Controller) pattern
Update your Rails App
In your rails public/javascripts directory create the following folders.
app
app/controllers
app/models
app/views
Add the sencha touch folder to the public/javascripts folder. Take a look at the Hello World application from Sencha for some assistance on downloading the zip file containing all of the applicable files. I downloaded it and extracted the entire package into public/javascripts/. Then I renamed the folder touch_1_0_1. So public/javascripts/touch_1_0_1 has the following directories: docs, examples, jsbuilder, pkgs, resources, src, and test. It also contains some of the other files that come with the Sencha touch download.
Now copy the stylesheet over to a directory that Rails likes. From public/javascripts/touch_1_0_1/resources/css copy “sencha-touch–debug.css” over to public/stylesheets/. If you did the first part of this tutorial, then you will now have scaffold.css and sencha-touch-debug-css in the public/stylesheets directory.
Update your index file with the references to the Sencha Touch file and stylesheet. Here is my index file.
<% if(mobile_device?) %>
<!-- Code Viewable to mobile devices -->
<%= content_for :head do %>
<%= stylesheet_link_tag 'sencha-touch-debug' %>
<%= javascript_include_tag 'touch_1_0_1/sencha-touch-debug' %>
<style>
.synced {
background-color: #EDE613;
}
</style>
<% end %>
<% else %>
<%= content_for :head do %>
<%= stylesheet_link_tag 'scaffold' %>
<%= javascript_include_tag :defaults %>
<% end %>
<h1>Listing contacts</h1>
<table>
<tr>
<th>First Name</th>
<th>Last Name</th>
<th>Email</th>
<th>Phone</th>
<th></th>
<th></th>
<th></th>
</tr>
<% @contacts.each do |contact| %>
<tr>
<td><%= contact.first_name %></td>
<td><%= contact.last_name %></td>
<td><%= contact.email %></td>
<td><%= contact.phone %></td>
<td><%= link_to 'Show', contact %></td>
<td><%= link_to 'Edit', edit_contact_path(contact) %></td>
<td><%= link_to 'Destroy', contact, :confirm => 'Are you sure?', :method => :delete %></td>
</tr>
<% end %>
</table>
<br />
<%= link_to 'New Contact', new_contact_path %>
<% end %>
Create the Sencha App
Starting point: app.js
Now you should be ready to start building the application. Let’s start with the entry point into the application, the app.js file. Put the app.js file into public/javascripts/app. The code below creates an instance of the viewport that will be used to display our panels. The Sencha API it give some helpful information about Ext.regApplication. The Sencha API will be your BFF as you build more applications. Creating your application with the regApplication function automatically creates your app.views, app.models, app.stores, and app.controllers namespaces which makes it easy to navigate through your application
Ext.regApplication(
{
name: 'app',
launch: function() {
this.views.viewport = new this.views.Viewport();
}
});
Views: Viewport.js
Now let’s create the Viewport.js file. Put the Viewport.js file in public/javascripts/app/views. The code below sets up the main panel that will provide the navigation for all of your sub-panels. When navigating between slides the code will actually be switching the active panel in the viewport. The viewport below shows all of the panels in the application. We haven’t created these yet so your application will crash if you try to run it right now. You can go ahead and create empty files for the 5 files below (ContactsList, ContactShow, ContactEdit, ContactNew, and ContactCatalog). Put them all into public/javascripts/app/views.
//app.views.Viewport
app.views.Viewport = Ext.extend(Ext.Panel, {
fullscreen: true,
layout: 'card',
cardSwitchAnimation: 'slide',
initComponent: function() {
Ext.apply(app.views, {
contactsList: new app.views.ContactsList(),
contactShow: new app.views.ContactShow(),
contactEdit: new app.views.ContactEdit(),
contactNew: new app.views.ContactNew(),
contactCatalog: new app.views.ContactCatalog()
});
Ext.apply(this, {
items: [
app.views.contactsList,
app.views.contactShow,
app.views.contactEdit,
app.views.contactNew,
app.views.contactCatalog
]
});
app.views.Viewport.superclass.initComponent.apply(this, arguments);
}
});
Update your index file (app/views/contacts/index.html.erb) to add the necessary imports. Then you can run your application and you should see the empty ContactsList panel displayed.
<%= javascript_include_tag 'app/app' %>
<%= javascript_include_tag 'app/views/ContactCatalog' %>
<%= javascript_include_tag 'app/views/ContactEdit' %>
<%= javascript_include_tag 'app/views/ContactNew' %>
<%= javascript_include_tag 'app/views/ContactShow' %>
<%= javascript_include_tag 'app/views/ContactsList' %>
<%= javascript_include_tag 'app/views/Viewport' %>
Model: Contact.js
Now let’s take a look at the model. Create Contact.js in public/javascripts/app/models. Our model serves three purposes:
1. Get data from the web server for the application
2. Store data locally in localstorage
3. Post data to the web server that has been captured on the device.
We’re going to do this with multiple proxies. The Sencha proxies make it easy to persist data. The code below defines the fields, validations, and a proxy for saving a single model instance to the web server. I like using xml to transmit back and forth but I notice that the Sencha guys like using json. XML is more comfortable for me at the moment and it seems to work well with ROR.
//Contact.js
app.models.Contact = new Ext.regModel('app.models.Contact', {
fields: [
{name: 'id', type: 'int'},
{name: 'remote_id', type: 'int'},
{name: 'synced', type: 'boolean'},
{name: 'first_name', type: 'string'},
{name: 'last_name', type: 'string'},
{name: 'email', type: 'string'},
{name: 'phone', type: 'string'}
],
validations: [
{type: 'presence', field: 'first_name'},
{type: 'presence', field: 'last_name'}
],
proxy: {
type: 'ajax',
url: 'contacts.xml',
reader: {
type: 'xml',
record: 'contact'
},
writer: {
type: 'xml',
record: 'contact'
}
}
});
...
Now add two more stores to your model. The first one is a local store which will help with CRUD to your local storage. The second is a remote store that we use only to get a complete list of data from the web server to populate our catalog.
//Contact.js
...
app.stores.localContacts = new Ext.data.Store({
id: 'localContacts',
model: 'app.models.Contact',
proxy: {
type: 'localstorage',
id: 'contacts'
}
});
app.stores.remoteContacts = new Ext.data.Store({
id: 'remoteContacts',
model: 'app.models.Contact',
proxy: {
type: 'ajax',
url: 'contacts.xml',
reader: {
type: 'xml',
root: 'contacts',
record: 'contact'
},
writer: {
type: 'xml',
record: 'contact'
}
}
});
...
Finally we need to add the CRUD and synchronizing code. The first function app.models.save is called from the controller when the save button is clicked on the ContactNew form. This takes all of the data from the form and persists it into local storage using the local store. The stores work really well with models so one trick that I use is to create a new model using the data from the form. Ext.ModelMgr.create(data, modelname) makes it easy to pass in a list of name-value pairs to create a new model instance. The store has a really nice method (add) for persisting existing models. Together you can pull all of your form data and persist it with only a couple of lines of code.
// Contact.js
...
app.models.save = function() {
var form = app.views.contactNew
var params = form.getValues();
var newcontact = Ext.ModelMgr.create(params, app.models.Contact);
var errors = newcontact.validate();
if (errors.isValid()) {
app.stores.localContacts.add(newcontact);
app.stores.localContacts.sync();
form.reset();
return true;
} else {
var errorMsg = '';
errors.each(function(e) {
errorMsg = errorMsg + fieldHumanize(e.field) + ' ' + e.message + "<br />";
errorMsg = errorMsg ;
});
Ext.Msg.show({
title: 'Error',
msg: errorMsg,
buttons: Ext.MessageBox.OK,
fn: function() {
return false;
}
});
}
Next we write the update function. This is called from the controller when the update button is clicked from the ContactEdit screen. All of the data is pulled from the form into a params variable. The code then loops through the params and updates everything on the model.
...
app.models.update = function(id) {
var contact = app.stores.localContacts.getById(id);
if(contact) {
var form = app.views.contactEdit
var params = form.getValues();
for(var field in params) {
console.log("field: " + field + ' | value: ' + params[field]);
contact.set(field, params[field]);
}
var errors = contact.validate();
if(errors.isValid()) {
contact.set('synced', false);
app.stores.localContacts.sync()
Ext.Msg.alert('Updated', 'The contact has been updated');
return true;
} else {
var errorMsg = '';
errors.each(function(e) {
errorMsg = errorMsg + fieldHumanize(e.field) + ' ' + e.message + "<br />";
errorMsg = errorMsg ;
});
Ext.Msg.alert('Error', errorMsg);
return false;
}
} else {
return false;
}
}
Next a short delete function to remove the record from local storage only.
app.models.destroy = function(id) {
var contact = app.stores.localContacts.getById(id);
if(contact) {
app.stores.localContacts.remove(contact);
app.stores.localContacts.sync();
return true;
} else {
return false;
}
}
Finally the sync function. After validating that the device is online the localstore is loaded with the latest data from localstorage. Each record in the local store is evaluated to find only those records that need to be synced (synced == false). Once we have the sync array and there are more than 0 records in the syncArray we loop through it. NOTE: I’m using a for loop but it is actually better to use an each loop when going through the array. To do that you would replace for(var i=0….) with syncArray.each(function(form) {… existing code …}. Then remove the line form = syncArray[i].
The pattern for syncing is as follows: use the data from the syncArray record to create a new model. Then use the proxy on the app.models.Contact model to save the record to the web server. This is why we have a proxy on the model, so that we can call syncModel.save and have the data synchronize with the server. There are three callbacks: success, failure, and callback. Success is called if the data successfully saves. Failure is called if something goes wrong and Callback is called regardless of what happens.
One of the tricky things with Javascript and ExtJS is managing the asynchronous actions. That is why the callbacks are used. If you just call syncModel.save() and then try to pop a success message, it won’t work the way you think it would. The save action will execute asynchronously and the code will move onto the next line while it the save is still executing. So your alert message would display even though the save hadn’t finished.
app.models.synchronizeLocalToRemote = function () {
if(!navigator.onLine) {
Ext.Msg.alert('Offline', 'You need to be online to sync to the web server');
return;
}
console.log('Start Sync');
var localStore = app.stores.localContacts.load();
var syncArray = getDataToSync(localStore);
var count = syncArray.length;
if(count == 0) {
Ext.Msg.show({
title: 'Synced',
msg: 'All contacts are synced'
});
return;
}
var syncInfo = "";
console.log("Number of items to sync: " + count);
// Show the syncing spinner
var mask = new Ext.LoadMask(Ext.getBody(), {msg: "Synchronizing"});
mask.show();
// Sync items to remote
for(var i = 0; i < count; i++) {
console.log("Index: " + i);
form = syncArray[i];
var syncModel = Ext.ModelMgr.create(form.data, app.models.Contact);
// Calling save on the model calls the remote proxy
syncModel.save({
success: function(result, request) {
var id = result.data['id']
console.log("Result ID: " + id);
console.log("Success");
form.set('remote_id', id);
form.set('synced', true);
localStore.sync();
syncInfo = 'Success: ' + form.get('first_name') + ' ' +
form.get('last_name') + ' has been synced<br />';
},
failure: function(result, request) {
console.log("Exception");
console.log("Result: " + request.responseText);
syncInfo = 'Failed: ' + form.get('first_name') + ' ' +
form.get('last_name') + ' has been synced<br />';
},
callback: function(result, request) {
console.log(syncInfo);
if(i >= count - 1) {
mask.hide();
Ext.Msg.show({
title: 'Complete',
msg: syncInfo
});
}
}
});
}
}
var getDataToSync = function(store) {
var syncArray = new Array();
store.each( function(form, index) {
var isSynced = form.get('synced');
if (!isSynced) {
syncArray.push(form);
}
});
return syncArray
}
Phew! Are you still with me? The code for the views is relatively straight-forward. I’ve posted the source code on GitHub so you can get walk through the views (public/javascripts/app/views) there. However I want to walk through a couple of examples of following a click from the view through the controller to another view.
Views and Controller: contacts.js
Here is a snippet from ContactsList.js. This is the button and handler for the “new” button on the top toolbar. When you click on the ‘new’ button on the toolbar the framework will take you to the controller.
//ContactsList.js
...
{
text: 'new',
ui: 'confirm',
handler: function() {
Ext.dispatch({
controller: app.controllers.contacts,
action: 'newContact',
animation: {type: 'slide', direction: 'left'}
});
}
}
...
The controller is in public/javascripts/app/controllers and is called contacts.js. From the newContact action in the controller we can see that the controller is setting the active panel on the viewport to the contactNew panel. One thing to note: it is setting the active panel to an instance of the new panel. Notice the lower-case “c” on contactNew. We are using the instances of the panels that were created in the Viewport code.
...
newContact: function(options) {
app.views.viewport.setActiveItem(app.views.contactNew, options.animation);
},
...
A more complex example of moving from panel to panel involves selecting an item from the list. From the ContactList panel you can click on an item to edit it. There are two click handlers: onItemDisclosure handles clicks directly on the arrow and onItemTap handles clicks on the row. They both do the exact same thing except that onItemDisclosure has access to the actual records whereas onItemTap has to translate the record from the item. The code below should look familiar as compared to the code above with one significant addition: We are sending an id to the controller. The id and the animation are both transmitted to the controller in an ‘options’ object.
//ContactsList.js
...
onItemTap: function(item) {
record = this.getRecord(item);
Ext.dispatch({
controller: app.controllers.contacts,
action: 'show',
id: record.getId(),
animation: {type: 'slide', direction: 'left'}
});
}
...
The controller uses the id from the options hash to retrieve the correct record for editing. If a contact is found in localstorage then the contactEdit view is updated with the data. The panel is updated by calling a method in the contactEdit view called updateWithRecord
// contacts.js
...
edit: function(options) {
var id = parseInt(options.id);
var contact = app.stores.localContacts.getById(id);
if(contact) {
app.views.contactEdit.updateWithRecord(contact);
app.views.viewport.setActiveItem(app.views.contactEdit, options.animation);
}
},
...
It is a little bit magical actually. As long as you are using a FormPanel it is really easy to update the fields with data from an existing record. By calling the ‘load’ method (this.load(record)) on the FormPanel and passing it the record each of the fields is mapped to the record object using the ‘name’ of the field. For example if the first_name of the record is populated it will automatically fill in the textfield where name: ‘first_name’. It will do this for all fields where the name equals the field from the record. It is good to keep this in mind when designing your forms: Make sure to name your fields that same as your model fields.
// ContactEdit.js
...
updateWithRecord: function(record) {
this.load(record);
var topToolbar = this.getDockedItems()[0];
topToolbar.getComponent('cancel').record = record;
var bottomToolbar = this.getDockedItems()[1];
bottomToolbar.getComponent('save').record = record;
bottomToolbar.getComponent('save').form = this;
}
Make sure to add references to the controller and model in your index.html.erb. Take care to add them in the following order. The Sencha framework will throw an error if the data for the ContactList is not available when you try to display the list. So it is important to load the model first, then the views.
<%= javascript_include_tag 'app/app' %>
<%= javascript_include_tag 'app/util' %>
<%= javascript_include_tag 'app/models/Contact' %>
<%= javascript_include_tag 'app/views/ContactCatalog' %>
<%= javascript_include_tag 'app/views/ContactEdit' %>
<%= javascript_include_tag 'app/views/ContactNew' %>
<%= javascript_include_tag 'app/views/ContactShow' %>
<%= javascript_include_tag 'app/views/ContactsList' %>
<%= javascript_include_tag 'app/views/Viewport' %>
<%= javascript_include_tag 'app/controllers/contacts' %>
That is pretty much it for our application. There are some fun things in the source code, like a catalog where you can view a list of items from the server on your mobile device. Then touch the item to download it to the device for manipulation. Please post any questions or comments and I’ll try to help out.
Thanks for reading!