Using the Ext JS 4 MVC architecture and a few gotchas

I recently worked on a POC to integrate Solr search with the Ext JS 4 infinite scrolling grid. This allows you to scroll through 234k+ records without having the user page through the data; the scrolling does the data buffering automatically.  Other features include hit highlighting, wild card searching, resizing windows and word-wrapped columns.  However, the most interesting part to me was using the new MVC approach that Sencha introduced in this release to organize your project much like you would a Grails or Java Web project.  I’ll detail the approach I took to make that happen and point out some gotchas along the way.

First, let’s start from the model.  In the code below you can see I’ve defined a model and proxy which will be the piece that will pull the data.  There’s nothing too special here with the exception of the namespace I used to define the model, ESearch.model.EPart.  These are crucial that they are spelled correctly because they will be used later in other parts of the MVC.

Ext.define('ESearch.model.EPart', {
    extend: 'Ext.data.Model',
    idProperty: 'id',
    fields: [
        {name:'id', type:'int'}, 'description', 'item_number', 'part_number'
    ],
    proxy: {
        // load using script tags for cross domain, if the data in on the same domain as
        // this page, an HttpProxy would be better
        type: 'jsonp',
        url: 'http://solrdev1/solr-eti/select/',
        callbackKey: 'json.wrf',
        limitParam: 'rows',
        extraParams: {
            q: '*',
            wt:'json',
            hl:'on',
            'hl.fl': 'description',
            'json.nl':'arrarr'
        },
        reader: {
            root: 'response.docs',
            totalProperty: 'response.numFound'
        },
        // sends single sort as multi parameter
        simpleSortMode: true
    }
});

The next thing to consider is the store.  Arguably, this is not part of MVC per se, but it is used in conjunction with the model to define what type of store we want to use.  In this case, we want to use a buffered store of with a page size of 500.  You’ll also notice that in this code I create some listeners for beforeload and load so that I can allow sorting with Solr and to be able to do hit highlighting and query times.  You’ll also notice that I link the model to the store by using the namespace for the model parameter.  Let’s take a look:

// default to wildcard search
var query = '*';

Ext.define('ESearch.store.EParts', {
    extend: 'Ext.data.Store',
    model: 'ESearch.model.EPart',
    pageSize: 200,
    remoteSort: true,
    autoLoad:false,
    // allow the grid to interact with the paging scroller by buffering
    buffered: true,
    listeners: {
        beforeload:{ fn: function(store, options) {
            if (options && options.sorters) {
            var sorters = options.sorters;
            for (var i=0; i 1) {
                var queryParsed = query.replace(/\*/g,'').replace(/"/g, '').trim();
                var queries = queryParsed.split(' ');

                for (var i=0; i < queries.length; i++) {
                    if (queries[i]) {
                        var q = escapeRegExChars(queries[i]);

                        // Check to highlight text only in grid-body
                        var node = Ext.get("grid-inf").dom.childNodes;
                        for (var j=0; j < node.length;j++ ) {
                            if (node[j].className.contains("x-grid-body",true)) {
                                node = node[j];
                                break;
                            }
                        }

                        highlightText(node,
                                q + "+", 'HL', true);
                    }
                }
            }
            // temporary fix to address issue with scrollbars not resizing           
            var grid = Ext.getCmp('grid-inf');
            grid.resetScrollers();
          }
        }
    }
});

Now that I have the data being consumed the way I want it the next step is to put it into an infinity scrolling grid.  Here’s the code to do that:

Ext.define('ESearch.view.parts.List', {
    extend: 'Ext.grid.Panel',
    alias: 'widget.partslist',
    store: 'EParts',
    initComponent: function() {

        var groupingFeature = Ext.create('Ext.grid.feature.Grouping', {
            groupHeaderTpl: 'Group: {name} ({rows.length})',
            startCollapsed: false
        });

        var selectFeature = Ext.create('qcom.grid.SelectFeature');

        var config = {
            name: 'qparts-grid',
            id: 'grid-inf',
            verticalScrollerType: 'paginggridscroller',
            loadMask: true,
            invalidateScrollerOnRefresh: false,
            disableSelection: false,
            features: [groupingFeature,selectFeature],
            viewConfig: {
                trackOver: false
            },
            // grid columns
            columns:[{xtype: 'rownumberer',width: 45, sortable: false},{
                id: 'id-col',
                header: "ID",
                dataIndex: 'id',
                width:60
            },{
                id:"descr",
                header: "Description",
                dataIndex: 'description',
                width: 300,
                renderer: columnWrap
            },{
                id:"itemnum",
                header: "Item Numbers",
                dataIndex: 'item_number',
                width: 100
            },{
                id: "partnum",
                header: "Part Numbers",
                dataIndex: 'part_number',
                flex: 1,
                renderer: columnWrap
            }]
            ,selModel:{
           selType:'rowmodel'
          ,allowDeselect:true
          ,mode:'MULTI'
         },
            tbar:
                ['Search:',{
                     xtype: 'textfield',
                     name: 'searchField',
                     hideLabel: true,
                     width: 250,
                     emptyText: "Enter search terms separated by space",
                     listeners: {
                         change: {
                            fn: function adjustQuery(field) {
                                // temporary fix to address issue with scrollbars not resizing
                                this.store.resetData();

                                // Regex query and add wildcards where appropriate
                                if (field.value.length >= 1) {
                                    var values = field.getValue().match(/[A-Za-z0-9_%\/\.\-\|]+|"[^"]+"/g),
                                        value =[];
                                    if (values && values.length > 1) {
                                        for ( var i=0; i < values.length; i++ ) {
                                            if (values[i].indexOf("\"") >= 0 ) {
                                                value.push(values[i].toLowerCase());
                                            }
                                            else {
                                                value.push("*" + values[i].toLowerCase() + "*");
                                            }
                                        }
                                        query = value.join(" ");
                                        if (Ext.isChrome) {
                                            console.log(query);
                                        }
                                    }
                                    else {
                                        if (field.getValue().indexOf("\"") >= 0 ) {
                                            value.push(field.getValue().toLowerCase());
                                            query = value.join(" ");
                                        }
                                        else {
                                            // temporary fix because regex not picking up 1 char
                                            var temp = values ? values[0] : field.getValue();
                                            query = "*" + temp.toLowerCase() + "*";
                                        }
                                        if (Ext.isChrome) {
                                            console.log(query);
                                        }
                                    }
                                    this.store.load({
                                        params: {q:query}
                                    });
                                }
                            },
                            scope: this,
                            buffer: 500
                         }
                     }
                },
                {
                     xtype: 'tbfill'
                },{
                     xtype: 'displayfield',
                     name: 'totalText',
                     id: 'totalText',
                     hideLabel: true,
                     baseCls: 'x-toolbar-text',
                     style: 'text-align:right;',
                     width:180
                }
            ]
        };
        // apply config object
     Ext.apply(this, config);

     // call parent initComponent
     this.callParent(arguments);
    }
});

So from the above code you see that I’m defining a Grid Panel and assigning an alias to it called “partslist” (more on that later), but one gotcha I found is that I could not use the full namespace for the store definition — I had to just simply call it “EParts”.  Finally, you’ll see me set-up the columns and create a top bar that will hold the search field.  I do a regex to process the search field to create a wildcard search and to preserve quotes.  I also set the buffer to 500 so they it will wait 500ms for keystrokes before firing the search again.

Now that we have the grid, we need to put it some where.  This is where I bring the window into the picture.  In the code below, I simply define my window size, where I want it in the browser, window capabilities like maximize, collapse and closable, and finally the items.  Notice for the items, I’m using the alias partslist from the previously defined grid as the xtype.  This allows me to insert a grid as I defined it before without having to instantiate it as a variable.  Let’s take a look:

Ext.define( 'ESearch.view.Portal', {
    extend: 'Ext.window.Window',
    alias: 'widget.portal',
        width: 800,
        height:600,
        x: 150,
        y: 80,
        layout:'fit',
        border: false,
        closable: true,
        maximizable: true,
        collapsible: true,
        title: 'EParts Search',
        items: [{
            xtype: 'partslist',
            itemId:'myPartList'
        }]
});

So to finish up the MVC portion, we need a controller.  In the code below you will see how we create the controller and then define the models, stores, views, and any references needed.  You’ll also see in the init function where I invoke the store for the initial load of data as well as an example of how we could listen for certain events and do something with that event.  Notice again, that the alias from the grid comes into play (partslist) so that we can capture button events from the grid.  This wasn’t completely implemented, but it gives an example how it might be implemented.

Ext.define('ESearch.controller.Search', {
    extend: 'Ext.app.Controller',
    models:[
        'EPart'
    ],
    stores:[
        'EParts'
    ],
    views:[
        'parts.List'
    ],
    refs:[{
         ref:'PartsList',
         selector:'partslist'
    }],
    init:function(app) {
            var store = this.getEPartsStore();
            store.guaranteeRange(0, 199);
            this.control({
                   'partslist button':{
                    click:this.onButtonClick
               }
          });
    },
    onButtonClick: function(btn, e) {
        if (btn.operation === 'newSearch') {
            //TODO need to find a nice way to instantiate a new window
        }
    }
});

The last little bit of code simply defines the application and sets some criteria as to what paths we should use and which pieces of Ext JS are required for this application to function.  One gotcha I noticed is that you must define the first part of your namespace as the the folder your app will fall under.  You’ll notice that I have mapped the path “app” to “ESearch” so thusly my directory structure for my application must follow something like this:

 

So for instance, ESearch.view.Portal, must live as a file called Portal.js under app/view and same for the other files you see there.  The App.js file that contains the following code will be under the “webapp” directory adjacent to “app” to maintain relative pathing.  All I do is create my viewport based on its namespace and fire .show() to kick the whole thing off.

Ext.Loader.setConfig({enabled: true,
        paths: {
            'Ext.ux':'lib/extjs4/ux/',
            'ESearch': 'app'
        }
});
Ext.require([
    'Ext.grid.*',
    'Ext.data.*',
    'Ext.util.*',
    'ESearch.view.Portal',
    'Ext.grid.PagingScroller',
    'Ext.ux.grid.FiltersFeature',
    'Ext.grid.feature.Grouping',
    'Ext.grid.plugin.CellEditing',
    'Ext.state.CookieProvider'
]);

Ext.application({
     name:'ESearch',
     appFolder:'app',
     autoCreateViewport:false,
     controllers:['Search'],
     launch:function() {
     Ext.state.Manager.setProvider(new Ext.state.CookieProvider());
     this.viewport = Ext.create('ESearch.view.Portal', {
              stateId:'esearchWindow'
     });
     window[this.name].app = this;

      this.viewport.show();

    }
});

And finally, we have an HTML file that points to all the necessary JS files for this application to work, which is pretty standard stuff to bootstrap the application. However, with this approach, I only had to define the App.js file and not all the underlying JS files in the MVC portion. This is because the pathing we used in the previous section.

I hope this is useful for folks that would like to explore MVC in Ext JS 4 a little more.  I really find it useful because it helps break up a larger component much along the lines I’m used to.  In this way you could have multiple DEVs work on the same project pretty easily without walking over each other.

Advertisements

4 comments on “Using the Ext JS 4 MVC architecture and a few gotchas

  1. Thanks for posting. Your image is missing. I am just learning MVC, but was wondering shouldn’t the toolbar functions such as ‘Search’ be in your controller rather than view (view.parts.List) ?

    • No problem…hmmm the image is showing up for me. To me, the search code is in the right spot because the field that I have in the toolbar is operating on a “change” event for and keystroke changes. If I was doing something that would change to another “view”, I would put it in the controller. Hope that makes sense.

  2. My app is fairly similar to yours. I have a grid with toolbar (in one view). The toolbar has two datefields for ‘start date’, ‘end date’ search criteria as well as a textfield as you have. When I do a store load, I want to pass the criteria to the server (date start, end and text field). Philosophically, I’m thinking the gathering and parsing of the parameters should be in the controller in some kind of ‘doStoreLoad’ event. A change to datefield or textfield would fire the event. I’m struggling now with how to access the values in the toolbar fields from within the controller…. but perhaps I’ll just create a function in the view to do the store load and move on.

    • Oops…that shouldn’t of been there. I added the PNG to wordpress so you should see it now.

      I’d recommend having the actions off a listener that exists on your toolbar. Get that working first and then try to see if you can pull some of that into the controller. I also have moved common functions into a separate JS file that I register, but in your case, I think this would be specific to that view.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s