Friday, December 30, 2011

Baby Steps with Dojo DataGrid and JsonRestStore Error Handling

These holidays I decided to teach myself something new.  I decided to take a look at Dojo, for being able to offer professional looking and behaving UI components in web based developments.

Instead of leaping into full prototypes of UI layout, I thought I better keep my first attempt at Dojo relatively simple.  I'm starting with a DataGrid that is using a JsonRestStore as the store.  The JsonRestStore is being serviced by a CakePHP 2.0 backend.  I've written a plugin component that will assist with converting between the JsonRestStore method of paging, and the CakePHP method of paging, but that's a topic for another post, and perhaps a release to GitHub.

For this experiment, I ended up using Dojo 1.6.1, even though Dojo 1.7.1 is available.  When I first started playing with Dojo, 1.7.1 had been released, but it was not available on the CDN.  Then I tried playing with an example that defined a custom module to interact with CakePHP pagination.  Unfortunately, there was a bug in 1.7.0 that meant mixing CDNs and local custom modules was out.  So I downloaded 1.7.1.  Then there was another bug that meant mixing local custom modules with the locale Dojo path was also out.  So I ended up falling back to 1.6.1. After all, all the tutorials were based on 1.6 anyway.  It's doco like this that makes me appreciate the work that the developers at cakephp.org put in to the manual, before the release goes live.  Cynical aside time: I recall watching a Dojo promo for what must have been the 1.6 release.  Many of the featured developers commented how the first step to learning Dojo was to look at the code.  Only a couple mentioned going through the tutorials.  One mentioned jumping on IRC, but was quick to qualify that you should only ask questions if you knew what you were talking about.  Dojos' current documentation might be an improvement on what it used to be, but it falls short of the mark if the recommendation is to look at the code, rather than read the manual.  On the flipside, the featured developers were right.  You're going to have to dive in to the code, given the doco in its current form.  Anyway, on with the show.

My end goal is to have a DataGrid that will allow inline editing, but report errors in saving in a manner that is consistent with CakePHP forms.  That is, after the post, the form keeps the edited values, but displays an indicator and message next to each offending field.  In the case of the grid, I've opted for a red border on the offending cell, and a tooltip that will display the associated error message on mouse over of the cell.

The first thing I did was run up a basic DataGrid using a JsonRestStore.  Inside the dojo.ready(), I defined the data store and the grid.

var dataStore = new dojox.data.JsonRestStore({
    target:"/my_datas/"          
});

var layout = [[
    {'name': 'Column 1', 'field': 'id', 'width': '100px'},
    {'name': 'Column 2', 'field': 'col2', 'width': '100px', cellType:dojox.grid.cells.Bool, styles:'text-align:center;'},
    {'name': 'Column 3', 'field': 'col3', 'width': '200px'},
    {'name': 'Column 4', 'field': 'col4', 'width': '150px'}
]];

var grid = new dojox.grid.DataGrid({
    id: 'grid',
    store: dataStore,
    structure: layout,
    rowSelector: '20px'
}, "gridDiv");

grid.startup();

Using examples found around the usual Dojo haunts, I added Add Row and Remove Selected Rows buttons, and functionality. The layout was updated to make columns 2 to 4 editable.

The Add and Remove functions worked well enough, but the inline editing wasn't persisting the changes to the server.  Here's where I learn my first DataGrid lesson.  DataGrid will sync the data to the store, but if the store needs persisting to a server, like a JsonRestStore does, then you are responsible for doing that yourself.  This should have been evident with the add and remove actions associated with the buttons calling dataStore.save().  So I set up a simple action for the onApplyEdit event to save the dataStore.

var applyEdit = function(rowIdx) {
    dataStore.save();
};
dojo.connect(grid, "onApplyEdit", applyEdit);

I should note that my code didn't actually look like that at the time. I had actually overridden the onApplyEdit directly in the grid definition. Since then, I've come to appreciate that if you're going to provide a function to the constructor arguments, defining one to a variable, then using dojo.connect() is the best course of action, since this will append your function to be called with the event, instead of overriding it. I think. I'm still a Dojo noob, so I'm just going to use dojo.connect() because it seems like the right thing to do.

Well, that editing is great if all is going well.  But if there's an error with the save action, or at least validation is failing, what to do?

Well, I spent hours trying to find an example of error handling for DataGrids and JsonRestStores, but I couldn't find a bloody thing.  Error handling is definitely something that you end up having to write yourself.  Here's how I deal with it.

First up, I needed to communicate the fact that validation had failed for an edit.  With CakePHP, you end up with a validationErrors array in your controller that gets passed to the view, and used in the form.  I created an error element that would display the session flash and the validationErrors in a JSON array.  I would strip the model name out of the validationErrors array, so I didn't have to deal with it in Dojo.  And if I did detect an error in the save, then I would return a 409 status code.

The next part was recording those errors in a place where I could then get at it to change how the grid was rendered.  First things first, storage.  And the dataStore was the best place for that storage.  Here is the modification to the applyEdit function, storing any validation errors.

var applyEdit = function(rowIdx) {

    var actions = dataStore.save();

    dojo.forEach(actions, function(action){
        var result = action.deferred.then(function(){
            if (action.target.validationErrors) {
                delete action.target.validationErrors;
            }
        },function(err){
            if (err.responseText) {                        
                try {
                    var responseText = JSON.parse(err.responseText);
                    action.target.validationErrors = responseText.validationErrors;
                } catch (e) {};
            }
        });
    });          
}; 

I eventually figured out that I would need to use Deferreds to be able to get a hook in to handling errors that might be returned in the JsonRest response for the JsonRestStore. Since it is possible for the save action on a store to save more than just one changed record, I would need to loop through all of the actions that were sent to the service, and perform the error checking against each one.

Thusly, I've attached a then() to the action.deferred. The first function in the then() is run on success.  This one will clear any validation errors for the target row, if any previously existed.  The second function is run if there was an error. Hopefully, the error responseText is parsable JSON containing validation errors.  I guess this part could do with further hardening.  What if the response didn't contain parsable JSON?  What if it did, but didn't contain validation errors?

Error handling in DataGrids with JsonRestStore is such a big deal.  It's such a pity that the subject does not get directly addressed in the available tutorials.

Now that I have the validation errors in the dataStore, I can use a cell formatter to detect and highlight the offending cell with a red border.  I'm actually just going to apply a CSS class, and let CSS take care of the rest.

var formatter = function(val, rowIdx, cell) {
    var item = grid.getItem(rowIdx);

    if (item.validationErrors && item.validationErrors[cell.field]) {
        cell.customClasses.push("validation-error");
    }
    return val;
};

Also add formatter: formatter to the records in the layout variable that are editable, namely, columns 2, 3 and 4. You'll also need to define the CSS.  You'll need to be specific, if you hope to have your CSS get included ahead of the other styles defined.

.claro .dojoxGridCell.validation-error {
    border: 1px solid red;
}

While I was at it, I also added the code for the tooltip that would display on mouse over of the offending cell.  This part of my code, I do believe is flawed, but I'll chat about that after the code.  First well, need some code to show the tooltip on mouse over, and hide it on mouse out.  I've also tried to add some code to prevent the tooltip from showing while the field is being edited.  I do this because I believe the HTML node of  the cell is actually replaced during editing, the lost with it is the reference to the displayed tooltip.

var tooltipNode;
var showTooltip = function(e) {
    if (gridTooltipEnabled) {
 var msg;
 var item = e.grid.getItem(e.rowIndex);
 if (item.validationErrors && item.validationErrors[e.cell.field]) {
     msg = item.validationErrors[e.cell.field].join('
');
 }
 if (msg) {
     dijit.showTooltip(msg, e.cellNode);
     // hold reference to cellNode with tooltip for easy hiding
     tooltipNode = e.cellNode;
 }
    }
};

var hideTooltip = function(e) {
    dijit.hideTooltip(e.cellNode);
    tooltipNode = null;
};

var gridTooltipEnabled = true;
// grid definition here ...

dojo.connect(grid, "onCellMouseOver", showTooltip);
dojo.connect(grid, "onCellMouseOut", hideTooltip);
  
// disable and hide tooltip while editing
dojo.connect(grid, "onStartEdit", function (cell, rowIdx) {
    gridTooltipEnabled = false;
    dijit.hideTooltip(tooltipNode); // can't get to a cellNode from cell
});

// enable tooltip when finished editing
dojo.connect(grid, "onCancelEdit", function(rowIdx) {
    gridTooltipEnabled = true;
});

This code constructs a tooltip from the validation errors, and displays it. If you start editing a cell, it will attempt to hide the tooltip, and prevent another from being displayed until you have cancelled editing or have applied the edit (I have a gridTooltipEnabled = true; at the top of applyEdit()).

What I really wanted to was to be able to identify a cellNode from the cell argument passed to onStartEdit, and call dijit.hideTooltip() against that.  But I just couldn't find a way to link these two items together.

The flaw is that if two tooltips are on display when editing starts, then only one of them, the most recently activated, is going to be hidden.

The last part to go is the behaviour of retaining the entered data after an error, and only removing it if the edit is cancelled, or another cell in the store is successfully saved.  You can't micro manage a JsonDataStore.  When you call save(), it applies for all dirty objects in the store, and when you call revert(), it applies for all dirty objects in the store.

To achieve this behaviour, you need to use the revertOnError property as an argument to the dataStore.save() function in applyEdit().  A slight side effect to this property is that the cell will not get redrawn if there is an error.  As a result, formatter is not called, and you don't get to see the red border until you go to a different page of data in the grid, and then go back.  To remedy this, you need to force a render with a grid.update() after assigning validationErrors to the target row in applyEdit().

However, if the user wishes to not attempt the fix the validation errors, and wishes to cancel the whole change, they can start editing the cell, and then cancel edit by pressing Escape.  I've added some code to the onCancelEdit action to revert if the row was dirty, and had validation errors.

dojo.connect(grid, "onCancelEdit", function(rowIdx) {
    gridTooltipEnabled = true;

    // if canceling editing and previously had errors, clear the errors and revert
    var item = grid.getItem(rowIdx);
    if (item) {
        if (dataStore.isDirty(item) && item.validationErrors) {
            // would rather revert the individual item, but this will do
            dataStore.revert();
        }
    }
});

As mentioned in the comments, I would rather just revert the individual row, instead of reverting the whole store. To do this, I have a feeling I would need to perform an explicit fetch on the item, and replace the dirty item in the store. I'll keep it simple for the moment.

There was a Dojo bug I had to apply a workaround for, when using the revertOnError property.  JsonRest.commit() has a bad reference to a dirtyObject variable that doesn't exist.  Simply changing it to dirtyObjects doesn't work either, but the bug reporter was good enough to provide a patch that did work when the problem was reported almost two years ago.  Unfortunately, this means that I'll never be able to use this with a CDN, and I'd need to patch and rebuild the Dojo 1.6.1 distribution.  My next task will be to upgrade this to use Dojo 1.7.1 (or 1.7.2, if it has been released by then).  Hopefully this bug will be fixed by then, or perhaps I'll be able to bump the ticket.

Well, that was my first look at Dojo, and in particular, the DataGrid and JsonRestStore.  I'm hoping that all the tutorials get an upgrade, the documentation improves and show stoppers like no local custom modules with CDNs are fixed before too long.  Even though this was just baby steps, it still seemed like I had to delve to an intermediate level to get the functionality to a point where it would be useful.

If  I can get this working with the 1.7.1 CDN, then I'll load the example to GitHub.  I'll also do an article on the CakePHP plugin that assists with JsonRestStore paging.

4 comments:

  1. I realize this post is really old and you probably already found the answer, but:

    "What I really wanted to was to be able to identify a cellNode from the cell argument passed to onStartEdit, and call dijit.hideTooltip() against that. But I just couldn't find a way to link these two items together."

    The cell object contains a field called "view", which is its DojoViewObject. Within the view is a function called getCellNode which takes a row index and a column index. You already get the row index from onStartEdit, and the column index is another field in the cell object, so:

    dojo.connect(grid, "onStartEdit", function (cell, rowIdx) {
    var cellNode = cell.view.getCellNode(rowIdx, cell.index);

    dijit.hideTooltip(cellNode)
    });

    That should work. I've been doing that alot myself for my own error handling.

    ReplyDelete
  2. Reuben, thanks for the post.

    Did you have a chance to put up CakePHP side of it? I am interested in how the communication takes place on the server side.

    Thank you!!

    ReplyDelete
  3. I never did get around to doing release it as a plugin. However, here's the basics of what I ended up with.

    I created a component called DojoRestComponent (in CakePHP 1.3, as was the project I was working in at the time).

    In the startup, process the HTTP_RANGE http header, and querystring for sort and conditions.
    If the the request was requestedWith json, set renderAs to json (use RequestHandler).

    Grab the HTTP_RANGE
    Explode the range to determine a limit and page to set on $controller->paginate. There is an assumption that if the range is not a single value, then the page is (range[0] / $limit) + 1. Non multiples (usually of 25) are not handled, or at least I don't have a solution.

    If $controller->params['url'] has more than one entry, process the sort key or keys, as populate $controller->paginate['order']. Any remaining entries in $controller->params['url'] are conditions and get added to $controller->params['conditions'][$key]. Manipulate the key on the URL to add the Model.

    In the beforeRender, process $controller->params for paging, limit and count to generate a http header called "Content-Range: items -/"

    Sorry it's all a bit abridged, but I'm unable to release the actual source code for this one.

    Since then, I've been using the SitePen dGrid, but the basics are still the same. For that particular project, I actually wrote an OffsetterComponent that behaves like the PaginateComponent, but deals with offset instead of page, since this fits better with who Dojo tends to do its Json Rest requests. And then the DojoRestComponent talked in terms of the OffsetterComponent, rather than the PaginateComponent. Even that bit of code was 4 or 5 months ago.

    ReplyDelete