sencha-logoIn our last installment, we tackled the “Big Form”, the grand inventory management form that we’ve been working toward through our whole journey thus far. But even though it worked well enough, it was still incomplete, and we promised ourselves that we’d add in a few more features to polish it off, namely:

  • Add Car image management
  • Add restriction for Make/Model selections
  • Add “detail” view to the car.

Since we always live up to our promises (:)), it’s time we knock this out!

 

NOTE: The code for this session can be found on GitHub.

A Few Additions

Before we jump in headfirst, however, there are a few more supporting pieces we need to add. I’ll outline them below, and you can add them as you have a chance (it’s good practice, right?).

Drive Train Options

I created a new “Option” called “Drive Trains.” This should be managed under Options, and can have the same data structure as the other options. Eat, sleep, repeat.

New Car Fields

I added a few more fields to the Car model to make it more realistic to something that might actually exist:

  • VIN (string)
  • StockNumber (string)
  • Mileage (number)
  • Fuel (string)
  • Engine (string)
  • Transmission (string)
  • Drive Train (option/combobox)

After you add these to your database and ExtJS model, also add fields for these into the “big form” under the Detail tab.

In the end, it might look something like this:

FullForm

Uploading Car Images

The biggest task on our list is managing image uploads. While we’re not going to get into how to manage this on the server side (that’s up to you…sorry!), let’s spend a few minutes looking at the strategy for how to support this in our Ext JS 4 app.

First, we’ll need a new table, model and store. Since we probably want to allow for more than a single image per car, we’ll create a new table called Image with the following fields:

  • ImageID
  • CarID (fk)
  • Path (varchar)
  • CreatedDate (timestamp)
  • Active

Basically, a one-to-many relationship between Car and Image.

Next, we can create a model called Image, which like all the other of our models, extends our Base model. I’ll let you knock this one out on your own.

And of course, we need a store. For our Images.js store, however, we’re going to deviate a bit. With all of our other stores, we’ve extended our Base.js store, which has the remote proxy. For this Image store, however, we’re actually not going to create a remotely-enabled store. We’ll discuss why in a bit, but for now let’s get it created:

/**
 * Store for managing car images
 */
Ext.define('CarTracker.store.Images', {
	extend: 'Ext.data.Store',
    alias: 'store.image',
    requires: [
        'CarTracker.model.Image'
    ],
    storeId: 'Images',
    model: 'CarTracker.model.Image'
});

It looks pretty similar to our other stores, but glaringly extends the base Ext.data.Store class, rather than our custom, proxy-configured Base class.

So here’s the reason why we’re doing this.

First, The only time that we care about the images is:

  • When we’re creating/editing a Car record
  • When we’re viewing the full details for a Car record (more on this later)

Outside of these scenarios, the images aren’t of any concern, so there’s no need to query them directly.

Second, when creating/editing a record, or when viewing the full details for a Car record, the only thing about the images that we *really* care about is the path so that we can display the image in the browser. Since we can easily include related image paths when we retrieve the full record, there’s no reason to create a remote store just to query data that we already have.

So why do we have a store at all? As we’ll see in a bit, the major reason is so that we can bind the store (of image paths) to an Ext.view.View component, and nicely display the images. Think of it like the magic of a grid, but for images.

Additionally, having the store will provide us with a really nice mechanism for adding/editing/removing images. Because we can track the image paths in a temporary store, all that we have to do when we’re ready to persist the full Car data record is to query the Image store, retrieve the existing paths, and pass them along as part of the data that is being saved.

Image Upload Approach

With this in mind, let’s talk about the approach we’re going to take to uploading. This can be outlined in a few steps:

  1. Open Car record for editing (or creation)
  2. Upload N images which will be associated with the Car
  3. Each upload should return the full image path, which will then be added to the Images store
  4. Before the Car record is persisted, we’ll query the Images store to retrieve all the configured image paths, and add them to the persisted data

Adding to the Form

So let’s see this in action. Since our form is already sub-divided into tabs for each main section, it seems to make sense to create a new tab for image management in our form. To do this, let’s create a new file called Image.js in the car.edit.tab package:

/**
 * Main panel for displaying images for {@link CarTracker.model.Car} records
 */
Ext.define('CarTracker.view.car.edit.tab.Image', {
    extend: 'Ext.panel.Panel',
    alias: 'widget.car.edit.tab.image',
    bodyPadding: 10,
    margin:-5,
    initComponent: function() {
        var me = this;
        Ext.applyIf(me, {
            items: [
                {
                    xtype: 'dataview',
                    itemId: 'images',
                    tpl: [
                        '<tpl for=".">',
                            '<div class="thumb-wrap">',
                                '<div class="thumb"><img src="{Path}"></div>',
                            '</div>',
                        '</tpl>',
                        '<div class="x-clear"></div>'
                    ],
                    overItemCls: 'x-item-over',
                    itemSelector: 'div.thumb-wrap',
                    emptyText: 'No images to display',
                    store: Ext.create('Ext.data.Store', {
                        fields: [ 'Path' ]
                    })
                }
            ],
            dockedItems: [
                {
                    xtype: 'toolbar',
                    dock: 'top',
                    ui: 'footer',
                    items: [
                        {
                            xtype: 'form',
                            itemId: 'uploadform',
                            border: false,
                            frame: false,
                            bodyPadding:0,
                            margins: '0 0 -5 0',
                            baseCls: 'x-plain',
                            items: [
                                {
                                    xtype: 'filefield',
                                    name: 'ImagePath',
                                    fieldLabel: 'Upload Photo',
                                    allowBlank: false, 
                                    fieldWidth: 300,
                                    buttonConfig: {
                                        iconCls: 'icon_picture',
                                        text: ''
                                    },
                                    labelAlign: 'left'
                                }
                            ]
                        },
                        {
                            xtype: 'button',
                            itemId: 'upload',
                            text: 'Upload'
                        }
                    ]
                }
            ]
        });
        me.callParent( arguments );
    }
})

A few things to note here. First, we’ve added an Ext.view.View to our tab. As mentioned above, this is what we’ll bind our Images store to. In this View, we’ve also specified a tpl (template) which describes how the data in our store should be rendered. We’ll talk about this more later.

Second, we’ve added an Ext.form.Panel (xtype:form) to our main component’s toolbar. That’s right…toolbars can contain not only “simple” components like buttons and fields, but can also contain other panels like a form.

So why the form? Well, the reasons are simple.

First, Ext JS does not perform uploads via XMLHttpRequests. In fact, it utilizes a good-ol form element which is submitted to a hidden, temporary iFrame that handles the entirety of the request. Of course, all of this happens behind the scenes, so for all intents and purposes, it appears to act as every other XMLHttpRequest that happens. Now you could do this all yourself if you really wanted to. However, if you have a form panel that can be submitted, Ext JS will handle ALL of it for you automatically.

Secondly, the reason we create a form-within-a-form is because we want to be able to handle multiple file uploads (that is, many file uploads, not multiple, simultaneous). If we were to submit our root form, we could only do one at a time, and we’d be submitting the entire form (along with our file field). Since we don’t want this to happen, we simply wrap the Ext.form.field.File (xtype:filefield) in a form panel which we can use as many times as we need to upload as many images as we like.

The Controller

Now to break some rules :). Throughout these sessions, our general rule of thumb has been to create a new controller for each major section of the app. While it could be argued that this image uploading functionality fits the bill, we’re going to simply enhance our existing Cars.js controller. Since the image uploading is so tightly bound to the Car record, it makes sense to keep it all in the family, so to speak.

Loading Images

The first thing we want to is code how we’ll load image data into our Images store (which is bound to our images tab’s dataview):

/**
 * Displays common editing form for add/edit operations
 * @param {Ext.data.Model} record
 */
showEditWindow: function( record ) {
    var me = this,
        win = me.getCarEditWindow(),
        isNew = record.phantom,
        data = [];
    // if window exists, show it; otherwise, create new instance
    if( !win ) {
        win = Ext.widget( 'car.edit.window', {
            title: isNew ? 'Add Car' : 'Edit Car'
        });
    }
    // show window
    win.show();
    // load form with data
    win.down( 'form' ).loadRecord( record );
    // prepare data for store
    data = me.prepareImageData( record.get( 'ImagePaths' ) );
    // load image view with data
    win.down( '#images' ).getStore().loadData( data );
}

...

/**
 * @private
 * Prepares raw image path data for store
 * @param {Array} paths
 */
prepareImageData: function( paths ) {
    var data = [];
    Ext.Array.each( paths, function( item, index, allItems ) {
        if( !Ext.isEmpty( item ) ) {
            data.push({
                'Path':item
            });    
        }
    });
    return data;
}

As you can see, we’ve enhanced our already-existing showEditWindow() method to include a script to load our view’s store. We do this by simply iterating over the ImagePaths already defined on the target data record, and then use that data to populate the store.

For a Car record with existing ImagePaths, this might look like:

ImageView

We can add some CSS to make the thumbnails look a bit better, but outside of this, the Ext.view.View takes care of the rest. Awesome.

Uploading Images

Enough beating around the bush. Let’s upload some images:

/**
 * Manages uploading images
 * @param {Ext.button.Button} button
 * @param {Ext.EventObject} e
 * @param {Object} eOpts
 */
upload: function( button, e, eOpts ) {
    var me = this,
        form = button.up( 'form' ),
        image = form.down( '[name=ImagePath]' ),
        record = form.getRecord(),
        view = me.getCarImageView().down( 'dataview' ),
        uploadform = form.down( '#uploadform' );
    // validate upload
    if( Ext.isEmpty( image.getValue() ) ) {
        Ext.Msg.alert( 'Attention', 'Please choose an image' );
        return false;
    }
    // submit form
    uploadform.submit({
        url: '/api/images',
        params: {
            Car: record.get( 'CarID' ) ? record.get( 'CarID' ) : 'new'
        },
        waitMsg: 'Uploading Image...',
        success: function( form, action ) {
            // add new record to store
            view.getStore().add({
                Path: action.result.data
            });
        },
        failure: function( form, action ) {
            Ext.Msg.alert( 'Uh-oh!', 'Sorry, there was an error uploading your file. Please make sure it is an image (.jpg, .gif, .png)' );
        } 
    })
}

This is pretty straightforward. When we click the Upload button, we simply find the form (remember, the one we added in the toolbar?), and submit it to the desired URL, additionally sending along the ID of the Car to which we want to associate this image. As with our stores’ proxies, and other AJAX requests, the form submit() provides support for success and failure, so we can handle each within our app as we need to. When the submit returns successfully, we simply pluck the file path returned from the server and add it to our images store.

Removing Images

By now, hopefully you’re already ahead of the game on how we can remove images from our Car record. As with Ext.grid.Panel, Ext.view.View supports “item” events, such as click, dblclick, contextmenu, etc. Because of this, we can attach a contextmenu event to the view, and handle the removal of images just like we would with grid rows. Go ahead and give it a shot and see if you can get it to work!

Shifting Gears: Dependent ComboBoxes

Now that we’ve put a bow on uploading and managing Car images, we can turn our attention the next item on our list: the relationship between the Make and Model ComboBoxes on our form’s Detail tab. As our form currently stands, you can select any combination of Make and Model that you’d like. Obviously this is no good, and we’d like to enforce the relationships we already forged between the two.

There are a number of ways to do dependent ComboBoxes in Ext JS. The one we’re going to look at is nice in that it is completely controller-driven, so there’s no artificial configuration that we need to add to either ComboBox in order to make this work. So let’s do it.

In the Cars.js controller, let’s first add two new component event listeners:

'form[xtype=car.edit.form] combobox[name=Make]': {
    change: this.filterModel
},
'form[xtype=car.edit.form] combobox[name=Model]': {
    beforequery: this.checkMake
}

The first is a change event for the Make ComboBox. If any change occurs in this field, we want to call the filterModel() method…which hopefully gives some indication of the way this is going :)

Next, we add a beforequery event listener to the Model ComboBox. Whenever a remote query is made for this field, we want to call the checkMake() method. Ostensibly, this will prevent us from even seeing Model results before a Make option is selected.

On to the code!

First, filterModel():

/**
 * Filters Model combobox based on selection in Make combobox
 * @param {Ext.form.field.ComboBox} combobox
 * @param {Object} newValue
 * @param {Object} oldValue
 * @param {Object} eOpts
 */
filterModel: function( combobox, newValue, oldValue, eOpts ) {
    var me = this,
        model = combobox.up( 'form' ).down( '[name=Model]' ),
        store = model.getStore(),
        filters = [
            {
                property: 'Make',
                value: newValue
            }
        ];
    // clear filter
    store.clearFilter( true );
    // filter model
    store.filter( filters );
}

Very similar to what we’ve done before. Basically, we create a custom filter which will query the server for new Model records that have the Make relationship defined. That’s it.

Next, checkMake():

/**
 * Interrupts Model combobox query to check if Make is defined
 * @param {Object} queryPlan
 * @param {Object} eOpts
 */
checkMake: function( queryPlan, eOpts ) {
    var me = this,
        make = queryPlan.combo.up( 'form' ).down( '[name=Make]' );
    // don't allow selection until make is selected
    if( Ext.isEmpty( make.getValue() ) ) {
        Ext.Msg.alert( 'Please select a Make before choosing a Model' );
        // cancel query
        queryPlan.cancel = true;
    }
}

In this method, we check to make sure that the Make ComboBox has a selected value. If it doesn’t, we set queryPlan.cancel to true, which will cancel the remote request for Model before it is sent.

Task #2, done!

The Last Task: Car Detail View

The last item on our task list was to create a “detail” view of our entire Car data record. What we’re after is something like this:

Detail

As you can see, this fancy little view displays a nice, non-form-ish view of our data…presumably a view that a business user might like to see when wanting to see a high-level view of a particular vehicle. Since this is so easy to do, we’re happy to oblige!

In order to pull this off, we’ll obviously need a new view. Let’s create one called Detail.js in the view/car package, on the same level as the List.js view.

Formatting the Content

Now for the $164,000 question: how do we format the data to look like this? There are actually a couple ways.

First, you could certainly kick of an AJAX request, format the content to your heart’s content server-side, and then return the string data to be plopped inside the view. There is absolutely nothing wrong with this, and depending on the complexity and dynamic nature of your content, it might actually be the more preferable.

The next option (which we’ll use in this scenario) is to use Ext.XTemplate to generate a format that will render with the data from our selected Car record. I won’t post the whole class, but here’s our Detail view:

Ext.define('CarTracker.view.car.Detail', {
    extend: 'Ext.window.Window',
    alias: 'widget.car.detail',
    ...
    initComponent: function() {
        var me = this;
        Ext.applyIf(me, {
            tpl: Ext.create('Ext.XTemplate', 
                '<table cellpadding="0" cellspacing="0" border="0" class="cardetails">',
                    '<tr>',
                        '<td>',
                            '<tpl for="ImagePaths">',
                                '<div class="thumbnail">',
                                    '<img src="{.}" height="60" data-qtip="<img src={.} />" />',
                                '</div>',
                            '</tpl>',
                        '</td>',
                        '<td>',
                            '<h1>{Year} {_Make} {_Model}</h1>',
                            '<table cellpadding="0" cellspacing="0" border="0" class="carnotes">',
                                '<tr>',
                                    '<td class="label">Category:</td>',
                                    '<td>{_Category}</td>',
                                    '<td class="label">Color:</td>',
                                    '<td>{_Color}</td>',
                                '</tr>',
                                '<tr>',
                                    '<td colspan="4">{Description}</td>',
                                '</tr>',
                                '<tr>',
                                    '<td colspan="4" class="label">Features:</td>',
                                '</tr>',
                                '<tr>',
                                    '<td colspan="2">',
                                        '<table cellspacing="0" cellpadding="0" border="0" width="100%">',
                                            '<tpl for="_Features">',
                                                '<tpl if="xindex==1 || xindex%2==1">',
                                                    '<tr>',
                                                '</tpl>',
                                                '<td><li>{.}</li></td>',
                                                '<tpl if="xindex%2==0">',
                                                    '</tr>',
                                                '</tpl>',
                                                '<tpl if="xindex%2==1 && xindex==xcount">',
                                                    '<td>&nbsp;</td>',
                                                    '</tr>',
                                                '</tpl>',
                                            '</tpl>',
                                        '</table>',
                                    '</td>',
                                    '<td colspan="2">&nbsp;</td>',
                                '</tr>',
                                '<tr>',
                                    '<td class="label">Sold?:</td>',
                                    '<td>{IsSold:yesNo}</td>',
                                    '<td class="label">Sale Price:</td>',
                                    '<td>{SalePrice:usMoney}</td>',
                                '</tr>',
                                '<tr>',
                                    '<td class="label">Sales Team:</td>',
                                    '<td colspan="3">',
                                        '<ul style="margin:0px 0px 0px 10px;padding:0px;">',
                                            '<tpl for="_SalesPeople">',
                                                '<li>{.}</li>',
                                            '</tpl>',
                                        '</ul>',
                                    '</td>',
                                '</tr>',
                            '</table>',
                        '</td>',
                    '</tr>',
                '</table>'
            )
        });
        me.callParent( arguments );
    }
});

The only thing worth paying attention to here is the bit within the Ext.XTemplate(…). In our template, we can define arbitrary strings that can be mixed with the data from our Car record to produce the view we saw above.

A few things worth noting:

  • The data we are applying to this template is data from our Car record (window.update( record.data )), but could be any set of key-value pairs that we might like to throw at it
  • To process values in the data set, you can use the {KeyName} syntax
  • You can do loops in templates! Notice the <tpl for=”ImagePaths”> part. Here, my data has an array of values called ImagePaths, and using the special looping syntax, I can access each item of the array iteratively
  • You can do conditional operations with if…else syntax. In this example, I’m doing the classic “mod” process for dynamic, multi-column tables from arbitrary data
  • You can easily apply any of the filter functions from Ext.util.Filter to a value, simply by using the {KeyName:filterName} syntax. And of course, in the case of the yesNo function above, you can even add your own to Ext.utli.Format if you need to

Helpful Hint: If you do decided to go the route of large XTemplate development, you might find it beneficial to create the HTML in another program altogether. I often test my templates as plain HTML (or even as ColdFusion templates with looping, conditionals, etc) first, and then port them to the string-builder syntax of the XTemplate. If you have to build a large template, AND you are not used to the syntax, it can be frustrating fighting with all the single-quotation marks and commas…

Boom! That’s it! Now all we have to do is wire this up to our grid’s contextmenu in the Cars.js controller and load the data:

// called from contextmenu click...
/**
 * Displays details window for selected car
 * @param {Ext.data.Model} record
 */
showDetailWindow: function( record ) {
    var me = this,
        win = me.getCarDetailWindow();
    // if window exists, show it; otherwise, create new instance
    if( !win ) {
        win = Ext.widget( 'car.detail', {
            title: 'Car Details'
        });
    }
    // show window
    win.show();
    // update data
    win.update( record.data );
}

Task #3 is history.

Wrapping Up

Awesomeness. Not only is our big form even bigger, but we’ve crossed off some much-needed tweaks and polish to our form. Our app is really starting to feel like something real now.

So what’s next? Well, our business users are anxious to get busy entering data, but are also chomping at the bit for some kind of reporting to add to their TPL summaries (whatever those are?). Therefore, let’s talk about some charting magic next. Sound good?