Introduction
The aim of this article is to show an example of how to build a software component according to best practices that is customizable through code generation techniques.
The example reported in this article shows a Windows Form bound to a DataTable
with several additional common features like record navigation control, access control management, and filter capability.
The purpose is to include all the features needed in one pre-tested control and make a set of simple customization rules that can be implemented by an automatic process.
First Step: Requirements
What are the features I need for my window?
- View only and Edit mode management.
- In Edit mode, the users can add, modify, and delete records with a changes confirmation ability.
- User must be able to undo the changes made.
- Retrieve from and store to database.
- The control of navigation across records.
- View, edit, and apply filter rules on data retrieved.
- Support for changing the data source programmatically.
Second Step: Prototyping
The logic is all encapsulated in the base class. Customization is made by overriding some hook methods. The base class form notifies inheritors by raising events. There are events to notify navigation changes, view mode changes, data changes, and the filtering result.
The form has two display modes: "View only mode" where edit operations are not permitted but the user can navigate across records and edit and apply filters, and "Edit mode" where the user can change the records by adding, deleting, and modifying rows.
The user can input filters like a Microsoft Access form. The controls bound to the data source become text boxes where the user can put filter rules for the data fields associated to them. For this reason, the window has a ViewMode
property which can be set to one of these values: ViewMode.Data
, ViewMode.Filter
, or ViewMode.Constraint
. In ViewMode.Data
, the controls are bound to the data source, and in ViewModeFilter
and ViewModeConstraint
, the controls are bound to a temporary table made to accept string values which are the filter or constraint rules to apply. The constraint mode has not been implemented yet. The purpose is to let the user input constraints like the filter constraints but that are passed to the business like "where" constraints.
Then, the architecture will be:
According to the MVC pattern, I've created the model with a DataSet
(MainDataSet
) which contains three DataTable
s:
DataSource
: it contains the data retrieved from the database which are the target of the binding,FilterSource
: a temporary table which contains the filter rules,ConstraintSource
: a temporary table which contains the rules passed to the where clause.
The form can access data of each table through the DisplaySource
object. The DisplaySource
is only a link which points to one of the three sources. The DataSource
is accessed through a DataView
to use a filter and access policy features. The SetViewMode()
method controls this behavior.
What does the base class need to know about customization?
The base form must know the data structure and which control is bound to. Inheritors can do this by overriding some methods as BindToData()
. The form base calls this method when ViewMode.Data
is set. When data is displayed in the controls, they have to be readonly if WinMode
is ViewOnly
, or have to be editable when WinMode
is Edit
. To manage this behavior, the base class calls the method protected overridable SetReadonly(boolean)
that inheritors can control.
All bindings are internally managed by the native binding features of the .NET framework.
The properties Rowstate
and Hasversion
are used to track record changes.
The base form also raises the event OnCurrent
when the displayed record changes, so inheritors can execute additional tasks like displaying additional information related to the current record.
Data is retrieved from and stored to a database by calling the RetriveData()
and CommitData()
base methods.
Third Step: Customization
The example project is shipped with a jet database that contains the "Project" table. This table is the data source of the customized form. It consists of five fields with some constraints to be near to real-world needs.
ProjID
: Primary key, not nullable, autonumberProjName
: not nullable, stringProjDesc
: nullable, stringStartDate
: nullable, dateProgress
: nullable, numeric
The methods that access the database and that describe the data structure are defined in the test for convenience. (It would be better if these methods were defined in a business class.) These business methods are defined in the "business functions" region.
Writing the customization: At first, we need to customize the method SetTableDefinition()
which is called when the form is initializing. Then, we have to make the binding:
Protected Overrides Sub SetTableDefinition()
Me.DataSource = GetTableSchema()
End Sub
Private Function GetTableSchema() As DataTable
Dim t As New DataTable("Projects")
Dim dc As DataColumn
dc = New DataColumn("ProjID", GetType(Int32))
dc.AutoIncrement = True
dc.AllowDBNull = False
Me.DataSource.Columns.Add(dc)
dc = New DataColumn("ProjName", GetType(String))
dc.AllowDBNull = False
Me.DataSource.Columns.Add(dc)
dc = New DataColumn("ProjDesc", GetType(String))
Me.DataSource.Columns.Add(dc)
dc = New DataColumn("StartDate", GetType(Date))
Me.DataSource.Columns.Add(dc)
dc = New DataColumn("Progress", GetType(Int32))
Me.DataSource.Columns.Add(dc)
Return t
End Function
Protected Overrides Sub BindTo_Data()
Me.BindControl(Me.txtProjID, "Text", Me.DisplaySource, "ProjID")
Me.BindControl(Me.txtProjName, "Text", Me.DisplaySource, "ProjName")
Me.BindControl(Me.txtProjDesc, "Text", Me.DisplaySource, "ProjDesc")
Me.BindControl(Me.dtpStartDate, "Value", Me.DisplaySource, "StartDate")
Me.BindControl(Me.txtProgress, "Text", Me.DisplaySource, "Progress")
End Sub
The user input is controlled by calling the SetReadOnly(boolean)
method. It is called with a true value when WindowMode
is changed to ViewOnlyMode
and with a false value when it is changed to EditMode
.
Protected Overrides Sub SetReadonly(ByVal Value As Boolean)
If Value Then
Me.txtProjID.ReadOnly = True
Me.txtProjName.ReadOnly = True
Me.txtProjDesc.ReadOnly = True
Me.dtpStartDate.Enabled = False
Me.txtProgress.ReadOnly = True
Else
If Me.ViewMode = ViewModeEnum.ViewFilters Then
Me.txtProjID.ReadOnly = False
Else
Me.txtProjID.ReadOnly = True
End If
Me.txtProjName.ReadOnly = False
Me.txtProjDesc.ReadOnly = False
Me.dtpStartDate.Enabled = True
Me.txtProgress.ReadOnly = False
End If
End Sub
During test form initialization, a record navigation control is added, the database connection is opened, and a DataAdapter
is created to be used to retrieve and store data:
Private Function GetAllProject() As DataTable
Dim t As DataTable
t = New DataTable("Projects")
Me.ad.Fill(t)
Return t
End Function
Private Sub SaveProjects(ByVal table As System.Data.DataTable)
ad.Update(table)
End Sub
During loading, the method RetriveDisplayData()
is called and the data-source is set to the table retrieved.
Private Sub BindingBest_Load(ByVal sender As Object, _
ByVal e As System.EventArgs) Handles MyBase.Load
newDtTb = RetriveDisplayData("")
Me.DataSource = newDtTb
End Sub
The handler BindingBest_Current()
shows how we can use the current event to change the view according to the value of the current record displayed. The cmdTest_Click()
handler shows how to programmatically change the values in the current record.
Last Step: Make a Code Generator
This step will be discussed in the second part of this article.
Other Points of Interest
Record changes are managed by the native binding features of the .NET framework. According to Microsoft best practices, all changes have to be made through the CurrencyManager
. While the user is modifying a record, the associated row has a "proposed version" that will be confirmed by calling currencymanager.EndCurrentEdit
, or rolled back by calling CancelCurrentEdit
(see the FriendSave()
and FriendRestore()
methods). Then, the "proposed" version becomes the "current" and the row state has changed. Then, while the records are modified, the row state tracks the history of the changes made. The rows can be in the state added, deleted, or modified so the data adapter can know when to send an insert or an update or a delete statement. After committing the work, the row state is reset.
Public Sub FriendSave(Optional ByVal DisplayWarning As Boolean = False)
Dim cm As CurrencyManager = Me.BindingContext(Me.DisplaySource)
Me.Cursor.Current = System.Windows.Forms.Cursors.WaitCursor
If cm.Count > 0 Then
Dim r As DataRowView = cm.Current
Dim mret As DialogResult
If DisplayWarning Then
mret = MessageBox.Show("Do you want to save changes ?", _
"Confirmation", MessageBoxButtons.YesNoCancel, _
MessageBoxIcon.Question)
End If
If mret = DialogResult.Cancel Then
Throw New cancelException("User cancel error.")
End If
If Not DisplayWarning Or mret = DialogResult.Yes Then
Try
cm.EndCurrentEdit()
If Me.p_TransactionMode = TransactionModeEnum.Row Then
Me.CommitSave(Me.DisplaySource.Table)
End If
Catch ex As Exception
MessageBox.Show(ex.Message, "Error", _
MessageBoxButtons.OK, MessageBoxIcon.Error)
End Try
Else
cm.CancelCurrentEdit()
End If
cm.Refresh()
End If
Me.Cursor.Current = System.Windows.Forms.Cursors.Default
Me.dbgp.Text = GetRowState()
End Sub
Public Sub FriendRestore(Optional ByVal DisplayWarning As Boolean = False)
Dim cm As CurrencyManager = Me.BindingContext(Me.DisplaySource)
If cm.Count > 0 Then
Dim r As DataRowView = cm.Current
Dim accept As Boolean
accept = True
If DisplayWarning Then
If MessageBox.Show("Do you want to discard changes ?", _
"Confirmation", MessageBoxButtons.OKCancel) = DialogResult.Cancel Then
accept = False
End If
End If
If accept Then
cm.CancelCurrentEdit()
End If
End If
Me.dbgp.Text = GetRowState()
End Sub
Enhancements
There are several ways to make a software component ready to be customized by automatic processes. I think that the better .NET way is to make use of Attributes. I hope readers will contribute...