The 4th page of the OfficeTest General Tests tutorial applies to more that just Firebird. In this tutorial we'll look at making a normal GridView work more like a spreadsheet or an editable grid. If you want to review the other tutorials, the central page can be found here Firebird and .NET 2.0 development Example. Information covered in previous tutorial pages will not be covered however this topic differs considerably from the previous pages. The 3rd tutorial (Employee: Simple) covers templated fields and manual binding which will be used here as well.
4. Editable GridView - acts more like a spreadsheet.
BillingType: EditGrid - run time view
Looking at this runtime image of the web page we can discuss how it should work:
There is 1 visable row in the grid for each row of data and a new row is added when the "Add New Row" button is pressed. Every field in the grid is editable at all times allowing you to quickly jump from field to field or row to row and just type any changes. There is a delete button for each row to make it more obvious and easier to delete a particular row. Changes made in the grid are not really made until the "Apply Changes" button is clicked. When changes are applied the deleted rows are actually deleted, edits are updated and new rows are inserted. In fact we could easily add a "Cancel Changes" button which would require almost no code.
The TAB key allows you to move quickly to the next edit field. Tabbing beyond the last edit field moves to the "Delete" button and then down to the next row. I didn't add any key event handling so you can't cursor up or down rows like in a real spreadsheet.
Below the grid is a label I use for showing a change log. The changes listed are the ones actually done when the "Apply Changes" button is pressed.
BillingType: EditGrid - design time view
The design time view looks almost exactly the same as the run time view. This is because the page is not using any nonvisual controls such as SqlDataSource. The web page itself is a very simple design. Essentially the web page has a GridView, 2 buttons and a label. The GridView has 5 templated columns containing 3 textboxes, a button and a label.
Markup Tags/Code
The tags related to the GridView gvBillingType and the templated fields is really what we want to concentrate on from the HTML first before we look at the real code.
<asp:GridView ID="gvBillingType" runat="server" AutoGenerateColumns="False" Caption="BillingType Editable Grid" OnRowCommand="gvBillingType_RowCommand" CellPadding="4" ForeColor="#333333" GridLines="None">
<Columns>
<asp:TemplateField HeaderText="ID">
<ItemTemplate>
<asp:TextBox ID="txtBillingTypeID" runat="server" Text='<%# Bind("BillingTypeID") %>' OnTextChanged="TextChanged" Width="47px"></asp:TextBox>
</ItemTemplate>
</asp:TemplateField>
<asp:TemplateField HeaderText="BillingTitle">
<ItemTemplate>
<asp:TextBox ID="txtBillingTitle" runat="server" Text='<%# Bind("BillingTitle") %>' OnTextChanged="TextChanged" Width="180px"></asp:TextBox>
</ItemTemplate>
</asp:TemplateField>
<asp:TemplateField HeaderText="BillingDesc">
<ItemTemplate>
<asp:TextBox ID="txtBillingDesc" runat="server" Text='<%# Bind("BillingDesc") %>' OnTextChanged="TextChanged" Width="250px"></asp:TextBox>
</ItemTemplate>
</asp:TemplateField>
<asp:TemplateField HeaderText="Operations">
<ItemTemplate>
<asp:Button ID="cmdDelete" runat="server" CommandArgument='<%# Eval("BillingTypeID") %>' CommandName="DeleteRow" Text="Delete" OnClick="cmdDelete_Click" />
</ItemTemplate>
</asp:TemplateField>
<asp:TemplateField HeaderText="Status">
<ItemTemplate>
<asp:Label ID="Label2" runat="server" Text='<%# Bind("Status") %>'></asp:Label>
</ItemTemplate>
</asp:TemplateField>
</Columns>
</asp:GridView>
gvBillingType primary properties:
<asp:GridView ID="gvBillingType" runat="server" AutoGenerateColumns="False" Caption="BillingType Editable Grid" OnRowCommand="gvBillingType_RowCommand" CellPadding="4" ForeColor="#333333" GridLines="None">
</asp:GridView>
ID field template:
<asp:TemplateField HeaderText="ID">
<ItemTemplate>
<asp:TextBox ID="txtBillingTypeID" runat="server" Text='<%# Bind("BillingTypeID") %>' OnTextChanged="TextChanged" Width="47px"></asp:TextBox>
</ItemTemplate>
</asp:TemplateField>
BillingTitle and BillingDesc work exactly the same so I won't go over them in detail. Just make sure to note that the OnTextChanged event handler is bound to the same TextChanged procedure for all 3 fields.
Operations field template:
<asp:TemplateField HeaderText="Operations">
<ItemTemplate>
<asp:Button ID="cmdDelete" runat="server" CommandArgument='<%# Eval("BillingTypeID") %>' CommandName="DeleteRow" Text="Delete" OnClick="cmdDelete_Click" />
</ItemTemplate>
</asp:TemplateField>
Status field template:
<asp:TemplateField HeaderText="Status">
<ItemTemplate>
<asp:Label ID="Label2" runat="server" Text='<%# Bind("Status") %>'></asp:Label>
</ItemTemplate>
</asp:TemplateField>
Page 2 of this tutorial (BillingTypeEditGrid.aspx.cs, C# Code)
This is the second page (Page 1) of the 4th tutorial. This section covers the C# coding portion of the BillingTypeEditGrid web page.
4. Editable GridView - acts more like a spreadsheet.
C# Code
We'll go over each function 1 at a time and discuss its purpose in the overall design.
Page_Load
protected void Page_Load(object sender, EventArgs e) {
if (!this.IsPostBack) {
Session["BillingTypeID"] = -1;
Populate();
}
}
BillingTypeID is used here as a local primary key allowing us to access each grid row uniquely. Its only used for newly added rows until they get inserted into the database. I keep it negative so I know its not a real primary key. This value is only required if you use the gvBillingType_RowCommand method below instead of the cmdDelete_Click method.
Populate
protected void Populate() {
DataTable billingTypes = new DataTable("BillingType");
billingTypes.Columns.Add(new DataColumn("BillingTypeID", Type.GetType("System.Int32")));
billingTypes.Columns.Add(new DataColumn("BillingTitle", Type.GetType("System.String")));
billingTypes.Columns.Add(new DataColumn("BillingDesc", Type.GetType("System.String")));
billingTypes.Columns.Add(new DataColumn("Status", Type.GetType("System.String")));
string connectionString = WebConfigurationManager.ConnectionStrings["OfficeTestFirebird"].ConnectionString;
FbConnection con = new FbConnection(connectionString);
FbCommand cmd = new FbCommand("SELECT \"BillingTypeID\",\"BillingTitle\",\"BillingDesc\" FROM \"BillingType\"", con);
try {
con.Open();
FbDataReader reader = cmd.ExecuteReader();
try {
billingTypes.BeginLoadData();
billingTypes.Load(reader);
billingTypes.EndLoadData();
} finally {
reader.Close();
}
} finally {
con.Close();
}
CompleteChanges(billingTypes);
}
A FbConnection and FbCommand are used to manually select the data from the Firebird database and then fill the DataTable. I used BeginLoadData/EndLoadData so that the DataTable doesn't think I'm adding new rows. This way the rows will not be flagged as new or modified. One advantage of using DataTable is that it works well with other dataset type objects such as FbDataReader in this case. Simply calling Load(reader) is enough code to fill the entire DataTable.
CompleteChanges
protected void CompleteChanges(DataTable billingTypes) {
gvBillingType.DataSource = billingTypes;
gvBillingType.DataBind();
SaveDataTable(billingTypes);
}
SaveDataTable
protected void SaveDataTable(DataTable table) {
Session["DataTable-BillingType"] = table;
}
GetDataTable
protected DataTable GetDataTable() {
DataTable table = (DataTable)Session["DataTable-BillingType"];
return table;
}
cmdAddNewRow_Click
protected void cmdAddNewRow_Click(object sender, EventArgs e) {
DataTable billingTypes = GetDataTable();
DataRow newRow = billingTypes.NewRow();
//newRow["BillingTypeID"] = GetNextBillingTypeID();
newRow["BillingTypeID"] = DBNull.Value;
newRow["BillingTitle"] = DBNull.Value;
newRow["BillingDesc"] = DBNull.Value;
newRow["Status"] = "Added";
billingTypes.Rows.Add(newRow);
CompleteChanges(billingTypes);
}
Notice the line that is commented out. This line should be used in conjunction with the gvBillingType_RowCommand method for deleting. It assigns a unique temporary negative primary key to the BillingTypeID. This provides an alternative way of identifying the current row.
cmdDelete_Click
protected void cmdDelete_Click(object sender, EventArgs e) {
Button button = (Button)sender;
GridViewRow gvRow = (GridViewRow)button.Parent.Parent;
int index = gvRow.RowIndex;
DataTable billingTypes = GetDataTable();
DataRow row = billingTypes.Rows[index];
row.Delete();
CompleteChanges(billingTypes);
}
We can determine the actual row by getting the grandparent of the button. The row which is a DataRow object has a RowIndex property that tells us which row from the top of the grid we are on. Keep in mind that this is relative to the actual visibly rendered grid, not the data source. If we were supporting paging or sorting the RowIndex would not necessary match the row in the data source. Since we are not using paging or sorting we can be certain the RowIndex is also the data source index (or can we?). There is actually a bug in this code. The bug only occurs if we delete a row other than the last row and then try to delete another row below it. The reason this bug occurs is because Deleted rows are not visible after they are deleted but they remain in the DataTable marked as deleted until the change is accepted (committed). This means that row 8 in the grid visually might be row 9 in the data source if a row further up was already deleted and the changes were not yet applied. We need to take this into account. See the TextChanged method which does account for this correctly. This is one of those reasons why the gvBillingType_RowCommand method for handling deletes is more consistent.
NOTE: This does not actually delete the row. It just marks it for deletion. If you cancel the changes on the DataTable the row will return. If you apply the changes the row is then deleted permanently. This is why no SQL is done at this point to delete it from the database. When the changes are applied we will delete it then and accept the changes.
cmdApplyChanges_Click1
protected void cmdApplyChanges_Click1(object sender, EventArgs e) {
ApplyChanges();
}
TextChanged
protected void TextChanged(object sender, EventArgs e) {
TextBox textBox = (TextBox)sender;
GridViewRow gvRow = (GridViewRow)textBox.Parent.Parent;
string colName = textBox.ID.Substring(3);
int index = gvRow.RowIndex;
DataTable billingTypes = GetDataTable();
int rowIndex = 0;
foreach (DataRow row in billingTypes.Rows) {
if (row.RowState != DataRowState.Deleted) {
if (index == rowIndex) {
row.BeginEdit();
if (textBox.Text == "")
row[colName] = DBNull.Value;
else
row[colName] = textBox.Text;
row["Status"] = "Modified";
row.EndEdit();
break;
}
rowIndex++;
}
}
}
This method actually handles all the textboxes so we need to determine the corresponding field from the textboxes' ID. We names the ID using the format txt so by using a SubString to remove the "txt" portion we can determine the correct field name.
Notice in this routine when we loop through the Rows looking for an RowIndex match we need to skip deleted rows. This code adjusts for the problem mentioned in the cmdDelete_Click method above. The RowIndex does not always match the DataTable index once a row has been deleted and the changes not applied.
To fix this problem we should really make a GetRow routine to use in the delete and text change handlers. Review the following changes to the existing code:
protected DataRow GetRow(GridViewRow gridRow) {
DataTable billingTypes = GetDataTable();
int rowIndex = 0;
foreach (DataRow row in billingTypes.Rows) {
if (row.RowState != DataRowState.Deleted) {
if (gridRow.RowIndex == rowIndex) {
return row;
}
rowIndex++;
}
}
return null;
}
protected void cmdDelete_Click(object sender, EventArgs e) {
Button button = (Button)sender;
GridViewRow gvRow = (GridViewRow)button.Parent.Parent;
DataRow row = GetRow(gvRow);
row.Delete();
DataTable billingTypes = GetDataTable();
CompleteChanges(billingTypes);
}
protected void TextChanged(object sender, EventArgs e) {
TextBox textBox = (TextBox)sender;
GridViewRow gvRow = (GridViewRow)textBox.Parent.Parent;
string colName = textBox.ID.Substring(3);
DataRow row = GetRow(gvRow);
row.BeginEdit();
if (textBox.Text == "")
row[colName] = DBNull.Value;
else
row[colName] = textBox.Text;
row["Status"] = "Modified";
row.EndEdit();
}
ApplyChanges
protected void ApplyChanges() {
DataTable billingTypes = GetDataTable();
string connectionString = WebConfigurationManager.ConnectionStrings["OfficeTestFirebird"].ConnectionString;
FbConnection con = new FbConnection(connectionString);
try {
con.Open();
lblChangeLog.Text = "<b>Change Log:<b></br>";
int rowIndex = 0;
while (rowIndex < billingTypes.Rows.Count) {
DataRow row = billingTypes.Rows[rowIndex];
if (row.RowState == DataRowState.Deleted) {
FbCommand cmd = new FbCommand("DELETE FROM \"BillingType\" WHERE \"BillingTypeID\" = @BillingTypeID", con);
cmd.Parameters.Clear();
cmd.Parameters.Add("@BillingTypeID", row["BillingTypeID", DataRowVersion.Original]);
cmd.ExecuteNonQuery();
lblChangeLog.Text += "Deleted Row: BillingTypeID=" + row["BillingTypeID", DataRowVersion.Original].ToString()+ "</br>";
row.AcceptChanges();
} else if (row.RowState == DataRowState.Modified) {
if ((row["BillingTypeID"] == DBNull.Value) || (row["BillingTitle"] == DBNull.Value)) {
row["Status"] = "BillingTypeID and BillingTitle cannot be NULL";
rowIndex++;
} else {
FbCommand cmd = new FbCommand("UPDATE \"BillingType\" SET \"BillingTitle\" = @BillingTitle, \"BillingDesc\" = @BillingDesc " +
"WHERE \"BillingTypeID\" = @BillingTypeID", con);
cmd.Parameters.Clear();
cmd.Parameters.Add("@BillingTypeID", row["BillingTypeID"]);
// Need to trim Char(NN) fields because they pad blank spaces automatically
cmd.Parameters.Add("@BillingTitle", row["BillingTitle"].ToString().TrimEnd());
cmd.Parameters.Add("@BillingDesc", row["BillingDesc"]);
cmd.ExecuteNonQuery();
lblChangeLog.Text += "Updated Row: BillingTypeID=" + row["BillingTypeID"].ToString() + "</br>";
row.AcceptChanges();
row["Status"] = DBNull.Value;
rowIndex++;
}
} else if (row.RowState == DataRowState.Added) {
if ((row["BillingTypeID"] == DBNull.Value) || (row["BillingTitle"] == DBNull.Value)) {
row["Status"] = "BillingTypeID and BillingTitle cannot be NULL";
rowIndex++;
} else {
FbCommand cmd = new FbCommand("INSERT INTO \"BillingType\"(\"BillingTypeID\",\"BillingTitle\",\"BillingDesc\") " +
"VALUES(@BillingTypeID,@BillingTitle,@BillingDesc)", con);
cmd.Parameters.Clear();
cmd.Parameters.Add("@BillingTypeID", row["BillingTypeID"]);
// Need to trim Char(NN) fields because they pad blank spaces automatically
cmd.Parameters.Add("@BillingTitle", row["BillingTitle"].ToString().TrimEnd());
cmd.Parameters.Add("@BillingDesc", row["BillingDesc"]);
cmd.ExecuteNonQuery();
lblChangeLog.Text += "Inserted Row: BillingTypeID=" + row["BillingTypeID"].ToString() + "</br>";
row.AcceptChanges();
row["Status"] = DBNull.Value;
rowIndex++;
}
} else
rowIndex++;
}
CompleteChanges(billingTypes);
} finally {
con.Close();
}
}
You may notice that during deletes DataRowVersion.Original version is used to get the primary key. This is required because deleted rows do not maintain a current version of the data which is the default view of the data.
Another thing to keep in mind is the loop index. Notice the loop is incremented only if we update or insert a row. The obvious reason for this is that deleted row are removed from the DataTable when row.AcceptChanges() is called which means our current rowIndex becomes the next row automatically. You should also remember that newly added rows that are deleted before the changes are applied are deleted instantly because they never really got added.
RowCommand: Alternative delete method
GetNextBillingTypeID
protected int GetNextBillingTypeID() {
// This was added to work with GetRow and gvBillingType_RowCommand for deleting
int BillingTypeID = (int)Session["BillingTypeID"];
BillingTypeID--;
Session["BillingTypeID"] = BillingTypeID;
return BillingTypeID;
}
Another way to handle this would be to add a separate column in the DataTable and in the grid (it could be hidden in the grid). This column would just be there to provide a unique id separate from the BillingTypeID. The column value could be generated using a GUID or a hash value. Alternatively since we are using Firebird in this example I could simply ask the database for a Generator value and use that instead. In fact the generated value could be the final primary key not just a temporary one.
GetRow
protected DataRow GetRow(DataTable billingTypes, string value) {
foreach (DataRow row in billingTypes.Rows) {
if (row.RowState == DataRowState.Deleted) {
if (row["BillingTypeID", DataRowVersion.Original].ToString() == value)
return row;
} else {
if (row["BillingTypeID"].ToString() == value)
return row;
}
}
return null;
}
gvBillingType_RowCommand
protected void gvBillingType_RowCommand(object sender, GridViewCommandEventArgs e) {
if (e.CommandName != "") {
DataTable billingTypes = GetDataTable();
DataRow row = GetRow(billingTypes, e.CommandArgument.ToString());
string rowHash = e.CommandArgument.ToString();
if (row != null) {
if (e.CommandName == "DeleteRow") {
row.Delete();
CompleteChanges(billingTypes);
}
}
}
}
<asp:Button ID="cmdDelete" runat="server" CommandArgument='<%# Eval("BillingTypeID") %>'
CommandName="DeleteRow" Text="Delete" OnClick="cmdDelete_Click" />
This is a very simple way to make an editable GridView. Obviously things like error detection and quick navigation of cells using the cursor keys is missing.
If anyone is interested in a more generic version of this code that uses the RowCommand method only I could whip something up and post it.
see next tutorial...Employee: Stored Procedure/Trigger web page which performs inserts that :1. allow a trigger to generate the primary key and 2. use a stored procedure.