Ext JS Crud

Sencha

Seems many spend hours searching how to implement Ext JS models using CRUD methods. I myself spent countless hours pouring over various methods others have used to implement such a powerful feature of Ext JS.

CRUD removes a lot of coding in sending Ajax requests to the server for submitting forms. The method Ext JS sends CRUD data is unique in terms of the form submit we’ve all become accustomed to. For me, it saves me hours in creating repetitive code the framework has the power to handle behind the scene, allowing me to be more productive.

The first step is to forget all the learning you’ve done in placing the proxy in the store. Reality is in most cases using CRUD and View Models will replace the need of any physical store definitions we all become far to accustomed to using.

So let’s start by taking the wet noodle and slapping our hands when we start thinking about writing the proxy in our store, rather than then the model, allowing  ourselves to implement the powerful world of an Ext JS CRUD application.

The example starts simple enough, the model.

Ext.define('NewGen.model.Places', {
    extend: 'Ext.data.Model',

    idProperty: 'placeId',

    fields: [
        { name: 'placeId', type: 'int' },
        { name: 'city', type: 'string' },
        { name: 'county', type: 'string' },
        { name: 'state', type: 'string' },
        { name: 'country', type: 'string' },
        { name: 'latitude', type: 'number' },
        { name: 'longitude', type: 'number' }
    ],
    proxy: {
        api: {
            create: '/resources/newgen.php?method=addPlace',
            read: '/resources/newgen.php?method=getPlaces',
            update: '/resources/newgen.php?method=savePlace',
            destroy: ''
        },
        reader: {
            type: 'json',
            rootProperty: 'places'
        },
        writer: {
            type: 'json',
            writeAllFields: true
        }
    }
});

In the above model it is clear to see how simple this model construct is. Define the model, extend from Ext.data.Model, or another model in the case you have a more complex data structure you are working with.

Some will feel it is repetitive and not needed to specify type for either the reader or writer as JSON is the default for both of those properties. I disagree as I have ran into instances the default leads to problems in some places whereby you thought a default was what you were expecting and discovered the default was different. Specifying these values makes your intent clear not only to yourself, but to other programmers making modifications to your previous work. Or, Sencha one day changes to a different default, breaking every line of code you have depending on the default JSON. Yes this is an extreme example but it does happen in real world situation.

The most important part about our model definition is:

api: {
            create: '/resources/newgen.php?method=addPlace',
            read: '/resources/newgen.php?method=getPlaces',
            update: '/resources/newgen.php?method=savePlace',
            destroy: '/resources/newgen.php?method=deletePlace'
        },

Here is the meat behind the CRUD we are implementing. We have defined calls for create, read, update and destroy. Ext JS will call the proper route based on the action you apply to your record changes. More on this later.

To remove a need for a matching store.js file, the proxy was defined in the model. Even with cases you need a store in place of the View Model store, your store definition will use the proxy defined in the model. This is contrary to where we all were at when first getting involved with Ext JS. I know my personal experience I first learned the proxy was a function of the store and including it in the model was unheard of.

Once getting used to adding a proxy to the model it actually makes more sense to put it there. This confines everything about the object in the model, the purpose of having a model.

Next we place this model in action.

Ext.define('NewGen.view.admin.places.PlacesAdminMainModel', {
    extend: 'Ext.app.ViewModel',
    alias: 'viewmodel.admin-places-placesadminmain',
    data: {
        name: 'NewGen'
    },

    stores: {
    	places: {
    		model: 'NewGen.model.Places',
    		callback: newgen.updateSession(),
    		autoLoad: true
    	},
    	placeHistory: {
            model: 'NewGen.model.PlacesHistory',
            autoLoad: false
        },
    }
});

Advantage of placing a store in the View Model is you can create base functionality to pull all information, or paging on the individual View Model store so the store becomes different views of the data based on the view you are presenting. We can even create bound variations of a model allowing the same store to be viewed differently in the same view.

In the  store definition I placed a callback so the user session is updated having made a call for data. This function is implemented using a global as a singleton to allow accessibility to call classes without needing to remember to place it in require: [].

The view information is shown for inclusion and tracing how this example was built to create the CRUD reviewed in this post.


Ext.define('NewGen.view.admin.places.PlacesAdminMain',{
    extend: 'Ext.panel.Panel',
    xtype: 'PlacesAdminMainPnl',
    bodyPadding: 10,
    title: 'Administration - Place\'s',
    width: Ext.getBody().getViewSize().width,

    requires: [
        'NewGen.view.admin.places.PlacesAdminMainController',
        'NewGen.view.admin.places.PlacesAdminMainModel'
    ],

    controller: 'admin-places-placesadminmain',
    viewModel: {
        type: 'admin-places-placesadminmain'
    },

    layout: {
        type: 'hbox'
    },

    items: [{
        xtype: 'grid',
        reference: 'placeGrid',
        title: 'Place\'s List',
        flex: 1,
        height: Ext.getBody().getViewSize().height - 143,
        bind: {
            store: '{places}'
        },
        listeners: {
            select: 'onPlacesSelect'
        },
        tbar: [{
            xtype: 'toolbar',
            width: '100%',
            items: [{
                xtype: 'fieldcontainer',
                layout: {
                    type: 'hbox'
                },
                items: [{
                    xtype: 'textfield',
                    fieldLabel: 'Search',
                    reference: 'tfSearch',
                    labelWidth: 60,
                    width: 300,
                    emptyText: 'City, County or State'
                }, {
                    xtype: 'button',
                    iconCls: 'fas fa-search',
                    tooltip: 'Search in City, County and State',
                    handler: 'updateSearch'
                }, {
                    xtype: 'button',
                    iconCls: 'fas fa-redo',
                    tooltip: 'Clear Search',
                    handler: 'clearSearch'
                }]
            }, {
                xtype: 'tbspacer',
                flex: 1
            }, {
                xtype: 'button',
                text: 'Add Place',
                iconCls: 'fas fa-plus',
                handler: 'openAddPlaceWdw'
            }]
        }],
        bbar: [{
            xtype: 'pagingtoolbar',
            displayInfo: true,
            bind: {
                store: '{places}'
            }
        }],
        style: {
                border: '1px solid black'
            },
        columns: [{
            text: 'Country',
            flex: 1,
            dataIndex: 'country'
        }, {
            text: 'State',
            flex: 1,
            dataIndex: 'state'
        }, {
            text: 'County',
            flex: 1,
            dataIndex: 'county'
        }, {
             text: 'City',
             flex: 1,
             dataIndex: 'city'
        }, {
            xtype: 'actioncolumn',
            width: 50,
            items: [{
                iconCls: 'x-fa fa-edit',
                handler: 'openEditPlaceWdw'
            }]
        }]
    }, {
        xtype: 'container',
        layout: {
            layout: 'vbox',
            pack: 'top',
        },
        flex: 1,
        items: [{
            xtype: 'panel',
            title: 'Map',
            flex: 1,
            height: (Ext.getBody().getViewSize().height - 155) / 2,
            margin: '0 0 10 10',
            style: {
                border: '1px solid black'
            }
        }, {
            xtype: 'grid',
            title: 'Historical Events',
            reference: 'placeHistoryGrid',
            margin: '0 0 0 10',
            plugins: {
                ptype: 'rowexpander',
                rowBodyTpl: [
                    'testing'
                ]
            },
            bind: {
                store: '{placeHistory}'
            },
            tbar: ['->', {
                xtype: 'button',
                text: 'Add Article',
                iconCls: 'fas fa-plus',
                tooltip: 'Add New Article',
                handler: 'openAddNewsWdw'
            }],
            bbar: [{
                xtype: 'pagingtoolbar',
                displayInfo: true,
                bind: {
                    store: '{placeHistory}'
                }
            }],
            flex: 1,
            height: (Ext.getBody().getViewSize().height - 150) / 2,
            style: {
                border: '1px solid black'
            },
            columns: [{ 
                xtype: 'datecolumn',
                text: 'Date',
                dataIndex: 'date',
                format: 'Y-m-d',
                width: 100
            }, {
                text: 'Publication',
                dataIndex: 'publication',
                flex: 1
            }, {
                text: 'Headline',
                dataIndex: 'headline',
                flex: 2,
                renderer: function (value) {
                    return decodeURI(value);
                }
            }, {
                xtype: 'actioncolumn',
                width: 90, 
                items: [{
                    iconCls: 'fas fa-eye',
                    tooltip: 'Read Article',
                    handler: ''
                }, {
                    xtype: 'container',
                    width: 5
                }, {
                    iconCls: 'fas fa-edit'
                }, {
                    xtype: 'container',
                    width: 5
                }, {
                    iconCls: 'fas fa-trash'
                }]
            }]
        }]
    }]
});

Two handlers, openEditPlaceWdw and openAddPlaceWdw are the focus of this post.

Ext.define('NewGen.view.admin.places.PlacesAdminMainController', {
    extend: 'Ext.app.ViewController',
    alias: 'controller.admin-places-placesadminmain',

    clearSearch: function (btn) {
        let store = this.lookupReference('placeGrid').getStore();
        store.getProxy().extraParams = {
            srchStr: ''
        }
        store.load(); 
        this.lookupReference('tfSearch').setValue('');
        this.lookupReference('tfSearch').focus(true);
    },

    onPlacesSelect: function (grid, rec, eOpts) {
        let store = this.lookupReference('placeHistoryGrid').getStore();
        store.getProxy().extraParams = {
            placeId: rec.get('placeId')
        }
        store.load();
    },

    openAddPlaceWdw: function (btn) {
    	let wdw = Ext.create('NewGen.view.admin.places.AddPlace');
    	wdw.query('#hfAddby')[0].setValue(localStorage.getItem('user'));
    	wdw.query('#tfCity')[0].focus(true);
    	wdw.setTitle('Add New Place');
    	wdw.query('#btnSavePlace')[0].setHidden(true);
    	wdw.show();
    },

    openEditPlaceWdw: function (grid, rowIdx, colIdx, obj) {
    	let rec = grid.getStore().getAt(rowIdx);
    	let wdw = Ext.create('NewGen.view.admin.places.AddPlace');
    	wdw.query('#hfAddby')[0].setValue(localStorage.getItem('user'));
    	wdw.setTitle('Edit: ' + rec.get('city') + ' ' + rec.get('state') + ' ' + rec.get('country'));
    	let frm = Ext.ComponentQuery.query('#frmPlace')[0];
    	wdw.query('#btnAddPlace')[0].setHidden(true);
    	frm.loadRecord(rec);
    	wdw.show();
    },

    updateSearch: function (btn) {
        let store = this.lookupReference('placeGrid').getStore();
        store.getProxy().extraParams = {
            srchStr: this.lookupReference('tfSearch').getValue()
        }
        store.load();
    },
});

When opening a window to add or edit a place you can see they are designed to use the same window with the action determining what button is visible. The controller for this window will handle the different actions of creating or saving a record. Ext JS will determine the route in the proxy based on information added to the model record.

The biggest change comes in how we save records. Both update and create routes use the same record to update. Ext JS decides if the record needs to be created or updated based on the model primary key.

This primary key is defined in the model using:

idProperty: 'placeId',

Ext JS default for idProperty is ‘id’. This is one area where the Ext JS default can cause issues. Example is if you have a duplicate id, Ext JS will remove duplicate primary keys causing instances when rendering the store of missing records. I have experienced this first hand on more than one occasion which is why I began specifying defaults on everything. The first time I encountered this I recall spending several hours tracking down why all the records in my store were NOT displaying in my grid.

Here we see concern about “best practices”. Here exists kind of a double standard. Some say it is best practice not to specify anything with a default that your code matches, to some it is considered redundant and not needed.  Experience taught me “best practice” is to specify everything. Developers of frameworks make changes, often those changes are “breaking” from existing practice. Specifying even defaults helps prevent future breaking should a breaking change be introduced. Development practice is to deprecate and then remove when introducing potential breaking changes, giving developers time to make corrections over time before things stop working.

Enough of the best practices opinion. Each developer will choose their preference and live with those choices.

Back to the idProperty. In CRUD this is more important even to specify the default than your previous use case. The idProperty alone is what Ext JS uses to determine if the record is an update or create route.

Below is the add new place window:

Ext.define(‘NewGen.view.admin.places.AddPlace’,{
extend: ‘Ext.window.Window’,
alias: ‘widget.AddPlaceWdw’,
width: 600,
height: 600,
modal: true,
title: ‘Add New Place’,


Ext.define('NewGen.view.admin.places.AddPlace',{
    extend: 'Ext.window.Window',
    alias: 'widget.AddPlaceWdw',
    width: 600,
    height: 600,
    modal: true,
    title: 'Add New Place',

    requires: [
        'NewGen.view.admin.places.AddPlaceController',
        'NewGen.view.admin.places.AddPlaceModel'
    ],

    controller: 'admin-places-addplace',
    viewModel: {
        type: 'admin-places-addplace'
    },

    layout: {
        type: 'vbox',
        align: 'stretch'
    },

    items: [{
        xtype: 'form',
        itemId: 'frmPlace',
        bodyPadding: 10,
        flex: 1,
        width: Ext.getBody().getViewSize().width,
        buttons: [{
            text: 'Add Place',
            iconCls: 'fas fa-plus',
            itemId: 'btnAddPlace',
            handler: 'addPlace'
        }, {
            text: 'Save Place',
            iconCls: 'fas fa-save',
            itemId: 'btnSavePlace',
            handler: 'savePlace'
        }, {
            text: 'Cancel',
            iconCls: 'fas fa-times'
        }],
        items: [{
            xtype: 'hiddenfield',
            name: 'placeId'
        }, {
            xtype: 'hiddenfield',
            name: 'addby',
            itemId: 'hfAddby'
        }, {
            xtype: 'fieldcontainer',
            layout: {
                type: 'hbox'
            },
            defaults: {
                labelWidth: 70,
                xtype: 'textfield',
                margin: '0 10 0 0'
            },
            items: [{
                fieldLabel: 'City',
                itemId: 'tfCity',
                name: 'city'
            }, {
                fieldLabel: 'County',
                name: 'county'
            }]
        }, {
            xtype: 'fieldcontainer',
            layout: {
                type: 'hbox'
            },
            defaults: {
                labelWidth: 70,
                xtype: 'textfield',
                margin: '0 10 0 0'
            },
            items: [{
                fieldLabel: 'State',
                name: 'state'
            }, {
                fieldLabel: 'Country',
                name: 'country'
            }]
        }, {
            xtype: 'fieldcontainer',
            layout: {
                type: 'hbox'
            },
            defaults: {
                labelWidth: 70,
                xtype: 'textfield',
                margin: '0 10 0 0'
            },
            items: [{
                fieldLabel: 'Latitude',
                name: 'latitude'
            }, {
                fieldLabel: 'Longitude',
                name: 'longitude'
            }]
        }]
    }, {
        xtype: 'tabpanel',
        flex: 2,
        items: [{
            title: 'Note\'s',
            xtype: 'grid',
            bbar: [{
                xtype: 'pagingtoolbar',
                // displayInfo: true
            }, {
                xtype: 'container',
                flex:1
            }, {
                xtype: 'button',
                text: 'Add Note',
                iconCls: 'fas fa-plus'
            }],
            columns: [{
                text: 'Date'
            }, {
                text: 'Note'
            }]
        }, {
            title: 'Map'
        }]
    }]
});

And the add place controller:

Ext.define('NewGen.view.admin.places.AddPlaceController', {
    extend: 'Ext.app.ViewController',
    alias: 'controller.admin-places-addplace',

    addPlace: function (btn) 
    {
    	let frm = btn.up('form');
    	let values = frm.getValues();
    	let rec = Ext.create('NewGen.model.Places', values);
    	rec.save({
    		success: function (record, val) {
    			newgen.showToast('Place Added', '<p>The place was successfully added.</p>');
    			btn.up('window').close();
    			let grid = btn.up('grid');
    			let store = grid.getStore();
    			store.load();
    			newgen.updateSession();
    		},
    		failure: function (err) {
    			newgen.ajaxFailure();
    			btn.up('window').close();
    		}
    	});
    },

    savePlace: function (btn) 
    {
    	let frm = btn.up('form');
    	let values = frm.getValues();
    	let rec = Ext.create('NewGen.model.Places', values);
    	rec.save({
    		success: function (record, val) {
    			newgen.showToast('Place Updated', '<p>The place was updated successfully.</p>');
    			btn.up('window').close();
    		},
    		failure: function (err) {
    			newgen.ajaxFailure();
    			btn.up('window').close();
    		}
    	});
    }
});

Currently the main difference is providing a different toast response between create and update. The second is more obvious. The create route will need to reload the store, the update route will not.

rec.save() serves two purposes, first it triggers the Ajax call to update the record on the server side, second it updates the view model store with the updated information. Since records are updated via save when changes occur there should be no reason to place an additional call to reload the store.

This controller should show some abbreviated coding in most cases. There isn’t a need to specify the url either on the form or the Ajax call. This is handled by Ext JS.

The route for create is called because in the add place there is no placeId defined. The missing placeId is telling Ext JS this is a new record and it should now send it through the create API definition. In the edit, placeId is defined and instructs Ext JS to send the record through the update route in the API definition.

Whether an update or create route defined in the API, information sent to the server changes. The record is no longer sent as post and not in either a query string or form submit. This type of process sends it as a JSON stream to the server-side script for processing.

Here is the Request payload (Firefox debugger) as this looks different in Chrome debugger.

{
	"placeId": 198,
	"addby": "1",
	"city": "Vancouver",
	"county": "",
	"state": "British Columbia",
	"country": "Canada",
	"latitude": 49.24966,
	"longitude": -123.11934
}
$data = json_decode(file_get_contents('php://input'), true)

$data is now an object we access using the keys that are sent in the JSON. So getting the placeId is as straight forward as $data[‘placeId’]. 

Since my security model only exposes one PHP file to the website my full function for saving and updating are as follows:  

case 'addPlace':
            echo PlacesClass::addNewPlace(json_decode(file_get_contents('php://input'), true));
break;

case 'savePlace':
            echo PlacesClass::savePlace(json_decode(file_get_contents('php://input'), true));
break;

Basically calling the respective PHP function to either add or update a record.

As processing is basically the same, below is the save function to complete updating an existing place record.

public static function savePlace ($data) 
        {
            $curDate = date('Y-m-d h:m:s');
            $conn = DBClass::getConnection();

            $stmt = $conn->prepare("UPDATE `places` SET `city` = :city, `county` = :county, `state` = :state, `country` = :country, `latitude` = :latitude, `longitude` = :longitude, `updateby` = :updateby, `updatedate` = :updatedate WHERE `placeId` = :placeId");
            $stmt->bindParam(':city', $data['city']);
            $stmt->bindParam(':county', $data['county']);
            $stmt->bindParam(':state', $data['state']);
            $stmt->bindParam(':country', $data['country']);
            $stmt->bindParam(':latitude', $data['latitude']);
            $stmt->bindParam(':longitude', $data['longitude']);
            $stmt->bindParam(':updateby', $data['addby']);
            $stmt->bindParam(':updatedate', $curDate);
            $stmt->bindParam(':placeId', $data['placeId']);
            $stmt->execute();

            if ($stmt->rowCount() == 1) {
                return '{ "result": true }';
            } else {
                return '{ "result": false }';
            }
        }

I also want to point out I do not include a “success”: true in my  return JSON anymore. Success and failure is a result of the Ajax request and not my processing. Success is returned to Ext JS to know whether the request was successful or if there was a failure. I am interested in the result of my successful Ajax call. My concern did the record update, ergo “result”. To Ext JS this call is successful regardless if results is true or false. Since Ext JS handles Ajax failures it will handle and return success to be used internally and catch server side errors.

Here is how C# would handle the payload sent as a JSON stream:

StreamReader rdr = new StreamReader(HttpContext.Current.Request.InputStream);
rdr.BaseStream.Position = 0;
string jsonPayload = rdr.ReadToEnd();
var emgContact = JsonConvert.DeserializeObject <Dictionary<string, string>> (jsonPayload);

And we then have a C# object we can access to process the add/update we are doing on the record.

To me whether PHP or C# the code is cleaner, the object is easier to handle than either Request.Form[], Request.QueryString[] or $_REQUEST[], $_POST[] all over the place to get a single variable we need to process our results.

I have seen increased front end speeds, server-side speeds and reduced coding across the boards when using the Ext JS CRUD method to develop my applications.

I can conclude that at my day job I brought up CRUD months ago and it was received by mixed results and not implemented. Today the push is there to switch to the CRUD method of handling our data. This was the case as is the case with many; until you actually implement it you won’t realize the time it saves or the bonuses in making things more clear, easier to handle and quicker for your end users.

Author: aallord

1 thought on “Ext JS Crud

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.