Introduction
The DataBinder.Eval method is
a very handy way to achieve data binding in Web Forms
applications. Because it uses reflection, it is totally
generic in the types of data source that it can handle.
Of course this versatility comes at a price. The
performance implications of reflection are often
remarked upon. However even more important to my mind is
the inability to handle situations where the data item
doesn't exist or to gain access to ancillary information
such as validation error messages.
If we are willing to restrict
ourselves to the use of DataSets as our data source, we
can enhance the functionality considerably allowing us
to produce rich UIs for handling validation errors such
as the example below.
In this article, I'm assuming
you have at least played with the Web Forms designer in
Visual Studio and are familiar with Web Forms binding
expressions.
Non existence of Data
It is not uncommon in a UI to
have a situation where the data being bound-to does not
exist. One example would be the picking of a customer
for an invoice where the system might automatically fill
in various controls with the address of that customer.
Before the customer has been picked, there would be no
data for these controls to be bound to. Unfortunately
the standard DataBinder.Eval function will generate an
exception in such a situation.
There is a slightly more contrived example in the screen
grab below where we are entering a set of Staff
Information records. Initially there are no records so
we would expect all the data entry controls to be either
read-only or disabled.
Take for example the "Date Of Birth" TextBox. It has a
Binding that ensures ReadOnly is set whenever no row
exists for the data that it intends to display. This is
done with a call to DataSetBinder.RowExists.
<asp:textbox id=TextBox2
style="Z-INDEX: 106; LEFT: 144px; POSITION: absolute;
TOP: 160px"
runat="server"
Text='<%# DataSetBinder.Eval(WebForm1_Staff1,"Staff.DateOfBirth","{0:d}")
%>'
ReadOnly='<%# Not
DataSetBinder.RowExists(WebForm1_Staff1,"Staff") %>'
ToolTip='<%# DataSetBinder.GetError(WebForm1_Staff1,"Staff.DateOfBirth")
%>'
CssClass='<%#
IIf(DataSetBinder.HasError(WebForm1_Staff1,"Staff.DateOfBirth"),"error","")%>'>
</asp:textbox>
The binding of the Text property using the
DataSetBinder.Eval method is very similar to what would
be done with DataBinder with even the paramaters being
the same. However DataSetBinder.Eval return the
DefaultValue for any data that doesn't exist instead of
throwing an exception. It obtains this from the DataSet
which in turn derives it from the dataset schema. (The
Binding of ToolTip and CssClass is used for validation
which is discussed below.)
The first parameter to Eval is
the data source from which to start, normally the
DataSet, although it could be "Container" if the control
is inside a template. The second parameter uses a syntax
similar to the way data is addressed in a Windows Forms
Binding. This parameter normally starts with a table
name followed by a series of child relationship names if
any and followed finally by the column name. However in
the case where the control is inside a template, it
starts as you might expect with "DataItem".
Binding Expression for a Top Level Control:
Eval(DataSet,"{TableName} [.{RelationName}] .{ColumnName}")
Binding Expression for a
Control inside a template:
Eval(Container,"DataItem [.{RelationName}] .{ColumnName}")
The parameters for RowExists
are exactly the same as Eval above. However the final .{ColumnName}
is omitted from the second parameter, because the whole
row is being specified rather than a column within it.
As well as making data entry
control read-only or disabled, buttons should be
disabled in cases where they require a row of data to
exist. For example the Add button for a master Staff
Information record should obviously be enabled always,
whereas the Add button for a Training course detail
should not be enabled unless there is a master record to
attach it to. As you might expect this is handled by
binding the Enabled property using another call to
DataSetBinder.RowExists.
<asp:button id=Button10
style="Z-INDEX: 111; LEFT: 680px; POSITION: absolute;
TOP: 272px"
runat="server"
Enabled='<%#
DataSetBinder.RowExists(WebForm1_Staff1,"Staff")%>'
CommandName="Add" Text="Add">
</asp:button>
When the user presses the Add
button to add the first Staff Information record, you
can see how the various controls update their Enabled or
ReadOnly states in the screen grab below.
Validation
In real world applications, validation can
often be quite complex. It may involve checking
elaborate relationships between the data entered or
looking up additional data not available on the client.
For this reason, while it's worth doing some simple
sanity checking on the client, I'm a big fan of doing
the bulk of the validation when the attempt is made to
save the users data to the data tier. ADO.NET has a
mechanism for recording errors against specific columns
in specific rows, so this is the obvious way to return
validation to the client.
Below is a sample piece of validation for an instance of
Staff Information. It does some of the usual things like
checking for required data. However it also checks for
overlaps in the dates of courses- an example of the more
complex validation that is often neccessary.
' Return True if the two Date Ranges overlap
Private
Function
RangesOverlap(ByVal
s1 As
Date,
ByVal e1
As
Date,
ByVal s2
As
Date,
ByVal e2
As
Date)
If
Date.Compare(s1,
e2) <= 0 And
Date.Compare(e1,
s2) >= 0 Then
Return True
End If
Return
False
End
Function
' This
function is an example of the sort of constraints that
might be applied
' to data prior to it being commited to the database
Private
Function
ValidateData() As
System.Boolean
Dim dtStaff
As DataTable =
Me.WebForm1_Staff1.Tables("Staff")
Dim dvStaff
As DataView =
dtStaff.DefaultView()
For
Each drv
As DataRowView
In dvStaff
drv.Row.ClearErrors()
If
TypeOf drv("Name")
Is DBNull
Then
drv.Row.SetColumnError("Name", "Name is
Required")
End
If
If TypeOf
drv("DateOfBirth") Is
DBNull Then
drv.Row.SetColumnError("DateOfBirth", "Date Of Birth is
Required")
ElseIf (System.DateTime.Today().Subtract(CType(drv("DateOfBirth"),
System.DateTime)).Days / 365 <= 15)
Then
drv.Row.SetColumnError("DateOfBirth", "Staff must
be over 15 in age")
End
If
Dim dvTrainingCoursesTaken
As DataView =drv.CreateChildView("Staff_TrainingCoursesTaken")
Dim
startDate(dvTrainingCoursesTaken.Count)
As
Date
Dim endDate(dvTrainingCoursesTaken.Count)
As
Date
Dim row(dvTrainingCoursesTaken.Count)
As
System.Data.DataRow
Dim i
As Int16 = 0
For
Each drv2
As DataRowView
In
dvTrainingCoursesTaken
Dim HasStartDate,
HasEndDate As
System.Boolean
If
TypeOf drv2("CourseName")
Is DBNull
Then
drv2.Row.SetColumnError("CourseName", "Course
Name is Required")
End
If
If TypeOf
drv2("StartDate") Is
DBNull Then
drv2.Row.SetColumnError("StartDate", "Start Date
is Required")
Else
startDate(i) =
CType(drv2("StartDate"),
DateTime).Date
HasStartDate =
True
End
If
If TypeOf
drv2("EndDate") Is
DBNull Then
drv2.Row.SetColumnError("EndDate", "End Date is
Required")
Else
endDate(i) =
CType(drv2("EndDate"),
DateTime).Date
HasEndDate =
True
End
If
If (HasStartDate
And HasEndDate)
Then
row(i) = drv2.Row
End
If
i = i + 1
Next
For i = 0
To
dvTrainingCoursesTaken.Count
For j
As Int16 = i + 1
To
dvTrainingCoursesTaken.Count
If
Not row(i)
Is
Nothing
And
Not row(j)
Is
Nothing
And
RangesOverlap(startDate(i), endDate(i), startDate(j),
endDate(j))
Then
row(i).SetColumnError("StartDate", "Course Dates cannot
overlap")
row(i).SetColumnError("EndDate", "Course Dates cannot
overlap")
row(j).SetColumnError("StartDate", "Course Dates cannot
overlap")
row(j).SetColumnError("EndDate", "Course Dates cannot
overlap")
End
If
Next
Next
Next
Return
Me.WebForm1_Staff1.HasErrors
End
Function
It is very important for a
good client experience to return all validation errors
together. However it is not enough to return all the
errors in one big bunch for the user to wade through.
For ease of use it is important to associate the error
message with the control containing the data in error.
In complex UIs displaying error messages against each
control can be messy and confusing. One obvious solution
to this is the highlight the control in error and show a
tool tip with the error message when the user moves the
mouse across the control.
This can be seen in the screen grab below with the
"Course Dates cannot overlap" error message. (By the
way, in case there's any confusion when looking at this,
my local date format is dd/mm/yyyy rather than month
first.)
To achieve this result, first
we need to bind the ToolTip property for each control to
an expression involving the GetError method. This method
has the same parameters as the Eval method, but it
returns the associated error message rather than the
data. It can be seen in two forms below. Firstly we have
an example of the top-level "Date Of Birth" TextBox
where the first parameter to DataSetBinder.GetError is
the DataSet and the second parameter is a dot delimited
path that starts with the TableName and finishes with
ColumnName. (In this case we are specifying a column in
the master table so no interveening relationship names
are required.)
<asp:textbox id=TextBox2
style="Z-INDEX: 106; LEFT: 144px; POSITION: absolute;
TOP: 160px"
runat="server"
Text='<%# DataSetBinder.Eval(WebForm1_Staff1,"Staff.DateOfBirth","{0:d}")
%>'
ReadOnly='<%# Not
DataSetBinder.RowExists(WebForm1_Staff1,"Staff") %>'
ToolTip='<%# DataSetBinder.GetError(WebForm1_Staff1,"Staff.DateOfBirth")
%>'
CssClass='<%#
IIf(DataSetBinder.HasError(WebForm1_Staff1,"Staff.DateOfBirth"),"error","")%>'>
</asp:textbox>
Secondly, here's an example of
the "Course Start Date" which is included in a template.
Here (just like Eval), the first parameter is Container
and the second parameter starts with "DataItem." and
finishes with the ColumnName. (Again we don't need
interveening relationship names in this particular
example.)
<asp:TextBox id=TextBox5 runat="server"
Width="130px"
Text='<%#
DataSetBinder.Eval(Container,"DataItem.StartDate","{0:d}")
%>'
ToolTip='<%#
DataSetBinder.GetError(Container,"DataItem.StartDate")
%>'
CssClass='<%#
IIf(DataSetBinder.HasError(Container,"DataItem.StartDate"),"error","")%>'>
</asp:textbox>
(As an aside notice that the
ReadOnly property was not bound for this TextBox. This
is because the row will always exist in this case -
otherwise the line would not be generated by the
DataList.)
Of course the user won't know which controls have an
error message tool tip unless we provide visual
feedback. This is done by binding the CssClass property
to an expression involving the HasError method. As we
flag a validation error by setting the style of the
apppropriate control to "error" style, appropriate
attributes must be set up for this style in our
stylesheet, for example:
.error {
background-color:#FFC080; border:1px solid }
Overview of DataSetBinder
To achieve all the functionality described
above, DataSetBinder only needs a handfull of functions
as shown below.
'
Return the value of the specified item or the
appropriate default if this item doesn't exist
Public
Shared
Function Eval(ByVal
ds As
System.Object, ByVal
dataMember As
System.String)
As
System.Object
'
Return a formatted value of the specified item or the
appropriate default
' if this item doesn't exist
Public
Shared
Function Eval(ByVal
ds As
System.Object, ByVal
dataMember As
System.String,ByVal
format As
System.String) As
System.Object
'
Return True if the specified Row exists
Public
Shared
Function
RowExists(ByVal ds
As System.Object,
ByVal dataMember
As System.String)
As
System.Boolean
'
Return True if the specified item has an error
associated with it
Public
Shared
Function HasError(ByVal
ds As
System.Object, ByVal
dataMember As
System.String)
As
System.Boolean
'
Return the Error Message associated with an item if any
Public
Shared
Function GetError(ByVal
ds As
System.Object, ByVal
dataMember As
System.String)
As
System.String
'
Return True if the specified row has any errors
associated with it
Public
Shared
Function
RowHasErrors(ByVal
ds As
System.Object,
ByVal
dataMember As
System.String) As
System.Boolean
Just like DataBinder, it has two versions of Eval- one
for unformatted and one for formatted data. As I
mentioned these versions don't employ reflection and
return default values for situations where data does not
exist.
The RowExists method is handy in binding expressions for
ReadOnly or Enabled to prevent data input or
inappropriate button clicking in situations where a row
does not exist to operate on.
Rich feedback on validation failures can be achieved by
binding CssStyle and ToolTip using the HasError and
GetError methods respectively.
Finally I have included a
RowHasErrors method that is not illustated in the
example but could be used to flag the lines in a
DataList that have validation failures in situations
where not all the data is displayed in the line.
I could describe the implementation of these functions
in detail but it probably simplest if you download the
source and look at it directly. There is less than 150
lines including comments. What a nice surprise it is to
achieve so much with so little.
Points of Interest
A demonstration project for the full Staff
Information example used in this article. To keep things
simple, this example simulates a data tier in an
XmlDocument where in reality Microsoft SQLServer or
something similar would be employed. It buffers all user
input in the ViewState to prevent potentially costly
uneccessary trips to the database. All the user input is
validated and committed in one go when the Save button
is pressed.
If you want to go straight through to using
DataSetBinder, you only have to download the source for
it and include it in your own project. You are granted
unrestricted rights to use this code however you want.
The code released with this article is based on a
portion of the AgileStudio product, which extends Visual
Studio. Interestingly it took me half an hour to put
together the Staff Information example in AgileStudio
and a day and a half to add all the extra code to make
it standalone.
Check out the free evaluation at
www.sekos.com which
automatically maintains the bindings, datasets and SQL
StoreProcs required for a specific user interface (for
Windows or Web applications).
Conclusion
If you would like to see a
version of DataSetBinder for CSharp, please let me know.
I would also like to further explore UIs for robust data
validation in a Web Forms environmen