ExtJS ComboBoxes – Part 2

ComboBoxes are fully integrated with the ExtJS data package, allowing them to be bound to a data store containing the values to show in the drop-down list. Whilst this brings a number of benefits it is also one of the biggest sources of configuration mistakes. In the second article in this series I'll attempt to unravel the complexities of configuring a combobox with a store.

Explicitly Creating The Store

In the previous article we saw examples of how to use some of the most common combobox config options but there was one setting that featured in every example without any explanation. That setting was store and it contained the array of values for the drop-down list:

        Ext.create('Ext.form.field.ComboBox', {
            store: ['Red', 'Yellow', 'Green', 'Brown', 'Blue', 'Pink', 'Black']
        });
    

However, if the list of values is not known at development time it can prove inconvenient to specify them as inline data in this way. For a large enough list of values it would be completely impractical to load them all into the browser, some form of server-side filtering or paging would need to be employed instead.

Providing inline data implicitly creates a full-blown ExtJS store behind the scenes. There are three main alternatives that allow full control over how the store is configured.

The first way is to specify the store as an inline configuration:

        Ext.create('Ext.form.field.ComboBox', {
            store: {
                fields: ['text'],
                data: [
                    {text: 'Red'},
                    {text: 'Yellow'},
                    {text: 'Green'},
                    ...
                ]
            }
        });
    

The second way is to pass in the store itself:

        var store = Ext.create('Ext.data.Store', {
            fields: ['text'],
            data: [
                {text: 'Red'},
                {text: 'Yellow'},
                {text: 'Green'},
                ...
            ]
        });

        Ext.create('Ext.form.field.ComboBox', {
            store: store
        });
    

The third way is to pass in the id of a store:

        Ext.create('Ext.data.Store', {
            fields: ['text'],
            storeId: 'my-store',
            data: [
                {text: 'Red'},
                {text: 'Yellow'},
                {text: 'Green'},
                ...
            ]
        });

        Ext.create('Ext.form.field.ComboBox', {
            store: 'my-store'
        });
    

In all three of these examples the data is still inline but it is now part of the configuration for the store rather than the combobox. It is important to realize that from the combobox's perspective the data could be coming from anywhere, all it sees is a store. The format of the data has changed slightly to conform to the format expected by the store. Each store has a single field specified called text, which is the default field name used by a combobox.

If you actually try any of these 3 examples you'll find that they don't behave the same way as the original example. The new behaviour is odd and can be difficult to fathom. We'll see a demo later but to understand it properly we'll first need to delve into how comboboxes handle remote data.

Remote Data

One of the main reasons for specifying a store rather than using inline data is that it makes it much easier to load data from a server. Let's start with a simple example. It introduces a few new configuration options that will need explaining.

        Ext.create('Ext.form.field.ComboBox', {
            queryMode: 'local',
            store: {
                autoLoad: true,
                fields: ['text'],
                proxy: {
                    type: 'ajax',
                    url: 'snooker-balls.json'
                }
            }
        });
    

The store is configured with autoLoad: true, so it will attempt to load the data as soon as it is created. The configured proxy will load the data using an AJAX request to contact the server at the given URL.

The JSON response from the server looks like this:

        [
            {"text": "Red"},
            {"text": "Yellow"},
            {"text": "Green"},
            {"text": "Brown"},
            {"text": "Blue"},
            {"text": "Pink"},
            {"text": "Black"}
        ]
    

You can try this example below. The data will start loading as soon as the store is created and should have finished loading long before you get to this demo. It should appear to behave exactly the same as if the data had been provided inline.

So far I haven't made any mention of the mysterious setting queryMode: 'local'. What does it do and why does the example above have it set to 'local' when the data is quite clearly being loaded remotely?

If you cast your mind back to the previous article you may recall that we encountered the setting triggerAction: 'query'. As we saw in that article, in the context of a combobox the word query is used to refer to the filtering of the list of values in the drop-down.

Applying this same translation to queryMode it suddenly makes a lot more sense. Even though the list of values is being loaded remotely, the filtering is still being performed locally. As the user types, the drop-down list is being filtered without making any further queries to the server.

Remote Filtering

Loading the list of values from a remote server is a good start but it doesn't always go far enough. What about if we have a large data set and can't realistically perform the filtering in the browser?

The first change we need to make is to switch to queryMode: 'remote'. As that's the default we can just remove the queryMode setting altogether. Likewise, we need to remove the setting autoLoad: true so that the store doesn't attempt to load the list of values immediately.

        Ext.create('Ext.form.field.ComboBox', {
            store: {
                fields: ['text'],
                proxy: {
                    type: 'ajax',
                    url: 'snooker-balls.json'
                }
            }
        });
    

However, this is only half the story. The server must now be modified to perform the filtering. Using the configuration above, if we type blac into the combobox it will send a request to the server that looks a little like this:

        snooker-balls.json?query=blac&...
    

For this example to work correctly the server must understand how to return filtered data based on the query. The name of the request parameter query can be modified using the config setting queryParam but for this example the default name will be fine.

Let's see it in action. The data set is the same as in all the previous examples.

You could be forgiven for being a bit underwhelmed. There are a couple of problems with this example:

  • Nothing happens until you type the fourth character.
  • Clicking the trigger still shows the full list. In our hypothetical scenario there would be too much data to make this practical.

Let's address these in order. Firstly, when using remote filtering the combobox switches its minChars setting from 0 to 4. This prevents a request from being sent to the server unless the query contains a minimum of 4 characters. In many cases this makes sense as you wouldn't want to query the server until enough characters have been typed to perform decent filtering. However, for our small data set this isn't necessary so let's switch it down to 1.

        Ext.create('Ext.form.field.ComboBox', {
            minChars: 1,
            store: {
                fields: ['text'],
                proxy: {
                    type: 'ajax',
                    url: 'snooker-balls.json'
                }
            }
        });
    

Stopping the trigger from loading the full list also proves to be quite simple. As we saw in the previous article, setting triggerAction: 'query' will force the trigger to perform a filtered query based on the current value. This also respects the minChars setting, so clicking the trigger or pressing the Down key on an empty combobox won't have any effect.

        Ext.create('Ext.form.field.ComboBox', {
            minChars: 1,
            triggerAction: 'query',
            store: {
                fields: ['text'],
                proxy: {
                    type: 'ajax',
                    url: 'snooker-balls.json'
                }
            }
        });
    

Things are now looking pretty good. Let's polish off this example by adding in a few of the configuration options we saw in the previous article: emptyText, hideTrigger and typeAhead.

        Ext.create('Ext.form.field.ComboBox', {
            emptyText: 'Colour',
            hideTrigger: true,
            minChars: 1,
            triggerAction: 'query',
            typeAhead: true,
            store: {
                fields: ['text'],
                proxy: {
                    type: 'ajax',
                    url: 'snooker-balls.json'
                }
            }
        });
    

Paging

Even with remote filtering, a data set could still be too large to show all of the matching values at once. Sometimes it's sufficient just to show the first few values and leave it at that but in other circumstances it's better to offer paging in the drop-down list.

Most of the required settings go on the store and are just standard ExtJS paging configuration. The combobox has been configured with width: 260 to make room for the paging toolbar. There are two pageSize settings, one on the store and one on the combobox. Both are needed but the one on the combobox does little more than tell the combobox to show a paging toolbar. Setting it to true works just as well as setting the actual page size.

        Ext.create('Ext.form.field.ComboBox', {
            minChars: 0,
            pageSize: true, // This just causes a paging toolbar to show
            triggerAction: 'query',
            width: 260,
            store: {
                fields: ['text'],
                pageSize: 5,
                proxy: {
                    type: 'ajax',
                    url: 'css-colors.json',
                    reader: {
                        root: 'data', // Must match the property in the JSON response
                        type: 'json'
                    }
                }
            }
        })
    

A typical request will look like this:

        css-colors.json?query=b&page=1&start=0&limit=5&...
    
  • limit is the page size.
  • start is the index of the first result on the desired page, starting at 0.
  • page is the page number, starting at 1.

There is some redundancy in the request but all 3 parameters are passed by default.

Responsibility for enforcing the paging lies solely with the server. If the server were to return more than the requested 5 results they would all be added to the store and shown in the drop-down.

The JSON response should look something like this:

        {
            "total": 8,
            "data": [
                {"text": "Beige"},
                {"text": "Bisque"},
                {"text": "Black"},
                {"text": "BlanchedAlmond"},
                {"text": "Blue"}
            ]
        }
    

The value total tells the store that there are 8 results matching the query even though only 5 have been returned. For a page size of 5 this translates to 2 pages.

The end result looks like this:

Explicitly Creating The Store – Revisited

At the start of this article we saw how to move from using inline combobox data to a store. Let's take another look at one of the examples we saw earlier, this time including a demo.

        Ext.create('Ext.form.field.ComboBox', {
            store: {
                fields: ['text'],
                data: [
                    {text: 'Red'},
                    {text: 'Yellow'},
                    {text: 'Green'},
                    ...
                ]
            }
        });
    

Clearly this isn't behaving correctly. The drop-down isn't showing up until the fourth character is typed and even when it does appear it isn't being filtered.

The fourth-character problem is one we've seen before. It happens when a combobox is using queryMode: 'remote'. That would also explain why filtering is broken. In simple terms, it doesn't make sense to use remote filtering for local data. The next paragraph attempts a more detailed explanation but it gets a little involved and requires a good prior understanding of the ExtJS data package.

Setting a combobox to queryMode: 'remote' prevents it from trying to filter the data itself. Instead it just tells the store to reload, passing it the relevant filtering options. The store doesn't load the data directly, instead it passes the options on to its proxy. For a store that uses inline data the proxy is created implicitly and is a memory proxy, wrapped around the data. A memory proxy is quite crude and always returns the full data set. It doesn't even understand paging, let alone filtering. So as the user types, the combobox will keep asking the store to reload but it doesn't make any difference as the proxy will always return all the data.

The bottom-line is that we need to switch our example to use queryMode: 'local' instead.

        Ext.create('Ext.form.field.ComboBox', {
            queryMode: 'local',
            store: {
                fields: ['text'],
                data: [
                    {text: 'Red'},
                    {text: 'Yellow'},
                    {text: 'Green'},
                    ...
                ]
            }
        });
    

But that raises another question. Why don't we need to do this when we're using inline combobox data? The answer to that question proves to be pretty mundane: when you specify inline data the combobox automatically switches to queryMode: 'local'.

Sharing Stores Between ComboBoxes

Often a UI will contain multiple comboboxes that use the same data set. It can be tempting to try to share a store between the comboboxes but that can cause problems. The following example shares a single store containing local data. Initially it may appear to work fine but if you play with it a bit you'll find that the filtering of the drop-downs leaks between the two comboboxes.

The key thing to realize is that the filtering is applied in the store and not in the combobox, so sharing a store also shares the filtering. Sharing a store will only work correctly if the list is never filtered, for example when the comboboxes are set to editable: false.