Re-Introduction
In the first iteration of this article, I demonstrated how to use HTML tables on the client for a very simple client-side paging solution. I have heard from several people who point out the performance problems with large sets of data. I agree. This solution is best for a fairly fixed amount of data. It's suited for a commercial application generally having between eight and twelve pages with about five "rows" or embedded tables per page.
Still the only performance hit is on page load. The client-side processing is very fast, even on slower machines. On a P-III 700 Mhz with 256 MB RAM, it responds about as well as it does on my 1.8 Ghz P-4 with 512 MB.
The ASP.NET DataGrid
, and third party, commercial extensions thereof, make developers� lives easier in many ways. But the DataGrid
is a crutch, and, while it may allow you to walk, it hinders you from running. Sometimes, a project arises with technical and user requirements that make the DataGrid
both too heavy and too limited. It was just such a project that forced me to leave, temporarily, the drag-and-drop development world for some code that would meet all of my client�s needs.
Background
My recent project for a well-known company required displaying a fairly large number of similar datasets involving marketing plans. Basically, users needed to see a list of their plans with buttons to take them to an edit page, buttons to expose context help inline with the form, and several rows of data for each plan. Moreover, the client demanded that postbacks be kept to a minimum, and expressly rejected my original plan posting back to change pages and to change the dropdownlist content based on other dropdownlist selections.
I solved the latter problem by extending Al Alberto�s Master Detail DDL project, which I found here on Code Project. But the paging remained a problem. I tried various techniques with DataGrid
s and Repeater
s, but all were too complex, requiring hundreds or more lines of code than I knew the solution really needed. Among those that provided some excellent insight into paging was Andrew Merlino�s article and code.
I realized that HTML tables can work just like div
layers. By setting a table�s style=�display:none�
property, I can hide the table on the client. All I needed to do was to create one table for each page. These tables contained a header row with a single cell, and a content row with a single cell. Into the content cell, I can inject anything�in the case of my demo, a series of tables containing data and formatting information. All that remained was to create some dynamic JavaScript to handle the page change requests on the client.
My original article was stark. In this iteration, I've concentrated on a few of the user features that make this approach desirable.
First, I switched databases from Northwind to pubs. This allows me to use richer data with more detail. To make the application more portable, I added the connection string to the web.config file.
Next, I've added progress bars for each "row" which roll up to an overall progress roll on the main page. Additionally, I've added an update field to allow users to set sales targets.
Finally, to make updates faster, I've added a psuedo-spin control to edit mode that moves the edit window one row up or down. When you reach the end of a page, it automatically takes you to the first row of the next page or the last row of the previous page.
Using the code
My demonstration, while admittedly sparse, consists of a single aspx page with its code-behind, containing fewer than 250 lines of code. The steps are as simple as the solution itself:
- Get a
DataSet
containing a single DataTable
.
- Determine the number of pages I need based on a user-definable Page Size and the number of rows in my table.
- Create a Container table for each page.
- Create a child table for each row of data.
- Add the child table to the Container table.
- Add the Container tables to the page, in this case, by adding it to the
Controls
element of a table cell set to runat=�server�
.
- Add the JavaScript to the page.
For this demo, I used the Northwind database in SQL Server. You can easily change the connection string/query string to use any SQL Server database you desire.
private void BuildTables ()
{
int NumItems = ds.Tables[0].Rows.Count;
int PageSize = Int32.Parse(txtPageSize.Text);
long Pages = (NumItems / PageSize);
long WholePages = NumItems / PageSize;
int Leftover = NumItems % PageSize;
if (Leftover > 0)
{
Pages += 1;
}
int StartOfPage = 0;
int EndOfPage = PageSize -1;
this.lblPages.Text = Pages.ToString() + " Pages";
this.lblRecords.Text = NumItems.ToString() + "Records";
for(intp=1;p<=Pages;p++)
{
HtmlTable tblPage = new HtmlTable();
HtmlTableRow trow = new HtmlTableRow();
HtmlTableRow hrow = new HtmlTableRow();
HtmlTableCell hcell = new HtmlTableCell();
hcell.InnerHtml = "<b>Page " +p.ToString()+"</b>";
hrow.Cells.Add(hcell);
tblPage.Rows.Add(hrow);
HtmlTableCell tcell = new HtmlTableCell();
tblPage.ID = "Page"+p;
if(p==1)
tblPage.Style.Add("Display","block");
else
tblPage.Style.Add("Display","none");
for(int i = StartOfPage;i<=EndOfPage;i++)
{
if(i < ds.Tables[0].Rows.Count)
{
tcell.Controls.Add(FillPages(ds.Tables[0].Rows[i],i,p));
tcell.Controls.Add(BuildEditTable(ds.Tables[0].Rows[i],i,p));
}
}
trow.Cells.Add(tcell);
tblPage.Rows.Add(trow);
HtmlTableCell tdPages = (HtmlTableCell)FindControl("tdPages");
tdPages.Controls.Add(tblPage);
StartOfPage = EndOfPage+1;
EndOfPage = EndOfPage+PageSize;
}
OverallProgress = (OverallProgress/NumItems);
this.hdnOverallPercent.Value=OverallProgress.ToString()+"%";
this.ovl.InnerHtml = OverallProgress.ToString()+"%";
this.RenderScript(Convert.ToInt32(Pages), Convert.ToInt32(NumItems));
}
The method above calls the FillPages (int Record)
method iteratively, passing the current row value and receiving a table in return. There are other, more elegant ways of determining how many rows are in the DataSet
table, but, again, the goal of this project is simplicity.
New to this iteration is the logic for determining progress and displaying a portion of a GIF image proportionate to the progress. Again, the principle is very simple: the image is 100px. I determine the percent completed (actual/target) and set the image width to equal the percent completed. If the Actual is 30 and the Target is 100, the image's width is 30px, or 30% of the complete picture.
I also keep a running total of the percentage for the overall progress. To keep things simple and on the client, I use a hidden HTML field (HtmlInputHidden
) to store the running total. After building the pages, I'll divide the number of records by the sum of PercentComplete
for the overall progress.
private HtmlTable FillPages(DataRow Record, int tableNumber, int pageNumber)
{
HtmlTable tblNew = new HtmlTable();
HtmlTableRow r1 = new HtmlTableRow();
HtmlTableRow r2 = new HtmlTableRow();
HtmlTableCell c1 = new HtmlTableCell();
HtmlTableCell c2 = new HtmlTableCell();
HtmlTableCell c3 = new HtmlTableCell();
HtmlTableCell c4 = new HtmlTableCell();
HtmlInputButton b1 = new HtmlInputButton();
b1.Value="Edit";
b1.ID="b"+tableNumber.ToString();
b1.Attributes.Add("onclick","doEdit('"+b1.ID+"')");
c4.Controls.Add(b1);
tblNew.Style.Add("DISPLAY","block");
tblNew.ID="#vw"+tableNumber.ToString();
tblNew.Attributes.Add("Page",pageNumber.ToString());
tblNew.Border=1;
tblNew.CellSpacing=0;
tblNew.CellPadding=3;
tblNew.Width = "520px";
c1.BgColor="silver";
c1.Style.Add("FONT-COLOR","WHITE");
c2.Width="80%";
c3.Width = "20%";
int intProgress =
Convert.ToInt32(Double.Parse(Record[4].ToString())/
Double.Parse(Record[5].ToString())*100);
if(intProgress>100)
intProgress=100;
this.OverallProgress += intProgress;
c2.InnerHtml = "Title: "+Record[1].ToString() +
"<br>Sales: $" +Record[4] + " Target: $" + Record[5];
c1.InnerHtml = "<b>" + Record[0].ToString() +
"</b> Progress" +
" <span id='Label7' " +
"class='TaskProgress' ></span>" +
" "+intProgress.ToString()+ "%" + b1;
c3.InnerHtml = "Category: " +Record[2].ToString();
r1.Cells.Add(c1);
r1.Cells.Add(c4);
r2.Cells.Add(c2);
r2.Cells.Add(c3);
tblNew.Rows.Add(r1);
tblNew.Rows.Add(r2);
return tblNew;
}
Now, we're going to build the Edit table. Because I am using the Pubs database, I do not store any changes made on the page to the database, so please don't expect your changes to persist.
private HtmlTable BuildEditTable(DataRow Record, int tableNumber, int pageNumber)
{
HtmlTable tblNew = new HtmlTable();
HtmlTableRow r1 = new HtmlTableRow();
HtmlTableRow r2 = new HtmlTableRow();
HtmlTableCell c1 = new HtmlTableCell();
HtmlTableCell c2 = new HtmlTableCell();
HtmlTableCell c3 = new HtmlTableCell();
HtmlTableCell c4 = new HtmlTableCell();
HtmlInputText txtAuthor = new HtmlInputText();
HtmlInputText txtTitle = new HtmlInputText();
HtmlInputText txtCategory = new HtmlInputText();
HtmlInputText txtTarget = new HtmlInputText();
HtmlInputButton bSave = new HtmlInputButton();
tblNew.Attributes.Add("Page",pageNumber.ToString());
bSave.ID = "bSave"+tableNumber;
HtmlInputButton bPrev = new HtmlInputButton();
HtmlInputButton bNext = new HtmlInputButton();
int iprev = tableNumber-1;
int inext = tableNumber+1;
bPrev.ID = "bPrev"+iprev;
bNext.ID = "bNext"+inext;
bPrev.Value = "^ Prev";
bNext.Value = "Next v";
bPrev.Attributes.Add("onclick",
"doEdit('b"+ iprev+"','"+pageNumber+"')");
bNext.Attributes.Add("onclick",
"doEdit('b"+inext +"','"+pageNumber+"')");
txtTarget.Size=10;
tblNew.Style.Add("DISPLAY","none");
tblNew.ID="#edit"+tableNumber.ToString();
tblNew.Border=1;
tblNew.CellSpacing=0;
tblNew.CellPadding=3;
tblNew.Width = "520px";
c1.BgColor="silver";
c1.Style.Add("FONT-COLOR","WHITE");
c2.Width="80%";
c3.Width = "20%";
int intProgress =
Convert.ToInt32(Double.Parse(Record[4].ToString())/
Double.Parse(Record[5].ToString())*100);
if(intProgress>100)
intProgress=100;
this.OverallProgress += intProgress;
txtTarget.Value = Record[5].ToString();
c2.InnerHtml = "Title: "+Record[1].ToString() + "<br>Sales: $" +Record[4] ;
c1.InnerHtml = Record[0].ToString();
c3.Controls.Add(bPrev);
c3.Controls.Add(bNext);
Literal l = new Literal();
l.Text = "New Target:";
c4.Controls.Add(l);
c4.Controls.Add(txtTarget);
r1.Cells.Add(c1);
r1.Cells.Add(c4);
r2.Cells.Add(c2);
r2.Cells.Add(c3);
tblNew.Rows.Add(r1);
tblNew.Rows.Add(r2);
return tblNew;
}
BuildTables
also calls RenderScript(int Pages, int Items)
which asks for the number of pages that will be generated and, new to this iteration, the overall number of items, then injects the appropriate JavaScript into the page. I've added several functions to handle the progress bars and the spinning edit window. The number of items is used to calculate the overall progress.
This method could be broken up into four separate methods--one for each action. I've left them in a single function for simplicity, but future needs may demand a more discrete set of methods.
protected void RenderScript(int Pages, int Items)
{
int MaxPage = Pages+1;
StringBuilder s = new StringBuilder("\n<script language="JavaScript">\n");
s.Append("function __onPrevPage ()\n");
s.Append("{\n");
s.Append("for (var i=2; i<"+ MaxPage +"; i++) {\n");
s.Append("if (document.getElementById" +
" ('Page' + i).style.display == 'block') {\n");
s.Append(" document.all('Page' + i).style.display = 'none';\n");
s.Append(" document.all('Page' +" +
" (i - 1)).style.display = 'block';\n");
s.Append(" break;\n");
s.Append("}\n");
s.Append("}\n");
s.Append("}\n");
s.Append("\n");
s.Append("function __onNextPage ()\n");
s.Append("{\n");
s.Append("for (var i=1; i<"+ Pages +"; i++) {\n");
s.Append(" if (document.getElementById" +
" ('Page' + i).style.display == 'block') {\n");
s.Append("document.all('Page' + i).style.display = 'none';\n");
s.Append(" document.all('Page' +" +
" (i + 1)).style.display = 'block';\n");
s.Append(" break;\n");
s.Append(" }\n");
s.Append(" }\n");
s.Append(" }\n");
s.Append("function __onFirstPage ()\n");
s.Append("{\n");
s.Append("for (var i=2; i<"+ MaxPage +"; i++) {\n");
s.Append(" if (document.getElementById" +
" ('Page' + i).style.display == 'block') {\n");
s.Append("document.all('Page' + i).style.display = 'none';\n");
s.Append(" document.all('Page' + (1)).style.display = 'block';\n");
s.Append(" break;\n");
s.Append(" }\n");
s.Append(" }\n");
s.Append(" }\n");
s.Append("function __onJumpPage (iPage)\n");
s.Append("{\n");
s.Append("for (var i=1; i<"+ MaxPage +"; i++) {\n");
s.Append(" if (document.getElementById ('Page'" +
" + i).style.display == 'block') {\n");
s.Append("document.getElementById('Page'" +
" + i).style.display = 'none';\n");
s.Append(" document.getElementById('Page'" +
"+iPage).style.display = 'block';\n");
s.Append(" break;\n");
s.Append(" }\n");
s.Append(" }\n");
s.Append(" }\n");
s.Append("function __onLastPage ()\n");
s.Append("{\n");
s.Append("for (var i=1; i<"+ MaxPage +"; i++) {\n");
s.Append(" if (document.getElementById" +
" ('Page' + i).style.display == 'block') {\n");
s.Append("document.all('Page' + i).style.display = 'none';\n");
s.Append(" document.all('Page" + Pages +
"').style.display = 'block';\n");
s.Append(" break;\n");
s.Append(" }\n");
s.Append(" }\n");
s.Append(" }\n");
s.Append("function __doOverallProgress() \n");
s.Append("{\n");
s.Append("document.all('lblProgressOverall').style.width='" +
this.OverallProgress.ToString()+"';\n");
s.Append("document.all('lblProgressOverallValue').value ='" +
this.OverallProgress+"';\n");
s.Append("}\n");
s.Append(" function doEdit(bval,pval){\n");
s.Append(" var record = bval.substr(1,bval.length);\n");
s.Append(" var vTbl = document.getElementById('#vw'+record);\n");
s.Append(" var eTbl = document.getElementById('#edit'+record);\n");
s.Append(" vTbl.style.display='none';\n");
s.Append(" for(var i = 0;i<" + Items +";i++){\n");
s.Append(" var t = document.getElementById('#edit'+i);\n");
s.Append(" var n = document.getElementById('#vw'+i);\n");
s.Append(" if(t.style.display == 'block'){\n");
s.Append(" t.style.display = 'none';\n");
s.Append(" n.style.display = 'block';\n");
s.Append(" }\n");
s.Append(" }\n");
s.Append(" eTbl.style.display='block';\n");
s.Append(" __onJumpPage(eTbl.Page);\n");
s.Append(" window.location=eTbl.id;\n");
s.Append(" }\n");
s.Append("</SCRIPT>");
Page.RegisterClientScriptBlock("pagingScripts",s.ToString());
}
Next Round
Next, I'm going to clean up the code and create a business object layer to encapsulate the functionality. I'm also going to incorporate my NUnit tests into the code and refactor everything several times over. Finally, I will modify the tables to build on the client from streamed XML that loads in the background.