Monday 19 October 2015

The ultimate paging strategy?

Scene

Analyst: “ the users want to see a list of widgets they’ve got“

Developer:”OK”

Sometime later …

Manager: ”The widget list is taking them ages to load – can you take a look at it?”.

Developer:”How many widgets have they got now?”

DBA:”3000”.

Developer:”Better turn on paging in the grid control then”.

Sometime later…

Manager: ”The widget list is taking them ages to load – can you take a look at it?”.

Developer:”How many widgets have they got now?”

Analyst:”300 000”.

Developer:”Move the paging to the database then”.

Sometime later…

Manager: ”The widget list is taking them ages to load – can you take a look at it?”.

DBA:”Also, my database server is running hot – can I have some more CPU’s?”.

Developer:”How many widgets have they got now?”

Analyst:”30 0000 000”.

Developer:”I’ll have a look later when I’ve finished updating my cv”.

[more]

Standard Paging strategies

  Advantages Disadvantages
Don’t page Easy No good once the number of rows exceeds 100 or so.
Off the shelf grid paging e.g. using GridView. Fairly painless to implement. Once total number of rows exceeds a few hundred becomes inefficient as all the data is loaded and processed in the web application on each page request.
“Off the shelf” database paging. Takes load off web server and performs set based operations on database which is better at it. Becomes inefficient with very large data sizes, particularly when requiring a total row count.
Out of the box solutions – such as LINQ integration – can be inefficient as they run the same query twice – once to find the total rows and once to get the data

My strategy

  • Do the paging in the database which is optimised for set based operations
  • Limit the number of rows that you will ever query
  • Use JSON services and javascript to minimise data transfer overheads – making your page quick and lean.

Over the years I’ve learnt it’s always best to “do the right thing” at the start.  You may have some tight deadline that you’re being told the future of the world depends on – but my advise is spend an extra hour getting it right in the beginning – you’ll save your self a day down the line.

At the core of my strategy is a limit on the number of rows that are queried.  This does involve a slight UI compromise – the user needs to accept that on some occasions they will get a message which instead of “Rows 50 – 100 of 10,0000 rows matching your filter” then may get “Rows 50 – 100 of more than 10,0000 rows matching your filter”.

The 10,000 – or max rows ever counted (“maxrows”) – introduces scalability into the design as we will only every filter this number.   Note – 10,000 is an arbitrary number I’ve chosen here – the number you use depends on the complexity and efficiency of your filter and the amount data you are searching.  The strength of the strategy is that the “maxrows” can be changed by the dba for each query.

So – here’s my starting strategy for paging.  I’m going to start with the database and work my way up to the UI. 

Database paging with row limit.

Here is a complete sample stored procedure.

ALTER PROC [dbo].[usp_FuelStopsGetForUser]
(
    @vcrUserName nvarchar(255),
    @vehicleId INT,
    @fuelTypeId INT, -- IGNORED AT THE MOMENT
    @garage MediumString,
    @startRow INT,
    @numberToFetch INT,
    @rowcount INT output,
    @rowCountExceeded BIT output
)
AS
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED
DECLARE @maxrows INT

set @maxrows = 100

SET @rowcount=0
    SELECT top(@maxrows+1) ROW_NUMBER() OVER (ORDER BY fs.dteStopDate DESC) AS 'Row',
    fs.intFuelStopId,
    max(fs1.intFuelStopId) AS intLastId,
    SUM(fs1.decLitres) AS TotalLitres,
    fs.dteStopDate
    INTO #tblFuelStop 
    FROM dbo.tblUserVehicle uv
    INNER JOIN tblFuelStop fs ON fs.intUserVehicleId = uv.intUserVehicleId
    LEFT OUTER JOIN tblFuelStop fs1 ON
        fs1.intUserVehicleId = fs.intUserVehicleId AND
        fs1.dteStopDate < fs.dteStopDate  AND
        fs.decODOKm IS NOT NULL AND fs1.decODOKm IS NOT NULL AND
        fs1.intPercentFull = 100  AND fs.intPercentFull = 100 AND
        fs.decLitres IS NOT NULL   
    WHERE uv.vcrUserName = @vcrUserName
    AND (@vehicleId = uv.intVehicleId OR @vehicleId IS NULL)
    AND (@garage IS NULL OR fs.vcrGarageName like @garage + '%')
    GROUP BY fs.intFuelStopId,
    fs.dteStopDate
    -- remove any with null litres
    UPDATE #tblFuelStop
    SET intLastId = NULL,
    TotalLitres = NULL
    WHERE intFuelStopId IN (
    SELECT fs.intFuelStopId FROM dbo.tblUserVehicle uv
    INNER JOIN tblFuelStop fs ON fs.intUserVehicleId = uv.intUserVehicleId
    INNER JOIN #tblFuelStop fst ON fs.dteStopDate BETWEEN fst.dteStopDate AND fst.dteStopDate
    WHERE uv.vcrUserName = @vcrUserName
    AND fs.decLitres IS NULL)

    SET @rowcount = @@ROWCOUNT

    IF(@rowcount > @maxrows)
        set @rowCountExceeded = 1
    ELSE
        SET @rowCountExceeded = 0

    SELECT top(@numberToFetch) fs.*,
        CASE WHEN 0 = (fs.decODOKm - fs1.decODOKm) THEN NULL ELSE 100 * (fs.decLitres + fsf.TotalLitres)/(fs.decODOKm - fs1.decODOKm) END AS decLitresPer100KM ,
        fs1.intUserFuelStopId AS intSince       
    FROM #tblFuelStop fsf
    INNER JOIN tblFuelStop fs
    ON fsf.intFuelStopId = fs.intFuelStopId
    LEFT OUTER JOIN tblFuelStop fs1 ON fs1.intFuelStopId = fsf.intLastId
    WHERE Row > @startRow

Strategies:

  • Use a strongly typed stored procedure – it’s efficient, secure and maintainable.  There is simply no substitute!
  • Standard paging paramaters - @startRow INT,    @numberToFetch INT  @rowcount INT output,– for integration with grid views etc.
  • @rowCountExceeded BIT output -  indicates more then the rowcount rows are there – we just weren’t prepared to query them.
  • SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED – this basically says if there is data that has not yet been committed so might role back – then I’ll show it anyway.  Unless your toting up transaction totals to decide on weather to issue a mortgage – your users wont care and you will probably never see any “phantom” rows.  So give your database a break and turn down the isolation level so you don’t have to take read locks on all the rows.
  • set @maxrows – this allows you to limit the amount of filtering you will ever do and can be changed as the data sizes etc. require.
  • SELECT top(@maxrows+1)  - however much data you stuff in the database your query should take the same amount of umph!
  • ROW_NUMBER() OVER (ORDER BY fs.dteStopDate DESC) AS 'Row' – allows us to create a row number on the fly so we can select the page of data we want.
  • Use temporary table - INTO #tblFuelStop  - this means that we only run our expensive filter once and get the total rowcount for free (SET @rowcount = @@ROWCOUNT )
  • Do any expensive calculations only on the data your returning:
    SELECT top(@numberToFetch) fs.*,
            CASE WHEN 0 = (fs.decODOKm - fs1.decODOKm) THEN NULL ELSE 100 * (fs.decLitres + fsf.TotalLitres)/(fs.decODOKm - fs1.decODOKm) END AS decLitresPer100KM ,
            fs1.intUserFuelStopId AS intSince       
        FROM #tblFuelStop fsf
        INNER JOIN tblFuelStop fs
        ON fsf.intFuelStopId = fs.intFuelStopId
        LEFT OUTER JOIN tblFuelStop fs1 ON fs1.intFuelStopId = fsf.intLastId
        WHERE Row > @startRow

Standard integration with gridviews – if you must

Personally I’m now avoiding gridviews like the plague as they generate loads of thick html, viewstate and other foul matters – but if you must (maybe because you’ve already got one) – then the single stored proc above allows a single call to get rowcount and data – off the shelf integration techniques seem to do two calls with twice the pain!

This requires a bit of fudge in the object data source code as below:

private int invoiceCount;

/// <summary>
      /// Get the invoice count from the search.
      /// </summary>
      /// <returns></returns>
      public int GetInvoiceCount(int businessId,
          DateTime? startDate,
          DateTime? endDate,
          String customer,
          int? invoiceReference,
          int status,
          int invoiceType,
          string purchaseOrderNumber,
          string dataField1,
          string dataField2,
          string dataField3,
          Decimal? minAmountPaid,
          Decimal? maxAmountPaid,
          Decimal? minFCAmountPaid,
          Decimal? maxFCAmountPaid,
          String customerCurrency,
          Decimal? minTotalGross,
          Decimal? maxTotalGross,
          Decimal? minFCTotalGross,
          Decimal? maxFCTotalGross,
          Decimal? minOCTotalGross,
          Decimal? maxOCTotalGross,
          String transactionCurrency)
      {
              return invoiceCount;
      }

      [DataObjectMethodAttribute(DataObjectMethodType.Select, true)]
      public DataSet GetInvoicesWithPaging(int businessId,
          DateTime? startDate,
          DateTime? endDate,
          String customer,
          int? invoiceReference,
          int status,
          int invoiceType,
          string purchaseOrderNumber,
          string dataField1,
          string dataField2,
          string dataField3,
          Decimal? minAmountPaid,
          Decimal? maxAmountPaid,
          Decimal? minFCAmountPaid,
          Decimal? maxFCAmountPaid,
          String customerCurrency,
          Decimal? minTotalGross,
          Decimal? maxTotalGross,
          Decimal? minFCTotalGross,
          Decimal? maxFCTotalGross,
          Decimal? minOCTotalGross,
          Decimal? maxOCTotalGross,
          String transactionCurrency,
          int maxRecords,
          int startIndex           
          )
      {
          DataSet set = DBInvoice.GetInvoicesWithPaging(businessId,
              startDate,
              endDate,
              customer,
              invoiceReference,
              status,
              invoiceType,
              purchaseOrderNumber,
              dataField1,
              dataField2,
          dataField3,
          minAmountPaid,
          maxAmountPaid,
          minFCAmountPaid,
          maxFCAmountPaid,
          customerCurrency,
          minTotalGross,
          maxTotalGross,
          minFCTotalGross,
          maxFCTotalGross,
          minOCTotalGross,
          maxOCTotalGross,
          transactionCurrency,
          maxRecords,
          startIndex,
          out invoiceCount
          );
          return set;
      }

The GetInvoicesWithPaging is always called by the gridview first so we set the invoice count as part of this call (out invoiceCount ) and then in the subsequent simply return the value we already have.

(How civilised).

When I experimented with LINQ this really hurt – the same generated query was being called twice – the second time with a simple count.  When querying a few million rows this was pretty painful – AVOID!

Expose as web/REST service

By exposing our data as a REST service – with the data transported as JSON (comma separated to old guys like me) we minimise the network load of paging so making our UI fast and efficient.  HTML is big and fat, json is small and lean.

Also you’ve get a web service for your app that allows others to integrate.

Since I now employ this pattern extensively I make use of a template base class for the results:

/// <summary>
   /// Base classs for search results
   /// </summary>
   [Serializable]
   public  class ResultsBase<T>
   {

           /// <summary>
           /// The rowcount
           /// </summary>
           public Int32 rowCount;

       /// <summary>
       /// Indicates the rowcount exceeded the number requested.
       /// </summary>   
       public Boolean rowCountExceeded;

       public T[] Items;
   }

Here is the web services for the stored proc described earlier.

/// <summary>
   /// Summary description for FuelStopsWS
   /// </summary>
   [WebService(Namespace = "http://www.myfuel.com/")]
   [WebServiceBinding(ConformsTo = WsiProfiles.BasicProfile1_1)]
   [System.ComponentModel.ToolboxItem(false)]
   // To allow this Web Service to be called from script, using ASP.NET AJAX, uncomment the following line.
   [System.Web.Script.Services.ScriptService]
   public class FuelStopsWS : System.Web.Services.WebService
   {

public class FuelStopSearchResults : ResultsBase <FuelStop>
       {
       }

       [WebMethod]
       [PrincipalPermission(SecurityAction.Demand, Authenticated = true)]
       public FuelStopSearchResults GetFuelStopsForUser(int? vehicleId,
               int? fuelTypeId,
           String garage,
               Int32 startRow,
               Int32 numberToFetch)
       {
           FuelStopSearchResults ret = new FuelStopSearchResults();
           String user = Context.User.Identity.Name;
           int rowCount = 0;
           Boolean rowCountExceeded = false;
           ret.Items = FuelStopManager.GetFuelStops(user,
               vehicleId,
               null,
               garage,
               startRow,
               numberToFetch,
               out rowCount,
               out rowCountExceeded).ToArray();
           ret.rowCount  = rowCount;
           ret.rowCountExceeded = rowCountExceeded;
           return ret;
       }

  • Note I use   [System.Web.Script.Services.ScriptService] tag to expose REST services as described in blgo

And the associated business process layer:

public class FuelStopManager
   {

/// <summary>
        /// Get the fuel stops for a given user matching the search criteria
        /// </summary>
        /// <param name="userName"></param>
        /// <param name="vehicleId"></param>
        /// <param name="fuelTypeId"></param>
        /// <param name="garage"></param>
        /// <param name="startRow"></param>
        /// <param name="numberToFetch"></param>
        /// <param name="rowCount"></param>
        /// <param name="rowCountExceeded">Indicates there are more then rowCount matches.</param>
        /// <returns></returns>
        public static ICollection<FuelStop> GetFuelStops(
            String userName,
            int? vehicleId,
                int? fuelTypeId,
            String garage,
                Int32 startRow,
                Int32 numberToFetch,
                out Int32 rowCount,
                out Boolean rowCountExceeded)
        {
            rowCountExceeded = true;
            rowCount = 0;
            ICollection<FuelStop> ret =null;
            ret = DBFuelStops.GetFuelStops(userName, vehicleId,
                fuelTypeId,
                garage,
                startRow,
                numberToFetch,
                out rowCount,
                out rowCountExceeded);
            return ret;
        }

}

And data access layer:

internal static ICollection<FuelStop> GetFuelStops(string userName, int? vehicleId, int? fuelTypeId, string garage, int startRow, int numberToFetch, out int rowCount, out bool rowCountExceeded)
       {
           Database db = PetrolDatabaseFactory.PetrolDB;
           IList<FuelStop> ret = new List<FuelStop>();
           rowCount = 0;
           rowCountExceeded = true;
           IDataReader reader = db.ExecuteReader("usp_FuelStopsGetForUser",
              new Object[] {userName,
              vehicleId,
              fuelTypeId,
              garage,
              startRow,
              numberToFetch,
              rowCount,
              rowCountExceeded });
           using (reader)
           {
               while (reader.Read())
               {
                   ret.Add(CreateFuelStop(reader));
               }
           }

           return ret;
       }

Javascript paging

Once the REST service is available we can use a combination of Microsoft AJAX and jquery (or just jquery) to retrieve and page through our data.

Sample 1 – jquery mobile showing first 10

This sample shows the first 10 user fuel stops (always).  The relevant javascript and ajax includes are on the master page described in a previous blog.

<%@ Page Title="" Language="C#" MasterPageFile="~/Views/Shared/Site.Master" Inherits="System.Web.Mvc.ViewPage<dynamic>" %>

<asp:Content ID="Content1" ContentPlaceHolderID="TitleContent" runat="server">
    Fuel Stops
</asp:Content>
<asp:Content ID="Content3" ContentPlaceHolderID="PageTitle" runat="server">
    Your recent fuel stops.
</asp:Content>
<asp:Content ID="Content2" ContentPlaceHolderID="MainContent" runat="server">
<script type="text/javascript" language="javascript" src="/PetrolServices/FuelStopsWS.asmx/js"></script>
<script type="text/javascript" language="javascript">
    $(document).ready(function () {
        // Get the fuel stops for a user - their history
        PetrolServices.FuelStopsWS.GetFuelStopsForUser(null, null, null, 0, 10, fuelStopsSuccess, fuelStopsFailed,complete);
    });

    function complete(){$('ddlVehicleSelect').listview('refresh');
    } 

    function fuelStopsSuccess(data) {
        var list = "";
        var addUrl = "<%= Url.Action("Add", "FuelStop") %>" + "/";
        for (var i = 0; i < data.Items.length; ++i) {
            var fs = data.Items[i];
            list += "<li data-role=\"list-divider\">"
            list += "<a href=\"" + addUrl + fs.FuelStopId + "\" rel=\"external\">Stop: "+fs.UserFuelStopNumber + " - " + fs.Garage + " Date - " + fs.StopDate.toDateString()  + "</a></li>";
            list += "<li>Quanity: "+ fs.Litres + " Litres, " + fs.GallonsString + " Gallons</li>";
            list += "<li>Price: "+ fs.PricePerLitre + " per litres " + fs.PricePerGallonString + " per Gallons</li>";
            list += "<li>Total: " + fs.TotalCostString + "</li>";
            list += "<li>MPG: "+ fs.MPGString + " Litres Per 100KM: " + fs.LPer100KMString + " Since stop " + fs.MPGSinceString + "</li>";

        }
        $("#dvFuelStops").empty();                              
        $("#lvFuelStops").append(list);              
        $("#lvFuelStops").listview();
        $("#lvFuelStops").listview('refresh');

    }

    function fuelStopsFailed() {
        alert("Could Not Fetch Fuel Data,");
    }
</script>
<ul data-role="listview" data-inset="true" data-theme="c" data-dividertheme="b" id="lvFuelStops">
<li data-role="list-divider">Fuel Stops</li>
<li><a href='<%= Url.Action("Add", "FuelStop") %>' rel="external">Add</a></li>
<div id="dvFuelStops" />
</ul>
</asp:Content>

The list displayed to the user is built  on the fly from the json retreived.

Sample 2 – with paging.

This sample was used to replace a RadGrid with paging– and has filters at the top of each column.   Again, the rows are built on the fly (to match the existing radgrid style) as are the page links – which sets the current page and refreshes the grid:

for (var i = startPage; i < endPage; ++i) {
            pages += "<a href=\"#\" onclick=\"currentPage=" + i + ";GetClients(); return false;\" class=\"rgCurrentPage\" ><span>" + (i + 1) + "</span></a>";
        }

Notice, also, how we show the user if our rowcount was exceeded:

if (results.rowCountExceeded) {
       pageInfo = "&nbsp;Page <strong>" +
       (currentPage+1) + "</strong> of more than <strong>" +
       (opdiv(results.rowCount, rowsPerPage)+1) +"</strong>, items <strong>" +
       ((currentPage * rowsPerPage) +1) + "</strong> to <strong>" +
       (currentPage * rowsPerPage) + rowsPerPage
       "</strong> of more than <strong>" + results.rowCount + "</strong>.";    
   }
   else {
       pageInfo = "&nbsp;Page <strong>" +
       (currentPage + 1) + "</strong> of <strong>" +
       (opdiv(results.rowCount, rowsPerPage)+1) +"</strong>, items <strong>" +
       ((currentPage * rowsPerPage) + 1) + "</strong> to <strong>" +
       ((currentPage * rowsPerPage) + rowsPerPage)
       "</strong> of <strong>" + results.rowCount + "</strong>.";
   }

Here is the complete listing:

<%@ Control Language="C#" AutoEventWireup="true" CodeFile="Clients.ascx.cs" Inherits="Clients_Controls_Clients" %>
<script type="text/javascript" language="javascript" src="/commonscripts/StionaScripts.js" ></script>
<telerik:RadCodeBlock runat="server">
<script type="text/javascript" language="javascript" >
    window.$ = $telerik.$;
    $(document).ready(function() {
        currentPage = 0;
        GetClients();
    });
    // the current page (0 based)
    var currentPage;
    var rowsPerPage = 50;
    function GetClients() {
        $("#clientTable > tbody").empty();
        var count=0;
        $("RadAjaxLoadingPanel1").attr("display","");
        BusinessHierarchyWS.Clients.GetClients(
            $("input#vcrName").val(),
            $("input#txtAddress").val(),
            $("input#txtAccountantName").val(),
            currentPage * rowsPerPage,
            rowsPerPage,
            ClientsGot);   
    }
    function clearFilters()
    {
        $("input#vcrName").val("");
        $("input#txtAddress").val("");
        $("input#txtAccountantName").val("");
        currentPage = 0;
        GetClients();
    }
    function PageControl(totalrows)
    {
        var startPage = currentPage - 5;
        var endPage = currentPage + 5;       
        if(startPage < 0){
            startPage = 0;
            endPage = currentPage + 5 -(currentPage - 5);
        }
        var lastPage = opdiv(totalrows, rowsPerPage);
        var more = false;
        if(endPage > lastPage){
            endPage = lastPage;
        }       
        else{
            more = false;
        }
        var pages;
        pages = "";
        for (var i = startPage; i < endPage; ++i) {
            pages += "<a href=\"#\" onclick=\"currentPage=" + i + ";GetClients(); return false;\" class=\"rgCurrentPage\" ><span>" + (i + 1) + "</span></a>";
        }
        return pages;
    }
    function Search(){
        currentPage = 0;
        GetClients();
    }

    function SelectRow(checkboxId) {
        //GET THE status of the one clicked
        var chkBox = $("input[name=" + checkboxId + "]");
        var status = chkBox.attr('checked');
        // first clear them all
        $("#clientTable input:checkbox").attr('checked', false);
        // set the status of the one clicked
        chkBox.attr('checked', !status);
    }
    function ClientsGot(results) {
        var rows;
        rows = "";
        for (var i = 0; i < results.clients.length; ++i) {
        // alter the class for every other row
        var rowClass = ((i % 2) == 0) ? "rgRow" : "rgAltRow";
        var item = results.clients[i];
        var addressLine;
        addressLine = "";
        addressLine = stringAppend(addressLine, item.AddressLine1);
        addressLine = stringAppend(addressLine, item.AddressLine2);
        addressLine = stringAppend(addressLine, item.AddressLine3);
        var row = "<tr class=\"" + rowClass + "\" onmousedown=\"checkRightMouseClick(event,'ID_" + item.ClientId + "\')\"  onclick=\"SelectRow(\'ID_" + item.ClientId + "\')\"  >";
        row+="<td><input id=\"" + item.ClientId + "\" type=\"checkbox\" value=\"" + item.ClientId + "\" name=\"ID_" + item.ClientId + "\" onclick=\"this.checked=!this.checked\" /></td>";
        row += "<td>"+item.Name+"</td>";
        row+="<td>" + addressLine + "</td>"
        row+="<td>" +item.Accountant.Name +"</td></tr>";
        rows += row;
    }
    // insert the rows
    $("#clientTable > tbody").append(rows);
    // now set right button event
    var menu = $find("ctl00_RadContextMenu1");
    var tbody = $get("clientTable");
    //tbody = tbody["tbody"];
    menu.addTargetElement(tbody);
    var pageInfo;
    if (results.rowCountExceeded) {
        pageInfo = "&nbsp;Page <strong>" +
        (currentPage+1) + "</strong> of more than <strong>" +
        (opdiv(results.rowCount, rowsPerPage)+1) +"</strong>, items <strong>" +
        ((currentPage * rowsPerPage) +1) + "</strong> to <strong>" +
        (currentPage * rowsPerPage) + rowsPerPage
        "</strong> of more than <strong>" + results.rowCount + "</strong>.";    
    }
    else {
        pageInfo = "&nbsp;Page <strong>" +
        (currentPage + 1) + "</strong> of <strong>" +
        (opdiv(results.rowCount, rowsPerPage)+1) +"</strong>, items <strong>" +
        ((currentPage * rowsPerPage) + 1) + "</strong> to <strong>" +
        ((currentPage * rowsPerPage) + rowsPerPage)
        "</strong> of <strong>" + results.rowCount + "</strong>.";
    }
    $("#clientTable div#pageInfo").empty();
    $("#clientTable div#pageInfo").append(pageInfo);
    var pageText = PageControl(results.rowCount);
    $("#clientTable div#pages").empty();
    $("#clientTable div#pages").append(pageText);
    $("RadAjaxLoadingPanel1").attr("display","none");

}

function checkRightMouseClick(e, clientId) {
    if (navigator.appName == 'Netscape'
           && e.which == 3) {
        SelectRow(clientId);
    }
    else {
        if (navigator.appName == 'Microsoft Internet Explorer'
          && event.button == 2)
            SelectRow(clientId);
    }
    return true;
}
</script>
</telerik:RadCodeBlock>
<asp:ScriptManagerProxy  runat="server" ID="scriptManager">
                <Services>
                    <asp:ServiceReference path="/BusinessHierarchyWS/Clients.asmx"  />
                </Services>
</asp:ScriptManagerProxy>

<div id="ctl00_ContentPlaceHolder1_ucClients_m_clientGrid" class="RadGrid RadGrid_FusionGrid">

<asp:Panel ID="pnlClientList" runat="server" Visible="true">
    <h3>
        <asp:Localize ID="m_clientsFieldSet" runat="server" Text="Select Client"></asp:Localize></h3>
<table cellspacing="0" class="rgMasterTable" border="0" id="clientTable" style="width:100%;table-layout:auto;empty-cells:show;">
    <colgroup>

        <col  />
        <col  />
        <col  />
        <col  />
    </colgroup>
<thead>
        <tr class="rgCommandRow">
        <td class="rgCommandCell" colspan="4">
<div class="filterclearline">
  <div class="filterclearlineleft">
    <input type="submit" name="btnSearch" value="Search"  class="buttons" onclick="Search();return false;" ID="btnSearch"/>
  </div>
  <div class="filterclearlineright">
  <input id="chkRememberFilter" type="checkbox" name="chkRememberFilter" />
  <label for="chkRememberFilter">Remember Filter</label>
  <input type="submit" name="btnShowAll" value="Clear all filters" id="btnShowAll" class="buttons" onclick="clearFilters();return false;" />   
  </div>
</div>
        </td>
        </tr>
        <tr class="rgPager" style="text-align:right;">
            <td colspan="4"><table cellspacing="0" border="0" style="width:100%;">
                <tr>
                    <td class="rgPagerCell NumericPages">
                    <div class="rgWrap rgNumPart" id="pages">
                    </div>
                    <div class="rgWrap rgInfoPart" id="pageInfo">                        
                    <!-- the page details goes here -->
                    </div></td>
                </tr>
            </table></td>
        </tr>
        <!-- title row -->
        <tr>
            <th scope="col" class="rgHeader">&nbsp;</th>
            <th scope="col" class="rgHeader">Name</th>
            <th scope="col" class="rgHeader">Address</th>
            <th scope="col" class="rgHeader">Accountant</th>
        </tr>
        <!-- the filters -->
        <tr class="rgFilterRow">
            <td>&nbsp;</td>
            <td style="white-space:nowrap;"><input name="vcrName" type="text" size="10" id="vcrName" class="rgFilterBox" onkeypress="if((event.keyCode == 13 || event.keyCode == 20)) {Search(); return false;}" /></td>
            <td style="white-space:nowrap;"><input id="txtAddress"  name="txtAddress" type="text" size="10"  class="rgFilterBox" onkeypress="if((event.keyCode == 13 || event.keyCode == 20)) {Search(); return false;}" /></td>
            <td style="white-space:nowrap;"><input id="txtAccountantName" name="txtAccountantName" type="text" size="10" class="rgFilterBox" onkeypress="if((event.keyCode == 13 || event.keyCode == 20)) {Search(); return false;}"  /></td>
        </tr>
    </thead><tfoot>
        <tr class="rgPager" style="text-align:right;">
            <td colspan="4"><table cellspacing="0" border="0" style="width:100%;">
                <tr>
                    <td class="rgPagerCell NumericPages">
                    <div class="rgWrap rgNumPart" id="pages">                       
                    </div>
                    <div class="rgWrap rgInfoPart" id="pageInfo">                        
                    <!-- the page details goes here -->
                    </div></td>

                </tr>
            </table></td>
        </tr>
    </tfoot>
    <tbody>
    <!-- clients go in here -->
    </tbody>
    </table>
</div>

No comments:

Post a Comment