CoverStory
LANGUAGES: C# | VB
ASP.NET VERSIONS: 3.5
AJAX Abstraction
Abstraction of Concrete Concepts Improves Functionality
By Brian Mains
Since the ASP.NET AJAX framework came out, it
really hasn t done anything exceptionally new that isn t already capable within
the JavaScript language itself or in one of the many JavaScript libraries on
the market (like extJS, Prototype, jQuery, Rico, etc.). Concepts like class
development and design patterns are already available in JavaScript (you can
find several books on the subject), and CSS class toggling, attaching event
handlers, and rounding corners of tables (without the use of an extender) are
already available in other JavaScript libraries.
What the ASP.NET AJAX framework does provide is a
more managed way to develop JavaScript components and expose structures that
look more like the ASP.NET Framework API. What every JavaScript library
attempts to offer is to provide more features using less code and this includes
the ASP.NET AJAX framework.
When developing any application, on the client or
on the server, it is good practice to develop using an approach that creates
reusable code, rather than embedding code into an application in a situation
where it can t be reused. If the developer can t make use of that code
elsewhere, what good is the code (even if it is well designed)? This same
concept works with JavaScript; it is better to create a reusable library than
embed the JavaScript code in the page markup.
Reusability is good, but abstraction of concrete concepts
provides even greater functionality. What I mean by this statement is that
creating components that work with a concrete implementation of
HTML/JavaScript, but work with these elements in an abstract way, provides a
greater level of reuse. This is the core concept of this article.
Simplifying Table Modifications
Tables are simplistic HTML structures; it s so easy
I m going to assume you know what a table looks like. While it s easy to define
a table structure in HTML, it s a little more tedious to create one in
JavaScript. An on-demand world requires on-the-fly, AJAX-enabled, client-side
table generation, so that s what this component is all about.
But what do we need to do with tables beyond the standard
HTML definition? Let s look at some possible scenarios:
A custom control or extender may dynamically
generate a table, or refresh a table based on external data.
A page may use a table, and want to be able to
use this component, to tap in to an existing table structure and modify it in
some way.
A page may extract information from a table by
reading its rows, columns, or cells.
In all these scenarios, I don t want the developer
to worry about some of these details; I want to spare them from having to write
the type of code you see in Figure 1.
var table = document.createElement("TABLE");
var thead = document.createElement("THEAD");
table.appendChild(thead);
var tbody = document.createElement("TBODY");
table.appendChild(tbody);
var tfoot = document.createElement("TFOOT");
table.appendChild(tfoot);
var headerRow = thead.insertRow();
var headerCell = headerRow.insertCell();
headerCell.innerHTML = "Name";
headerCell = headerRow.insertCell();
headerCell.innerHTML = "Address";
var contentRow = tbody.insertRow();
var contentCell = contentRow.insertCell();
contentCell.innerHTML = "Brian";
contentCell = contentRow.insertCell();
contentCell.innerHTML = "Some Address";
Figure 1: Defining a table programmatically
This is the DOM approach to creating a table in
JavaScript; creating the table as a string and assigning it via the innerHTML
property of another HTML element is another viable option. However, this
article uses the DOM approach throughout. You probably noticed the code in
Figure 1 wasn t that difficult to write. What I m attempting to do with this
simple example is create an abstract approach (not letting the user worry about
the table specifics) to provide a better set of features with JavaScript using
less code.
The following JavaScript component example is named
TableContentManager. This component can generate a new table structure on the
fly, or it can create an instance of itself using an existing table, reading in
the table s information (expecting that that table fits the standard definition
the TableContentManager generates).
The
TableContentManager is outlined in Figure 2. The approach for creating a new
table versus reading the existing table is done by using static methods, a
factory method design pattern approach.
Nucleo.Web.TableContentManager
= function(table,
columns) {
Function._validateParams(arguments, [
{ name: "table", type: Object,
mayBeNull: false,
optional: false },
{ name: "columns", type: Array,
mayBeNull: false,
optional: false }
]);
this._table = table;
this._columns = columns;
this._events = new Sys.EventHandlerList();
}
Nucleo.Web.TableContentManager.registerClass(
"Nucleo.Web.TableContentManager",
Sys.Component);
Nucleo.Web.TableContentManager.createNew
=
function(targetParent, columns) {
}
Nucleo.Web.TableContentManager.read
=
function(targetTable) {
}
Figure 2: TableContentManager s core definition
For
creating a new table structure, I m using the DOM approach that is similar to
creating an XML document in .NET: create content using document.createElement,
then append child content to parent content using the appendChild method. The
table has a header, body, and footer appended to it, followed by all the rows
and cells that make up each row.
In
addition, the table must be appended to the parent so it comes in view. The
simplest way was to pass along the parent to the createNew static method. You
saw the same code in Figure 1, which is now reusable (see Figure 3).
Nucleo.Web.TableContentManager.createNew
=
function(targetParent, columns) {
var table =
document.createElement("TABLE");
var thead =
document.createElement("THEAD");
table.appendChild(thead);
table.appendChild(document.createElement("TBODY"));
table.appendChild(document.createElement("TFOOT"));
var headerRow = thead.insertRow();
for (var columnIndex = 0; columnIndex <
columns.length;
columnIndex++) {
var headerCell = headerRow.insertCell();
headerCell.innerHTML =
columns[columnIndex];
}
targetParent.appendChild(table);
var tableManager =
new Nucleo.Web.TableContentManager(table,
columns);
tableManager._attachToRowEvents(headerRow);
return tableManager;
}
Figure 3: Creating a new table
This method
doesn t create any actual data; rather, it creates the header structure and
establishes the collection of columns. This collection of columns is used to
enable the consumer to reference the column by name instead of by index. The
rows of data that are created later are validated against the columns, ensuring
that only the correct number of columns of data is created. For instance, if
five column names are passed in, a five-column structure, one column for each
of the column names, is generated. If one more or less is passed in, an
exception is thrown.
The body of
the table is generated by two methods in the prototype, which is called after
the TableContentManager class is generated. These methods, createNewRow and
updateRow, bind a single object to the table for a specified row, creating or
updating the row as necessary.
These
methods work nicely because an object in JavaScript is noted by curly braces,
{}, and each property/value of that property is noted in the name:value
notation. Luckily, a similar approach also works with array scenarios, and this
method has dual functionality. Take a look at the example of reading in a
single row of data shown in Figure 4.
updateRow:
function(index, values) {
if (values == null || values == undefined)
throw
Error.argumentNull("values");
var isArray = (Object.getType(values) ==
typeof (Array));
if (isArray && (values.length !=
this.get_columnCount()))
throw Error.argument("The array
doesn't have the
correct
number of values");
var body = this._getBodyElement();
var contentRow = null;
//If -1 (new row indicator) or the body
doesn't have
//the total number of rows for the index,
create
//a new row and attach to it
if (index == -1 || body.rows.length <=
index) {
contentRow = body.insertRow();
this._attachToRowEvents(contentRow);
}
//ensure index isn't out of bounds of row
collection
else
contentRow = body.rows[index];
for (var cellIndex = 0; cellIndex <
this.get_columnCount(); cellIndex++) {
//If the data source is an array, reference
by index
//(ie values[0])
//If an object, reference by column name
//(ie values["Name"])
var value = isArray ? values[cellIndex] :
values[
this.get_columns()[cellIndex]];
var contentCell = null;
//Create or get the existing cell
if (contentRow.cells.length <=
cellIndex)
contentCell = contentRow.insertCell();
else
contentCell =
contentRow.cells[cellIndex];
if (value != null)
contentCell.innerHTML = value.toString();
else
contentCell.innerHTML = "";
}
},
Figure 4: Loading a single row of data into the table
If the
values passed in are an array, the object s type is an array, and an array
would have to be referenced by an index value (0 - X, where X is the last index
of the column, in the notation values[0]). If an object consists of name:value
pairs, the object must reference properties using the list of columns passed in
using createNew. So an object would have to be referenced as
object["ColumnName"].
In Figure
4, updateRow does all the work because it s easier to create a method that
creates/updates rows and cells in a table, rather than split the functionality.
You may have seen this method reference other methods or properties; those are
self-explanatory except for _attachToRowEvents. This method attaches to the
client-side events, using the delegate process ASP.NET AJAX created to listen
for the click, mouseover, or mouseout events of the table rows.
The second
major function of this component, outside of dynamically creating a new table,
is the process of reading a table. Reading the table is done through the static
read method, which assumes the content is generated in the same format that the
TableContentManager generates it (a header row that contains column names),
with each body row representing a row of data.
When
reading the table, the body of the content isn t read into variables. The
reasoning for this approach comes from the idea that any data within the table
is dynamically referenced (instead of storing a static reference to all the
row s values). Because the JavaScript component works with the table through
the _table variable, it has all the information it needs to extract this
information later. Columns, however, are a different story, because the columns
are used to read/validate information in the table and shouldn t change. Check
out the process for reading from a table, as shown in Figure 5.
Nucleo.Web.TableContentManager.read
=
function(targetTable) {
var columns = [];
var headerRow =
targetTable.getElementsByTagName("THEAD")
[0].rows[0];
for (var headerCellIndex = 0; headerCellIndex
<
headerRow.cells.length; headerCellIndex++)
Array.add(columns,
headerRow.cells[headerCellIndex].innerHTML);
var tableManager =
new
Nucleo.Web.TableContentManager(targetTable,
columns);
tableManager._attachToRowEvents(headerRow);
return tableManager;
}
Figure 5: Reading an existing table
This component provides the possibility for all
sorts of helper methods. For instance, it s handy to have methods that extract
information from the table. One idea is to allow users to extract information
by the name of the column, or by the column or row index. Additionally, it s
handy to have methods that can read an
entire row or entire column of data, rather than a single cell.
To make use
of the columns, ASP.NET AJAX added an indexOf method that finds a value out of
the array. Because the columns are stored in a variable and exposed through the
columns property (via get_columns), the column s index in this collection is
used, as shown in getCellValue and getCellValues in Figure 6.
getCellValue:
function(rowIndex, columnName) {
var columnIndex =
Array.indexOf(this.get_columns(),
columnName);
return this.getCellValueAt(rowIndex,
columnIndex);
},
getCellValueAt:
function(rowIndex, columnIndex) {
var body = this._getBodyElement();
return
body.rows[rowIndex].cells[columnIndex].innerHTML;
},
getCellValues:
function(columnName) {
var columnIndex =
Array.indexOf(this.get_columns(),
columnName);
return this.getCellValuesAt(columnIndex);
},
getCellValuesAt:
function(columnIndex) {
var body = this._getBodyElement();
var columnValues = [];
for (var rowIndex = 0; rowIndex <
body.rows.length;
rowIndex++)
Array.add(columnValues,
body.rows[rowIndex].cells[columnIndex].innerHTML);
return columnValues;
},
getRowValues:
function(rowIndex) {
var body = this._getBodyElement();
var row = body.rows[rowIndex];
var rowValues = [];
for (var columnIndex = 0; columnIndex <
row.cells.length;
columnIndex++) {
Array.add(rowValues,
row.cells[columnIndex].innerHTML);
}
},
Figure 6: Getting cell values
See how the
index of the column in the local collection matches the index of the column in
the table, and can be passed along to a different method to perform the actual
work? These methods make it handy to get information out of the table.
To this
point, what this article is trying to achieve is to make JavaScript coding
easier by abstracting the work (working with the TableContentManager instead of
a table HTML element) and by reducing the total number of lines of code a
developer must write. In the future, new features easily can be added by
creating methods in the TableContentManager class.
Let s see
how this abstraction benefits us by looking at a working sample. We re going to
use the web service shown in Figure 7 to stream data to the client. By using
the ScriptService attribute, this enables the web service to be used in an AJAX
application.
public
class TestService : WebService
{
[WebMethod]
public object GetResultSet()
{
return new[]
{
new
{
ID = 1,
Name = "Sports",
Description = "Covers every kind
of sport"
},
new
{
ID = 2,
Name = "Entertainment"
Description = "DVD's, CD's, TV on
DVD,
Blue Ray, etc."
}
};
}
}
Figure 7: A web service that returns data
The web
service in Figure 7 returns only a sampling of data; the actual data source
returns a little more sample data. As a side note on using the anonymous
features of .NET, the new anonymous types and anonymous collections features
make it easy to set up examples or return subsets of data by using these
features to return a customized result. Because the web service can return the
reference as an object, any type of anonymous object can be accommodated (as
long as the object can be serialized properly). A web page can use this web
service and display the results in an approach like that shown in Figure 8.
<div
id="tableOutputDisplay"></div>
<asp:Button
ID="btnRefresh" runat="server"
UseSubmitBehavior="false"
OnClientClick="update();return
false;"
Text="Refresh Table" />
<script
language="javascript" type="text/javascript">
var _tableManager = null;
function pageLoad() {
update();
}
function update() {
TestService.GetResultSet(TestServiceSucceeded,
TestServiceFailed,
$get("tableOutputDisplay"));
}
function TestServiceFailed(results, context,
method) {
alert("FAILED");
}
function TestServiceSucceeded(results,
context, method) {
if (context.childNodes.length == 0)
_tableManager =
Nucleo.Web.TableContentManager.createNew(
context, ["ID",
"Name", "Description"]);
else
_tableManager =
Nucleo.Web.TableContentManager.read(
context.childNodes[0]);
for (var index = 0; index <
results.length; index++)
_tableManager.updateRow(index,
results[index]);
_tableManager.add_rowClick(
TableContentManager_RowClick);
updateStyles();
}
</script>
Figure 8: Creating or updating a table
The web service
returns an array of objects in JSON format that can be used to generate a
table. If a table has not yet been created, the createNew method is called.
Otherwise, the read method reads the table attached to the DIV parent, passing
this reference along. Each row of the table gets refreshed with new data that
comes from the web service; the old data gets overwritten with the new data.
This process is triggered by a button click.
One item to
note: when the page posts back to the server, dynamic content is not retained
using viewstate. The page must rerender client-side content on every page load,
which can be taxing on the server. There are ways to circumvent this, but that
is beyond the scope of the article.
Styling the Table
It seems every website in the world must rely on
CSS, which is the best way to style website content to make it more appealing
to the user. Tables fit within this category. Most developers use a set of
styles for styling the table s header, footer, and content rows. The
TableContentManager publicizes a header style, item style, and footer style for
the time being. A common approach to styling content is to supply the name of a
CSS class to apply to each row. You see CSS classes heavily used in the AJAX
control toolkit, while other toolkits (my unfinished Nucleo.NET toolkit and the
AJAX Data Controls projects, available on CodePlex) use styles by converting
style content to text.
The common approaches to exposing styles are via
properties, with a getter and setter, or by passing them in to the static
factory methods. When setting style-based content, the way I ve found works
best across the recent versions of the major browsers is to set the cssText
property of the style with the CSS markup string. Setting the styles, when
creating the table dynamically, would look something like Figure 9.
var
tr = tbody.insertRow();
tr.style.cssText
= this.get_itemStyle();
Figure 9: Assigning styles to a row
You may have noticed the updateStyles method in the
sample code in Figure 8. This method establishes the row-based styles and
applies them to the table. In the TableContentManager, styles are exposed via
getters and setters, which causes a problem; the table content is generated
before the getters and setters can be applied to the table. To remedy this
requires the updateTableStyles method, which is called in updateStyles, as
illustrated in Figure 10.
//Defined
in the test page
function
updateStyles() {
if (_tableManager != null) {
_tableManager.set_headerStyle(
"color:navy;background-color:gray;
font-weight:bold;");
_tableManager.set_itemStyle("color:navy;
background-color:lightyellow;");
_tableManager.updateTableStyles();
}
}
//Defined
in TableContentManager class
updateTableStyles:
function() {
if (this.get_headerStyle() != null &&
this.get_headerStyle().length > 0) {
var header =
this.get_table().getElementsByTagName(
"THEAD")[0];
if (header != null &&
header.rows.length > 0)
header.rows[0].style.cssText =
this.get_headerStyle();
}
if (this.get_itemStyle() != null &&
this.get_itemStyle().length > 0) {
var body = this._getBodyElement();
for (var rowIndex = 0; rowIndex <
body.rows.length;
rowIndex++)
body.rows[rowIndex].style.cssText =
this.get_itemStyle();
}
if (this.get_footerStyle() != null &&
this.get_footerStyle().length > 0) {
var footer =
this.get_table().getElementsByTagName(
"TFOOT")[0];
if (footer != null &&
footer.rows.length > 0)
footer.rows[0].style.cssText =
this.get_footerStyle();
}
}
Figure 10: The updateTableStyles method
As a side note, setting certain styles at the row
level works; setting other styles may not work at the row level. I ve had
issues with certain CSS attributes at the row level, so if you try something
yourself and it doesn t work, it may not be supported by the table row.
Other Helpful Methods
DHTML provides hook-ins for JavaScript
developers, in the sense that every DOM element exposes many events that
JavaScript developers can use. ASP.NET AJAX provides a delegate-based event
handling system that is a two-step process for registering event handlers to
client events.
Events work in a multi-step process. First, event
handlers are registered using the Function.createDelegate method, the common
approach to creating event handlers in ASP.NET AJAX. In addition, AJAX
components can expose their own events by adding three methods for the event: a
method each to add, remove, and raise the event handler. The add_ and remove_
prefixes are necessary, but the method to raise the event isn t required (and
can be called elsewhere or named differently, like with an _on prefix). I
have opted to omit the raise_ method, but have used it in the past. To sum up,
take a look at Figure 11.
add_rowClick:
function(handler) {
this.get_events().addHandler("click",
handler); },
remove_rowClick:
function(handler) {
this.get_events().removeHandler("click",
handler); },
add_rowMouseOver:
function(handler) {
this.get_events().addHandler("mouseover",
handler); },
remove_rowMouseOver:
function(handler) {
this.get_events().removeHandler("mouseover",
handler); },
add_rowMouseOut:
function(handler) {
this.get_events().addHandler("mouseout",
handler); },
remove_rowMouseOut:
function(handler) {
this.get_events().removeHandler("mouseout",
handler); },
_processEvent:
function(domEvent, eventName) {
var handler =
this.get_events().getHandler(eventName);
if (handler) {
var row = domEvent.target;
if (domEvent.target.tagName ==
"TD" |
| domEvent.target.tagName == "TH")
row = domEvent.target.parentNode;
handler(this, new
Nucleo.Web.TableRowEventArgs(
row, row.rowIndex - 1,
(row.parentNode.tagName ==
"THEAD"),
(row.parentNode.tagName ==
"TFOOT")
));
}
},
_rowClickCallback:
function(domEvent) {
this._processEvent(domEvent,
"click");
},
_rowMouseOverCallback:
function(domEvent) {
this._processEvent(domEvent,
"mouseover");
},
_rowMouseOutCallback:
function(domEvent) {
this._processEvent(domEvent,
"mouseout");
},
Figure 11: Client-side events
The events property (get_events) is a special
object of type Sys.EventHandlerList that handles all events. All event handlers
are stored in this object, and are called by calling the handler returned from
the getHandler method. The signature these events expose is determined by the
event-raising method themselves, which is illustrated in the _processEvent
private method. Any event handler registered using the add method of that event
will receive this event notification. So, the final process for events is that
the table row fires the callback method, and the callback method fires the
class event, which then any event handlers get fired in the ASPX page or user
control.
Normally
the callback wouldn t call a private method; rather, the callback from clicking
the row would call the RowClicked event by firing the event handlers registered
through the event property. In this case, though, there is some complexity
which necessitates a common method. This complexity comes in the way of an
event argument.
These
events have the signature of two parameters: the object raising the event
(TableContentManager) and the event argument, similar to events in the .NET
Framework. An event is raised by getting the handlers for that event and
calling them as a delegate. The delegate has some important information, such
as the row currently clicked, that row s index, whether the row is the header
row or the footer row, and so on. All of this is stored in the custom event
argument, available with the sample source code (available for download; see
end of article for details).
But first,
it s important to understand that the DOM events fire for the row. These events
call the _processEvent method. This method determines whether the current
object is a row or a cell. If a cell, it s easy to get the row by accessing the
parentNode property. The event handler gets a call with a custom
TableRowEventArgs object, which contains the current row (in reference to the
body; index zero is the header cell, which I want the index to be used for only
body rows), and whether the current row is a child of a header or footer tag
(by checking the parentNode property and looking at the tag name).
The example
in Figure 8 also includes one more method of note: an event handler reference
for the click event, named TableContentManager_RowClick. Check out the event
handler in Figure 12.
function
TableContentManager_RowClick(sender, e) {
if (e.get_isHeader()) {
var columnValues =
_tableManager.getCellValues("Name");
var message = "";
for (var index = 0; index <
columnValues.length;
index++) {
if (index > 0)
message += ", ";
message += columnValues[index];
}
alert(message);
}
else if (e.get_isFooter()) {
}
else {
var cellValue =
_tableManager.getCellValue(e.get_index(),
"Name");
alert(cellValue);
}
}
Figure 12: Handling row clicks
When the row
is clicked, the header property is used to enter into a special case. If the
current row click is being processed for the header row, the getCellValues is
called, getting all the values for the current row (note I m not tracking the
cell that was clicked, so currently I m grabbing all the values for the Name).
However, if a body row s element is clicked, only the Name column value for the
current row is returned, using the body row s index and grabbing the name
value.
Conclusion
You don t necessarily need ASP.NET AJAX to use this
component; this component can be defined for regular JavaScript or leveraged
against other JavaScript frameworks. However, this code uses the ASP.NET AJAX
framework concepts to create abstract solutions to common problems, and simplifies
the work that must be done to perform these common tasks.
One of the keys to abstraction is simplification,
which is what this article illustrated by wrapping common functions into this
component. Another key principle is encapsulation, hiding from the developer the
work done against the table.
It would be easy to add other important features to
this component. For instance, the component could create a style for mousing
over and out of a row. It could allow for creating a column that has a row
selector, to allow the user to select rows. It also could add or remove columns
dynamically, or even rearrange the columns on the fly.
This type of component offers many possibilities. This
object may not be the most useful to your work, but the concepts discussed
herein should help you see how it applies to the applications you work on and
the specific client-side challenges you may face.
Source code accompanying this article is available
for download.
Brian Mains (bmains@hotmail.com)
is a Microsoft MVP and consultant with Computer Aid Inc., where he works with
non-profit and state government organizations.