Read Part I here.
In Part I of the article I showed how to use IEC (Inline Edit Controller) to enable inline editing in grid. We've used this inline editing in a real project - TargetProcess - ASP.NET based agile project management software. This part describes the architecture and advanced features like validation and extension.
Basic Classes
Inline editing solution consists of three classes:
InlineEditController
- Controller class that handles inline editing EditAreaSettings
- Class that stores information about what should be processed in IEC TransactObject
- Helper object that manages saving procedure (it acts as a synchronizer and restores old values in case of server side error as well as confirms correct saving)
var editAreasSettings = new Array();
var shipNameEditAreaSettings =
new EditAreaSettings("ShipName","ShipNameIdPrefix",null, true);
editAreasSettings.push(shipNameEditAreaSettings);
var shipAddressAreaSettings = new EditAreaSettings("ShipAddress","ShipAddressIdPrefix");
editAreasSettings.push(shipAddressAreaSettings);
var shipRegionAreaSettings = new EditAreaSettings("ShipRegion","ShipRegionIdPrefix");
editAreasSettings.push(shipRegionAreaSettings);
var inlineEditController =
new InlineEditController('gridID', editAreasSettings, onSaveEventHandler, true);
function onSaveEventHandler(retObj)
{
var transactionObj = new TransactionObject(retObj.itemID, inlineEditController);
transactionObj.onStatusSuccess = onRequestComplete;
transactionObj.onStatusError = onRequestError;
retObj.OrderID = retObj.itemID;
TargetProcess.DaoTraining.BusinessLogicLayer.OrderService.UpdateOrder(retObj,
transactionObj.onSaveSuccess ,
transactionObj.onSaveError)
}
Let's take a look at all the classes.
InlineEditController
Controller class that handles inline editing.
var inlineEditControllerObj = new InlineEditController
(gridID, editAreasSettings , onSaveEventHandler, isExplicitSave)
Arguments
gridID
Id of the grid
editAreasSettings
Array of EditAreaSettings
objects
isExplicitSave
Flag specifying whether a row in edit state should be saved when another row is set into edit state. For example, when user clicks on another row, the first row will be saved if this flag is set to true
.
OnSaveEventHandler
Callback function that is called on save event
IEC does not know how to save the item, so the saving procedure should be provided (for example, it may be Web Service call).
function onSaveEventHandler(retObj){
retObj.OrderID = retObj.itemID;
MyWebService.OrderService.UpdateItem(retOb);
}
retObj
Object that contains edited values
Public Methods
inlineEditControllerObj.commitChanges(itemID)
This method should be called to confirm that data was successfully saved and there is no need to restore initial values.
inlineEditControllerObj.abandonChanges(itemID)
This method should be called to restore the initial values in case of server side error.
EditAreaSettings
Class that stores information about what should be processed in IEC. The IEC should have information about what and how it should be updated. The EditAreaSettings
object holds all essential information to edit the item.
var editAreaSettingsObj = new EditAreaSettings(areaName,
areaPrefix,
areaDataSourceControlID,
isFocus,
onSelectValue,
onExtractEditedValue,
onRenderEditedValue,
onCancelEditValue,
onEditValue)
Arguments
areaName
Maps the edited values into retObj
(as you remember retObj
is passed into OnSaveEventHandler
and stores all new values that should be saved).
areaPrefix
We find the "Edit Area" using this argument. The full id of "Edit Area" is areaPrefix
+"id of item".
<asp:TemplateField HeaderText="Ship Name">
<ItemTemplate>
<span id="ShipNameIdPrefix<%# Eval("OrderID")%>">
<asp:Label ID="Label1" runat="server"
Text='<%#Eval("ShipName")%>'></asp:Label>
</span>
</ItemTemplate>
</asp:TemplateField>
var shipNameEditAreaSettings =
new EditAreaSettings("ShipName","ShipNameIdPrefix",null, true);
editAreasSettings.push(shipNameEditAreaSettings);
var inlineEditController =
new InlineEditController('uxGridID', editAreasSettings,
onSaveEventHandler, true);
...
areaDataSourceControlID
Id of a dataSource control
When edit state is enabled for row, all labels are transferred into editable state. It means that text will be replaced by input field. Another scenario is when text value should be replaced by Select Box with predefined values to select. IEC will try to set the value in the Select box based on text in column. DataSourceControl
is a drop down that contains all required values.
isFocus
Should "Edit Area" receive a focus when edit state becomes active?
onSelectValue
In most cases, you will use this handler to set the correct value in DataSource
Control.
onExtractEditedValue
A custom handler may be used to extract edited values from "Edit Areas".
onCancelEditValue
A custom handler may be used to set edit area into original (read-only) state.
onEditValue
A custom handler may be used to set edit area into cool (inline edit) state.
OnRenderEditedValue
A custom handler may be used to render original (read-only) with some specialties, like display an image that represents Priority or something like that.
All these handlers can be omitted. You should use them for complex cases.
TransactObject
Helper object that manages saving procedure (it acts as a synchronizer and restores old values in case of server side error as well as confirms valid saving).
var transactionObject = new TransactionObject(itemID, inlineEditController);
Arguments
itemID
ID of an item that will be updated. inlineEditController
The reference to the IEC.
IEC LifeCycle
IEC life-cycle is not a simple thing I should say. It is quite hard to describe how it works in short, but let's try.
Stage 1. IEC Initialization
IEC binds event handlers to corresponding elements through the whole grid. We have just three handlers: Edit, Save and Cancel. So bind them to required controls.
function InlineEditController
(gridID, editAreasSettings, onSaveEventHandler, isExplicitSave)
{
...
var activeInlineEditPanel;
var thisRefInlineEditController = this;
var previousStates = new Object();
attachEvents();
function attachEvents()
{
var grid = document.getElementById(gridID);
if(grid == null)
{
alert("error: Unable to find a grid.");
return;
}
grid.onclick = onGridClick;
for(var index=0; index < grid.rows.length;index++)
{
var cells = grid.rows[index].cells;
for(var index1=0; index1 < cells.length; index1++)
{
var element = findInlineEditPanelInElement(cells[index1]);
if(element == null)
continue;
if(element.attributes == null)
continue;
if(element.attributes[INLINE_PANEL_FLAG_ATTRIBUTE] != null)
{
var row = findRowForElement(element);
row.ondblclick = onDblClickRow;
row.onkeypress = onEditing;
var children = extractDHTMLElements(element);
for(var index2 = 0; index2 < children.length; index2++)
{
var control = children[index2];
switch(control.attributes[INLINE_ACTION_ATTRIBUTE].nodeValue)
{
case INLINE_EDIT_ACTION:
control.onclick = onEditAction;
break;
case INLINE_SAVE_ACTION:
control.onclick = onSaveAction;
...
Stage 2. Enabling Edit Mode
What happens when the user clicks Edit link? Maybe the system crashes as usual? Not at this time! It sets the table row into editable state. The most interesting method is editRow
(hmm, it looks like it should be refactored a little, ok, I will leave you some space for improvements :). So what does it do?
- Hides some actions and shows inline editing actions instead.
- Highlights editable row with fancy green color (well, may be customized if you prefer pink).
- Stores current values (we may need them later if user pushes Cancel or some server problems arises).
- Calls specific
onEditValue
handler if exists. - Creates input element to allow user to actually edit something.
......
function onEditAction()
{
cancelEditRow();
editRow(this.parentNode);
}
function onDblClickRow(eventObj)
{
if(eventObj == null)
eventObj = event;
var srcElement = eventObj.srcElement ? eventObj.srcElement : eventObj.target;
if(srcElement.tagName == "A" )
return;
var inlineEditPanel = findInlineEditPanelInElement(this);
if(inlineEditPanel)
{
cancelEditRow();
editRow(inlineEditPanel);
}
}
function editRow(inlineEditPanel)
{
if(activeInlineEditPanel != null)
{
alert("error: Unable to set edit state.
There must be no item in edit state.");
return;
}
activeInlineEditPanel = inlineEditPanel;
var children = extractDHTMLElements(activeInlineEditPanel);
for(var index=0; index < children.length; index++)
{
var control = children[index];
switch(control.attributes[INLINE_ACTION_ATTRIBUTE].nodeValue)
{
case INLINE_EDIT_ACTION: control.style.display='none'; break;
case INLINE_SAVE_ACTION: control.style.display='';break;
case INLINE_CANCEL_ACTION: control.style.display='';break;
default:;
}
}
highLightInlineEditRow(INLINE_ROW_BACKGROUNDCOLOR_ACTIVE);
var itemID = getActiveInlineEditPanelID();
if(itemID == null)
{
alert("error: Unable to find item id.");
return;
}
previousStates[itemID] = new Object();
for(var index=0; index < editAreasSettings.length; index++)
{
var editAreaSettings = editAreasSettings[index];
var editAreaID = editAreaSettings.areaPrefix+itemID
var editArea = document.getElementById(editAreaID);
if(editArea == null)
continue;
previousStates[itemID][editAreaID] = editArea.parentNode.innerHTML;
if(editAreaSettings.onEditValue != null)
{
editAreaSettings.onEditValue(editAreaSettings, editArea);
}
else
{
if(editAreaSettings.areaDataSourceControlID == null)
editTextField(editArea);
else
editDataSourceField(editArea, editAreaSettings.areaDataSourceControlID);
}
if(editAreaSettings.onSelectValue != null)
editAreaSettings.onSelectValue(editAreaSettings, editArea);
else
selectValueInDataSourceControl(editArea);
if(editAreaSettings.isFocus)
setFocusForEditableField(editArea);
}
}
There are two possible customizations at this stage:
Stage 3. Save Changes
First we should extract new values from form fields. The methods with very original names extractEditedValues
and extractEditedValue
do the entire job. As a result we have an object with all new values. Then we call onSaveEventHandler
to save new values into the database.
function onSaveAction()
{
var obj = extractEditedValues();
if(onSaveEventHandler != null)
onSaveEventHandler(obj);
cancelEditRow();
}
function extractEditedValues()
{
if(activeInlineEditPanel == null)
return null;
var retObj = new Object();
var itemID = getActiveInlineEditPanelID();
if(itemID == null)
{
alert("error: Unable to find item id.");
return;
}
retObj.itemID = itemID;
for(var index=0; index < editAreasSettings.length; index++)
{
var editAreaSettings = editAreasSettings[index];
var editArea = document.getElementById(editAreaSettings.areaPrefix+itemID);
if(editArea == null)
continue;
if(editAreaSettings.onExtractEditedValue != null)
editAreaSettings.onExtractEditedValue
(retObj, editAreaSettings, editArea);
else
extractEditedValue(retObj, editAreaSettings, editArea);
}
return retObj;
}
function extractEditedValue(refRetObj, editAreaSettings, editArea)
{
var children = extractDHTMLElements(editArea);
if(editAreaSettings.areaDataSourceControlID == null)
{
refRetObj[editAreaSettings.areaName] = children[1].value;
children[0].innerHTML = children[1].value;
}
else if(children[1].tagName == 'SELECT' )
{
var select = children[1];
refRetObj[editAreaSettings.areaName] =
(trimText(select.options[select.selectedIndex].value)=="")
? null : select.options[select.selectedIndex].value;
children[0].innerHTML =
(trimText(select.options[select.selectedIndex].value)=="") ?
"" : select.options[select.selectedIndex].text;
}
}
There is one possible customization at this stage.
onExtractEditedValue
A custom handler may be used to extract edited values from "Edit Areas". Example
Stage 4. Disabling Edit Mode
When we saved changes, the table row should be turned back into original state. Not such a trivial thing in fact. The cancelEditRow
function is responsible for the transition.
function onEditing(eventObj)
{
if(eventObj == null)
eventObj = event;
if(eventObj.keyCode == 27)
onCancelAction();
...
}
function onCancelAction()
{
cancelEditRow();
}
function cancelEditRow()
{
if(activeInlineEditPanel == null)
return;
var children = extractDHTMLElements(activeInlineEditPanel);
for(var index=0; index < children.length; index++)
{
var control = children[index];
switch(control.attributes[INLINE_ACTION_ATTRIBUTE].nodeValue)
{
case INLINE_EDIT_ACTION: control.style.display=''; break;
case INLINE_SAVE_ACTION: control.style.display='none';break;
case INLINE_CANCEL_ACTION: control.style.display='none';break;
default:;
}
}
highLightInlineEditRow(INLINE_ROW_BACKGROUNDCOLOR_NORMAL);
var itemID = getActiveInlineEditPanelID();
if(itemID == null)
{
alert("error: Unable to find item id.");
activeInlineEditPanel = null;
return;
}
for(var index=0; index < editAreasSettings.length; index++)
{
var editAreaSettings = editAreasSettings[index];
var editArea = document.getElementById(editAreaSettings.areaPrefix+itemID);
if(editArea == null)
continue;
if(editAreaSettings.onCancelEditValue != null)
{
editAreaSettings.onCancelEditValue(editAreaSettings, editArea);
}
else
{
if(editAreaSettings.areaDataSourceControlID == null)
cancelEditTextField(editArea);
else
cancelEditDataSourceField(editArea);
}
if(editAreaSettings.onRenderEditedValue!=null)
editAreaSettings.onRenderEditedValue(editAreaSettings, editArea);
}
activeInlineEditPanel = null;
}
There are two possible customizations at this stage.
onCancelEditedValue
A custom handler may be used to set edit area into original (read-only) state. Example
onRenderEditedValue
A custom handler may be used to render original (read-only) label with some specialties, like display an image that represents Priority or something like that. Example
TransactObject Implementation
TransactObject
is a helper object that manages saving procedure (it acts as a synchronizer and restores old values in case of server side error as well as confirms valid saving).
The IEC replaces editable fields by text with new values, while the server is still processing the request. Since updating is asynchronous, we have a problem of Web service exception synchronization. What happens when an object can't be saved and we receive an exception? New values will be set for the row, but these values are not correct anymore.
Another problem is that AJAX.NET wraps all exceptions from Web service into WebServerError
class and it's impossible to get information about what item was failed. TransactionObject
handles these problems. It has a reference to IEC and can restore the initial values in case of error. IEC holds the initial values of the item till the commitChanges
or abandonChanges
method call.
function onSaveEventHandler(retObj)
{
var transactionObj = new TransactionObject(retObj.itemID, inlineEditController);
transactionObj.onStatusSuccess = onRequestComplete;
transactionObj.onStatusError = onRequestError;
retObj.OrderID = retObj.itemID;
TargetProcess.DaoTraining.BusinessLogicLayer.OrderService.UpdateOrder(retObj,
transactionObj.onSaveSuccess ,
transactionObj.onSaveError)
}
function TransactionObject(itemID, inlineEditController, onSuccessStatus, onErrorStatus)
{
var thisRefTransactionObject = this;
this.inlineEditController = inlineEditController;
this.itemID = itemID;
this.onStatusSuccess = onSuccessStatus;
this.onStatusError = onErrorStatus;
this.onSaveSuccess = function(result)
{
thisRefTransactionObject.inlineEditController.commitChanges
(thisRefTransactionObject.itemID);
if(thisRefTransactionObject.onStatusSuccess)
thisRefTransactionObject.onStatusSuccess(result);
}
this.onSaveError = function(ex, context)
{
thisRefTransactionObject.inlineEditController.abandonChanges
(thisRefTransactionObject.itemID);
if(thisRefTransactionObject.onStatusError)
thisRefTransactionObject.onStatusError(ex);
}
Extending Base Functionality. Custom Edit Handler
By default IEC has a simple logic for text and drop down editing, since it is impossible to implement all cases.
Sometimes, you get into a situation when the base functionality is not enough. For instance, Priority
property of Order
class. When the row is in read-only state it must show the priority image and in edit state it must show the select box.
<asp:TemplateField>
<ItemTemplate>
<%----%>
<span priorityname='<%#Eval("Priority")%>'
id="PriorityIdPrefix<%# Eval("OrderID")%>">
<span>
<%#GetPriorityHTML(Container.DataItem)%>
</span>
</span>
</ItemTemplate>
</asp:TemplateField>
var prioritySettings = new EditAreaSettings
("Priority","PriorityIdPrefix","uxPriorities");
prioritySettings.onRenderEditedValue = onRenderPriorityValue;
prioritySettings.onSelectValue = onSelectPriorityValue;
editAreasSettings.push(prioritySettings);
var inlineEditController = new InlineEditController
('<%=uxOrders.ClientID%>', editAreasSettings, onSaveEventHandler, true);
var HIGHEST_PRIORITY_IMAGE = "<%=HIGHEST_PRIORITY_IMAGE%>";
var NORMAL_PRIORITY_IMAGE = "<%=NORMAL_PRIORITY_IMAGE %>";
var LOWEST_PRIORITY_IMAGE = "<%=LOWEST_PRIORITY_IMAGE %>";
var HIGHEST_PRIORITY = "<%=Priority.Highest %>";
var NORMAL_PRIORITY = "<%=Priority.Normal %>";
var LOWEST_PRIORITY = "<%=Priority.Lowest%>";
var PRIORITY_ATTR = "priorityname";
It is required to provide two handlers: onRenderPriorityValue
and onSelectPriorityValue
.
onSelectPriorityValue
handler just selects the predefined value in Select box.
function onSelectPriorityValue(editAreaSettings, editArea)
{
var dataSourceControl =
document.getElementById(editAreaSettings.areaDataSourceControlID);
dataSourceControl.value = editArea.attributes[PRIORITY_ATTR].nodeValue;
}
onRenderPriorityValue
converts Priority
value into a specific image.
function onRenderPriorityValue(editAreaSettings, editArea)
{
var dataSourceControl =
document.getElementById(editAreaSettings.areaDataSourceControlID);
var children = extractDHTMLElements(editArea);
var readField = children[0];
switch(dataSourceControl.value)
{
case HIGHEST_PRIORITY:
readField.innerHTML = HIGHEST_PRIORITY_IMAGE;
break;
case NORMAL_PRIORITY:
readField.innerHTML = NORMAL_PRIORITY_IMAGE;
break;
case LOWEST_PRIORITY:
readField.innerHTML = LOWEST_PRIORITY_IMAGE;
default:
}
editArea.attributes[PRIORITY_ATTR].nodeValue = dataSourceControl.value;
}
This situation is quite a unique case. In most case the base logic of IEC will fit your requirements. For instance, Country
property of Order
class works just fine.
<asp:TemplateField HeaderText="Ship Country">
<ItemTemplate>
<span id="ShipCountryIdPrefix<%# Eval("OrderID")%>">
<asp:Label ID="Label1" runat="server" Text='<%#Eval("ShipCountry")%>'></asp:Label>
</span>
</ItemTemplate>
</asp:TemplateField>
.....
<select style="display: none" id="uxCountries">
<option>-- Select Country</option>
<option value="USA">United States</option>
<option value="Canada">Canada</option>
<option value="Mexico">Mexico</option>
<option value="Afghanistan">Afghanistan</option>
<option value="Albania">Albania</option>
....
var shipCountrySettings =
new EditAreaSettings("ShipCountry","ShipCountryIdPrefix","uxCountries");
editAreasSettings.push(shipCountrySettings);
As you see, there are no custom handlers. All what you need to do is to pass uxCountries
ID into shipCountrySettings
. The IEC will successfully select the value in Select box using the text in column and render the new value.
Extending Base Functionality. CheckBox Handler
What if you have a boolean value that is represented as a checkbox in the grid? Let's see how that can be implemented.
<asp:TemplateField HeaderText="Rush Order">
<ItemTemplate>
<span id="IsRushOrderIdPrefix<%#Eval("OrderID")%>" >
<asp:CheckBox Enabled="false" runat="server"
Checked='<%#Eval("isRushOrder") %>' ID="uxRushOrder" />
</span>
</ItemTemplate>
</asp:TemplateField>
var isRushOrderSettings = new EditAreaSettings("IsRushOrder","IsRushOrderIdPrefix");
isRushOrderSettings.onEditValue = onEditRushValueHandler;
isRushOrderSettings.onExtractEditedValue = onExtractRushValueHandler;
isRushOrderSettings.onCancelEditValue = onCancelEditRushValueHandler;
editAreasSettings.push(isRushOrderSettings);
I should create three custom handlers: onEditRushValueHandler
, onExtractRushValueHandler
, and onCancelEditRushValueHandler
. onEditValue
just enables checkbox.
function onEditRushValueHandler(editAreaSettings, editArea)
{
var isRushOrderSpan = getFirstDHTMLElement(editArea);
isRushOrderSpan.disabled = false;
var isRushOrderChk = getFirstDHTMLElement(isRushOrderSpan);
isRushOrderChk.disabled = false;
}
onExtractRushValueHandler
gets the new value from checkbox and saves it in retObj
(that is passed into onSaveEventHandler
as you remember).
function onExtractRushValueHandler(retObj, editAreaSettings, editArea)
{
var isRushOrderChk = getFirstDHTMLElement(getFirstDHTMLElement(editArea));
retObj[editAreaSettings.areaName] = isRushOrderChk.checked;
}
onCancelEditValue
disables checkbox.
function onCancelEditRushValueHandler(editAreaSettings, editArea)
{
var isRushOrderSpan = getFirstDHTMLElement(editArea);
isRushOrderSpan.disabled = true;
var isRushOrderChk = getFirstDHTMLElement(isRushOrderSpan);
isRushOrderChk.disabled = true;
}
Thus you can create custom handlers for any case, even extremely complex ones.
Input Validation
There are no standard solutions to validate input in JavaScript (like ASP.NET validators, for example), but you can still use handlers that would act as validators. Fortunately, TransactObject
restores the initial values in case of server side error that can be caused by invalid input, but it's not enough. We should implement extra validation for the Freight
property that is supposed to be Decimal
to validate input.
<asp:TemplateField HeaderText="Freight">
<ItemTemplate>
<span id="FreightIdPrefix<%# Eval("OrderID")%>">
<asp:Label ID="Label1" runat="server" Text='<%#Eval("Freight")%>'></asp:Label>
</span>
</ItemTemplate>
</asp:TemplateField>
var freightSettings = new EditAreaSettings("Freight","FreightIdPrefix");
freightSettings.onSelectValue = onSelectFreightValueHandler;
editAreasSettings.push(freightSettings);
We need to implement just one handler onSelectFreightValueHandler
. This handler assigns validation handler for Freight
input box and it is impossible to type anything except numbers and decimal separator.
function onSelectFreightValueHandler(editAreaSettings, editArea)
{
var children = extractDHTMLElements(editArea);
children[1].onkeypress = onKeyPressDecimalValueHandler;
}
function onKeyPressDecimalValueHandler(eventObj,obj)
{
var thisRef;
if(obj == null)
thisRef = this;
else
thisRef = obj;
function isValidInput(key,value)
{
if(key == "." && value.indexOf(".")==-1)
return true;
if(isNaN(key))
return false;
else
return true;
}
if(typeof event == "undefined")
{
var key = String.fromCharCode(eventObj.charCode);
if(eventObj.charCode == 0)
return;
if(!isValidInput(key, thisRef.value))
eventObj.preventDefault();
}
else
{
var key = String.fromCharCode(event.keyCode);
if(!isValidInput(key, thisRef.value))
event.returnValue = false;
}
}
The other solution is to use TransactObject
as a validator. For instance, if I try to enter invalid date TransactObject
restores the initial value and shows the error message.
Now you can use inline editing in all your lists without problems.