Jul 17 2009

Editable cells with liteGrid

Category: jQueryMatt @ 05:31

Continuing my series on the development of liteGrid (part 1, part 2), this post will look at how I implemented an add-on module that provides click-to-edit functionality.  There are several different ways that you can handle editable tables in a browser, but the two most common are cell-based editing and row-based editing.  In row-based editing, an entire row is toggled into edit mode, the user makes changes to the entire row, and then clicks a save button to persist changes to the row.  Alternatively, cell-editing usually allows a user to click on a specific cell to change.  The overall aim for liteGrid is to provide a very Excel-like experience, but inside a browser, so I implemented a module that provide cell-editing functionality.  Again, I am told by Rob that this is absolutely the correct approach to go (<---sarcasm). 

Usage

When an editable cell is clicked, the cell toggles to a configurable editor.  If the user presses enter, the new value is saved to the underlying row’s data item.  If the user presses escape, the edit is canceled, and the row is restored to its original state.  Let’s look at how we would use this module:

$("#myTable").inrad_liteGrid(
    {
        columns: [
            { field: "Name", editable: true },
            { field: "Value", header: "My Value", editable: true,
              type: "dropdown", dropdownOptions: ["one", "two"] },
            { field: "Cost", editable: true, type: "currency" },
            { field: "Other", editable: true, defaultValue: "text2", 
              type: "dropdown", dropdownOptions: [
                    { text: "text1", value: 1 }, 
                    { text: "text2", value: 2 }, 
                    { text: "text3", value: 3}] 
            }
        ],
        dataProvider: new MockDataProvider(),
        modules: [new TreeGridModule(), new StripifyModule(), new InlineEditModule()]
    });

The column model has been extended slightly as InlineEditModule supports additional column properties that allow the caller to control what is editable and how its edited.  By default, columns are not editable.  The “editable: true” property must be specified to enable editing.  By default, a text editor is used, but other types are supported out of the box (with more to come later): dropdown and currency.  For the dropdown type, another column property is supported/required: the list of options.  These can be specified as either a simple string array or as an array of objects with text and value properties.  Again, all we have to do to take advantage of this module’s functionality is add a new instance of it to the liteGrid’s modules array.

The Code

</boringStuff>.  Time for some code.  Be warned, this is the most complex module for liteGrid yet!

function InlineEditModule() {

    //Key codes.
    var escapeKey = 27;
    var enterKey = 13;

    var base = this;

    //We need to know when a row has been bound, that way we can add
    //events to make it editable.
    base.initialize = function(liteGrid, options) {
        ...
    }

    //Callback that takes care of actually making the element editable.
    base.columnBound = function(event, column, tdElement) {
        ...
    }

    //Checks to see if editing is allowed, and if so, initializes everything
    //so that the cell can be edited.
    base.beginEditing = function(column, tdElement) {
        ...
    };

    //Does any post processing.  For now, it just marks the cell as being in edit mode.
    base.beganEditing = function(column, tdElement) {
        ...
    }

    //Gets an appropriate editor.
    base.getEditor = function(column) {
        ...
    }

    //This is the function that supports editing of text elements.  It displays a
    //simple text input element.
    base.editText = function(column, tdElement, dataItem) {
        ...
    }

    //An editor for currency values.
    base.editCurrency = function(column, tdElement, dataItem) {
        ...
    }

    //An editor that allows selecting a value from a drop-down list.
    //This editor requires an additional property to be specified on the 
    //column. 'dropdownOptions' should be an array of values to display 
    //in the list.
    base.editDropdown = function(column, tdElement, dataItem) {
        ...
    }
    
    //TODO: Additional editors go here!
}

First, a few “const” fields are declared to make the code more readable.  These are simply the corresponding keycodes which will be used to save/cancel an edit later. After that, we have the standard module stuff: grabbing a self-reference to eliminate scope issues and initialization which will tie the module into to liteGrid events that it cares about, the main one being the “columnBound” event.  There are two functions that help with making a cell editable, beginEditing and beganEditing.  As you will see, there is a reason this functionality is split into two methods.  Finally, we have the helper function that retrieves the appropriate editor for a cell and the various built-in editors.  All editors are implemented as functions on the module.  The nice thing about this approach is that it’s extensible.  If you want to define a custom type and editor, all you need to do is add a function to an instance of the module, then specify the type on your column model:

var inlineEditor = new InlineEditModule();
inlineEditor.editMyCustomType = function() {
    //TODO: Implement your editor!
}

$("#myTable").inrad_liteGrid(
    {
        columns: [
            { field: "Name", editable: true, type: "myCustomType" },
            ...
        ],
        dataProvider: new MockDataProvider(),
        modules: [new TreeGridModule(), new StripifyModule(), inlineEditor]
    });

I’m not going to get in to the initialize method since all it does is grab a reference to the liteGrid and register to receive columnBound events.  The real magic starts when a new column is bound:

base.columnBound = function(event, column, tdElement) {

    //If the field isn't editable, do nothing
    if (!(column.editable === true)) {
        return;
    }

    //If the column has already been processed, do nothing.
    if (tdElement.hasClass("editable")) {
        return;
    }

    //Determine which editor to use.
    var editor = base.getEditor(column);

    tdElement.addClass("editable")
            .click(function() {
                //Initialize editing.
                if (base.beginEditing(column, tdElement)) {

                    //This is the raw data item.
                    var dataItem = tdElement.parent().data("dataItem");

                    //Create the editor.  We have to new it since there could be 
                    //multiple fields in edit at once.
                    var newEditor = new editor(column, tdElement, dataItem);

                    //Store the editor for later.
                    tdElement.data("editor", newEditor);

                    //Do anything after that?
                    base.beganEditing(column, tdElement);
                }
            });
}

First, the function simply checks to see if the column is editable.  If not, there’s nothing to do.  It also checks for the marker class “editable” which indicates that a column has already been processed and is editable.  This is needed since a column could be rebound for a variety of reasons, and we don’t want to rebind redundant event handlers.  Next, a helper method is used to grab the correct editor for the column.  An inline function is then bound to the element’s click event.  It first checks to see if the column can be edited at this time (more on that later), and if so, toggles the editor on for the cell.  A reference to the editor is stored using jQuery’s data function so that it can be retrieved easily later.

Here’s where the real fun begins.  beginEdit is responsible for checking to see if the element is editable at this time, and it also prepares everything for the editor.

base.beginEditing = function(column, tdElement) {

    //If the field is already in edit mode, don't do anything.
    if (tdElement.hasClass("editing")) {
        return false;
    }

    //Subscribers can request that the edit be cancelled.
    var event = $.Event("cellBeginEditing");
    event.cancelEdit = false;
    //base.liteGrid.$el.trigger("cellBeginEditing", [column, tdElement]);
    base.liteGrid.$el.trigger(event, [column, tdElement]);

    if (event.cancelEdit === true) {
        return false;
    }

    //Store the current contents of the cell.
    tdElement.data("oldValue", tdElement.html());

    //This is the event handler that is responsible for
    //ending edit mode.  It's declared as a variable 
    //so that it can unregister itself after its finished.
    var onKeyUp = function(event) {
        //Escape == cancel edit, Enter == save changes
        if (event.keyCode == escapeKey ||
                event.keyCode == enterKey) {

            //Grab the editor out of the element.
            var editor = tdElement.data("editor");

            if (!editor) {
                console.error("Unable to retrieve editor reference.");
                return;
            }

            var modified = false;

            if (event.keyCode == escapeKey) {

                //Editors can define a custom method to be called
                //when editing mode is canceled.
                if (editor.cancelEdit) {
                    editor.cancelEdit();
                }

                //Restore the old contents.
                tdElement.html(tdElement.data("oldValue"));
            }
            else {

                var value = editor.getValue();

                var dataItem = tdElement.parent().data("dataItem");
                dataItem[column.field] = value;
                tdElement.parent().data("dataItem", dataItem);

                tdElement.html(value);

                //See if any changes were made.
                if (tdElement.html() != tdElement.data("oldValue")) {
                    tdElement.addClass("modified");
                    modified = true;
                }
            }

            //Clear data that we don't need, and mark 
            //the cell as being editable again.
            tdElement.removeData("editor")
                    .removeData("oldValue")
                    .removeClass("editing");

            //If changes were made, raise the column-bound event so that any 
            //other modules can re-examine the column.
            if (modified) {
                base.liteGrid.$el.trigger("columnBound", [column, tdElement]);
            }

            //Unregister the event.
            tdElement.unbind("keyup", onKeyUp);
        }
    };

    //We watch for escape and enter events.
    tdElement.bind("keyup", onKeyUp);

    return true;
}

If the element is already being edited (as indicated by the marker “editing” class), nothing happens.  Otherwise, we use jQuery’s custom event support to raise a “cellBeginEditing” event.  Subscribers to the event can choose to block the edit by setting “cancelEdit” to true.  If none of the subscribers choose to block the edit, work continues.  The current contents of the cell are persisted (again, using jQuery’s data function) so that they can be restored later if the edit operation is canceled. 

Here is where things get a little tricky.  A handler is attached to the cell’s keyup event (keypress is currently bugged in jQuery for certain keys, particularly enter and escape).  Instead of attaching the function in-line though, I’m actually assigning it to a variable.  That’s so I can unregister the handler when I no longer need it. 

Inside the handler (which will be fired anytime the user presses a key while the editing the cell), nothing happens unless the user pressed the escape or enter keys.  When this happens, the editor is retrieved using jQuery’s dta function.  If the escape key was pressed, the editor is checked to see if it defines custom behavior that needs to be run when editing is canceled; if so, it is called.  After that, the old contents are restored.  If the user pressed enter, then they are indicating that they want to persist the value.  All editors must expose a getValue function that returns the new value.   The data item for the row is retrieved, and the new value is stored, and the element is updated.  A quick check is performed to see if the value actually changed.  Regardless of which key was pressed, the element is cleaned up.  The marker class is removed, and objects stored using jQuery’s data function are purged.  If the column was actually edited, the columnBound event is re-fired, so that interested parties can examine the new value.  Finally, the function unbinds itself from the keypress event so that it won’t be called again.

If that sounds complicated, that’s only because it is.  It took quite a while to get that part working, and I’m still not 100% satisfied with the code.  It *does* work though, in both Firefox and IE8, so I call that a win.

Next up is a simple helper that’s called after an element has entered edit mode:

base.beganEditing = function(column, tdElement) {

    tdElement.addClass("editing");

    base.liteGrid.$el.trigger("cellBeganEditing", [column, tdElement]);
}

It just adds the marker class and raises an event so that interested parties can keep up with what’s going on (remember, you can actually have modules that enrich the behavior of other modules by taking advantage of the event infrastructure that liteGrid is built on).

The next function is responsible for determining the appropriate editor for a cell:

base.getEditor = function(column) {

    //First, see if a custom editor is specified.
    if ("customEditor" in column) {
        return customEditor;
    }

    //If not, see if we have an editor for the type.
    var type = (column.type || "text");

    //All edit functions are in the form editType.
    type = "edit" + type.substr(0, 1).toUpperCase() + type.substr(1);

    //If the editor is defined, return it.  Looking up editors like this
    //allows for custom editors to be added on after the fact.
    if (type in base) {
        return base[type];
    }
    else {
        return function() { alert("No suitable editor found for this type: " + type); }
    }
}

First it checks to see if the column defines a custom editor.  I forgot to mention that you can do that.  So yeah, a column can define a custom function that handles its editing.  Next the editor type is built using the format “editorType”.  A quick check to see if the module defines this function is performed.  If it does, it’s returned, otherwise an alert is shown.  This flexible look-up of editors supports the ability to bolt-on new editors to an instance of the module that I described earlier.

Finally, we have the editors.  Due to this article already being hella-long, I’m going to show only the text editor.  The others are straightforward to implement:

base.editText = function(column, tdElement, dataItem) {

    //Used to eliminate scope issues.
    var editor = this;

    //Enables the editor on the element.
    editor.initialize = function() {
        editor.input = $("<input type='text' value='" + dataItem[column.field] + "' />")
        tdElement.html(editor.input);
        editor.input.focus();
    }

    //Invoked to save changes back to the underlying data item.
    this.getValue = function() {
        return (editor.input.val() || "");
    }

    editor.initialize();
}

The editor is broken up with its initialization logic wrapped in an inner-function, but it doesn’t have to be.  The only requirement is that an editor *must* expose a getValue function.  So, this editor simply builds a standard input box for the value being edited, adds it to the cell in place of its old contents, and sets the focus to it.  Getting the value is a simple matter of extracting the value out of the input later.  Remember that all cleanup is handled by the core of the module, so individual editors don’t have to worry about it (unless they want to).

Final Thoughts

After I completed most of the work on this module, James pointed out that I might have been able to leverage jEditable in my work.  I somehow neglected to check for tools to build from when I stared on this module, so it’s quite possible that I will rewrite this at some point to use jEditable instead of my custom editing scheme.  For now though, I’m fairly satisfied with the results.  As always, comments are very appreciated.

Tags:

blog comments powered by Disqus