I’ve been working on a form that needs to support a unknown number of rows, that the user will either enter 1-by-1 or bulk upload. Adding a few rows to a table is pretty easy in HTML, but as the rows increase page starts to slow down and the UI can become overwhelming with HTML input controls. I decided to test a few concepts to determine what would best fit my form, I started with doing rows of input controls, followed by trying to use jqGrid plug-in. I really liked jqGrid, but as I tried to get everything working I found myself having to “work around” lots of issues (read forum, apply fix, something else breaks, repeat), so I gave up after making the solution working 90% in jqGrid because the code was already a lot more complex than what I wanted. In the end, I decided that building my own table editor that supported CRUD with jQuery. In addition to following the KISS rule, I also had a list of goals I wanted to include.
Solution Goals
- Allow users to add 1 to 2,000 rows
- Keep the page quick when working with 500+ rows
- Make all edits in memory
- Create a Undo/Cancel button to undo a edit
- Capture dynamic HTML TABLE row contents for use in a server side postback
- (Not in DEMO) Enable validation for rows in Edit Mode
- (Not in DEMO) Enable default values for manually added rows
All of the goals above were in the final solution and 95% of the work is done client-side in ~300 lines of jQuery & JavaScript code. I choose to use jQuery templates for the rows, since it offers a simple model for merging data/HTML along with some advanced features to perform logic in how elements are rendered (e.g. If my Cross object has a Status set, it will display an alert icon on the row and notify the user something in wrong). Since most of these other features were case specific, I left them out of the demo to focus on doing the basic CRUD in HTML and how I got the dynamic rows back to ASP.NET
Final Product
My solution was designed to leverage ASP.NET, but all of the code below is 100% HTML. You can take this code and apply it to any table and choose to leverage any server technology you want. Part of step 5 is ASP.NET specific, but this shows a neat trick for getting the HTML table rows back to the server so you can access them in a traditional ASP.NET postback event.
Step 1: Prerequisites (Accessible via CDN)
- jQuery
- jQuery tmpl plug-in
- JSON.org library
<!-- jQuery on GOOGLE CDN -->
<script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jquery/1.7.2/jquery.min.js"></script>
<!-- JSON.org on CDNJS CDN -->
<script type="text/javascript" src="http://ajax.cdnjs.com/ajax/libs/json2/20110223/json2.js"></script>
<!-- jQuery tmpl() plug-in on ASPNET CDN -->
<script type="text/javascript" src="http://ajax.aspnetcdn.com/ajax/jquery.templates/beta1/jquery.tmpl.js"></script>
Step 2: HTML Layout
<body>
<h1>
CRUD My Table</h1>
<!-- Table where we will perform CRUD operations, data loaded via jQuery tmpl() -->
<table id="CRUDthisTable" class="mediumTable">
<thead>
<tr class="rowHeader">
<th></th>
<th>Change Type </th>
<th>Update Type </th>
<th>Customer Part </th>
<th>ROHM Part </th>
<th>Rank Start </th>
<th>Rank End </th>
<th>Price </th>
<th>UOM </th>
<th>Apply Date </th>
<th>Remarks </th>
</tr>
</thead>
<tbody>
</tbody>
</table>
<!-- jQuery tmpl() Templates -->
<!-- Edit Rows -->
<script id="editRowTemplate" type="text/x-jquery-tmpl">
<tr class="editRow">
<td>
<span id="cancel" name="cancel" tooltip="Cancel" class="ui-icon ui-icon-close CancelRow">Cancel</span>
<span id="save" name="save" tooltip="Save" class="ui-icon ui-icon-disk SaveRow">Save</span>
</td>
<td>
<select id="field1" name="field1" class="changeType">
<option></option>
<option>All</option>
<option>Part</option>
<option>Price</option>
</select></td>
<td>
<select id="field2" name="field2" class="updateType">
<option></option>
<option>Add</option>
<option>Update</option>
<option>Delete</option>
</select></td>
<td>
<input type="text" id="field3" name="field3" class="customerPart required part" value="${CustomerPart}" /></td>
<td>
<input type="text" id="field4" name="field4" class="rohmPart validROHMpart part" value="${ROHMPart}" /></td>
<td>
<input type="text" id="field5" name="field5" class="rankStart rank" value="${RankStart}" /></td>
<td>
<input type="text" id="field6" name="field6" class="rankEnd rank" value="${RankEnd}" /></td>
<td>
<input type="text" id="field7" name="field7" class="price required number" value="${Price}" /></td>
<td>
<select id="field8" name="field8" class="uomType required">
<option></option>
<option>1</option>
<option>1000</option>
</select></td>
<td>
<input type="text" id="field9" name="field9" class="applyDate required date" value="${ApplyDate}" /></td>
<td>
<input type="text" id="field10" name="field10"class="remarks" value="${Remarks}" /></td>
</tr>
</script>
<!-- View Rows -->
<script id="viewRowTemplate" type="text/x-jquery-tmpl">
<tr>
<td style="width:50px;">
<span id="edit" name="edit" title="Edit" class="ui-icon ui-icon-pencil EditRow">Edit</span>
<span id="delete" name="delete" title="Delete" class="ui-icon ui-icon-trash DeleteRow">Delete</span>
</td>
<td style="width:120px;">${ChangeType}</td>
<td style="width:120px;">${UpdateType}</td>
<td>${CustomerPart}</td>
<td>${ROHMPart}</td>
<td style="width:45px;">${RankStart}</td>
<td style="width:45px;">${RankEnd}</td>
<td>${Price}</td>
<td style="width:64px;">${UOM}</td>
<!-- **** TIP: Here we use a function to format the date mm/dd/yyyy -->
<td style="width:80px;">${FormatDate(ApplyDate)}</td>
<td>${Remarks}</td>
</tr>
</script>
</body>
Step 3: Example Loading Data ( NO CRUD Functionality )
// Helper Function to Format Date in View Row
function FormatDate(date)
{
return date.getMonth() + 1 + "/" + date.getDate() + "/" + date.getFullYear();
}
// After the DOM has loaded, take the sample data and inject it into the table using the View Row template.
$(document).ready(function ()
{
// Sample Data - Could be returned via AJAX or could be manual rows added to the TABLE
var crosses = [
{ "ChangeType": "All", "UpdateType": "Add", "CustomerPart": "1SS355TE-17", "ROHMPart": "1SS355TE-17", "RankStart": "", "RankEnd": "", "Price": 0.0151, "UOM": 1, "ApplyDate": new Date(1335337200000), "Remarks": "", "Status": null, "StatusNote": null },
{ "ChangeType": "All", "UpdateType": "Add", "CustomerPart": "RB160M-60TR", "ROHMPart": "RB160M-60TR", "RankStart": "", "RankEnd": "", "Price": 0.0605, "UOM": 1, "ApplyDate": new Date(1335337200000), "Remarks": "", "Status": null, "StatusNote": null },
{ "ChangeType": "All", "UpdateType": "Add", "CustomerPart": "RR264M-400TR", "ROHMPart": "RR264M-400TR", "RankStart": "", "RankEnd": "", "Price": 0.031, "UOM": 1, "ApplyDate": new Date(1335337200000), "Remarks": "", "Status": null, "StatusNote": null },
{ "ChangeType": "All", "UpdateType": "Add", "CustomerPart": "1SR154-400TE25", "ROHMPart": "1SR154-400TE25", "RankStart": "", "RankEnd": "", "Price": 0.0309, "UOM": 1, "ApplyDate": new Date(1335337200000), "Remarks": "", "Status": null, "StatusNote": null },
{ "ChangeType": "All", "UpdateType": "Add", "CustomerPart": "RF071M2STR", "ROHMPart": "RF071M2STR", "RankStart": "", "RankEnd": "", "Price": 0.0638, "UOM": 1, "ApplyDate": new Date(1335337200000), "Remarks": "", "Status": null, "StatusNote": null}];
if (crosses) {
$("#viewRowTemplate").tmpl(crosses).appendTo("#CRUDthisTable");
}
});
CRUD Table – Data Loaded no CRUD Functions Activated
** As you can see, I’m not using images links similar to the final product since I was aiming for simplicity. If you want to use images, I suggest you use the jQuery UI icons as I did in the final product, they can easily be added to a span by adding two class values (e.g. class=”ui-icon ui-icon-close”).
Step 4: Enable CRUD
// Global Parameters
var rowNum = 1;
var rowRemovedNum;
var rowRemovedContents;
// Read a row in Edit Mode into a Cross Object
function GetEditRowObject()
{
var row = $('#CRUDthisTable tbody tr.editRow');
var cross = {};
cross.ChangeType = row.find('.changeType').val();
cross.UpdateType = row.find('.updateType').val();
cross.CustomerPart = row.find('.customerPart').val();
cross.ROHMPart = row.find('.rohmPart').val();
cross.RankStart = row.find('.rankStart').val();
cross.RankEnd = row.find('.rankEnd').val();
cross.Price = row.find('.price').val();
cross.UOM = row.find('.uomType').val();
var dateString = row.find('.applyDate').val();
cross.ApplyDate = new Date(dateString);
cross.Remarks = row.find('.remarks').val();
return cross;
}
// Read a row in View Mode into a Cross Object
function GetViewRowObject(rowNum)
{
var row = $('#CRUDthisTable tbody tr').eq(rowNum);
var cross = {};
cross.ChangeType = row.find('td:eq(1)').text();
cross.UpdateType = row.find('td:eq(2)').text();
cross.CustomerPart = row.find('td:eq(3)').text();
cross.ROHMPart = row.find('td:eq(4)').text();
cross.RankStart = row.find('td:eq(5)').text();
cross.RankEnd = row.find('td:eq(6)').text();
cross.Price = row.find('td:eq(7)').text();
cross.UOM = row.find('td:eq(8)').text();
cross.ApplyDate = row.find('td:eq(9)').text();
cross.Remarks = row.find('td:eq(10)').text();
return cross;
}
// Read all rows into Cross Object Array
function GetAllViewRowsAsCrossObjects()
{
var crossTableRows = [];
$('#CRUDthisTable tbody tr').each(function (index, value)
{
var row = GetViewRowObject(index);
crossTableRows.push(row);
});
return crossTableRows;
}
// Check if any rows are in Edit Mode
function IsExistingRowInEditMode()
{
var rowsInEditMode = $('#CRUDthisTable tbody tr.editRow').length;
if (rowsInEditMode > 0) {
alert('You have a row in Edit mode, please save or cancel the row changes before you continue.');
return true;
}
return false;
}
// After the DOM has loaded, bind the CRUD events
$(document).ready(function ()
// Events
$('.AddRow').click(function()
{
if (IsExistingRowInEditMode())
return;
rowRemovedNum = 0;
var data = { data: 1 };
var output = $("#editRowTemplate").tmpl(data).html()
$('#CRUDthisTable tbody').prepend('<tr class="editRow">' + output + '</tr>');
var $rowEdit = $('#CRUDthisTable tbody tr.editRow');
// Defaults //
var changeTypeDefualt = $('#ChangeTypeDefualt').val();
var updateTypeDefault = $('#UpdateTypeDefault').val();
var uomDefault = $('#UOMDefault').val();
var applyDateDefault = $('#ApplyDateDefault').val();
var changeType = $rowEdit.find('.changeType');
$(changeType).val(changeTypeDefault);
var updateType = $rowEdit.find('.updateType');
$(updateType).val(updateTypeDefault);
var uomType = $rowEdit.find('.uomType');
$(uomType).val(uomDefault);
var applyDate = $rowEdit.find('.applyDate');
$(applyDate).val(applyDateDefault);
$('#CRUDthisTable tbody tr:first')[0].scrollIntoView();
});
$('.EditRow').live('click', function(e)
{
if (IsExistingRowInEditMode())
return;
var row = $(this).parent().parent().parent().children().index($(this).parent().parent());
var data = GetViewRowObject(row);
var output = $("#editRowTemplate").tmpl(data).html()
rowRemovedNum = row;
rowRemovedContents = $('#CRUDthisTable tbody tr').eq(row).html();
$('#CRUDthisTable tbody tr').eq(row).after('<tr class="editRow">' + output + '</tr>');
var changeTypeDefualt = $('#ChangeTypeDefualt').val();
var updateTypeDefault = $('#UpdateTypeDefault').val();
var uomDefault = $('#UOMDefault').val();
var applyDateDefault = $('#ApplyDateDefault').val();
var $editRow = $('#CRUDthisTable tbody tr.editRow');
var changeType = $editRow.find('.changeType');
$(changeType).val(data.ChangeType);
var updateType = $editRow.find('.updateType');
$(updateType).val(data.UpdateType);
var uomType = $editRow.find('.uomType');
$(uomType).val(data.UOM);
$('#CRUDthisTable tbody tr').eq(row).remove();
});
$('.SaveRow').live('click', function(e)
{
var isValid = ValidateNestedControls("#CRUDthisTable");
// Good place to add validation, don't allow save until the row has valid data!
// if (!isValid)
// return;
var savedData = GetEditRowObject();
var row = $(this).parent().parent().parent().children().index($(this).parent().parent());
var output = $("#viewRowTemplate").tmpl(savedData).html();
var tableRows = $('#CRUDthisTable tbody tr').length;
if (tableRows == 0 || row == 0) {
$('#CRUDthisTable tbody').prepend('<tr>' + output + '</tr>');
}
else {
$('#CRUDthisTable tbody tr').eq(row).before('<tr>' + output + '</tr>');
}
$('#CRUDthisTable tbody tr').eq(row + 1).remove();
});
$('.CancelRow').live('click', function(e)
{
var row = $(this).parent().parent().parent().children().index($(this).parent().parent());
$('#CRUDthisTable tbody tr').eq(row).remove();
var tableRows = $('#CRUDthisTable tbody tr').length;
if (rowRemovedContents) {
if (tableRows == 0 || row == 0) {
$('#CRUDthisTable tbody').prepend('<tr>' + rowRemovedContents + '</tr>');
}
else {
$('#CRUDthisTable tbody tr').eq(row).before('<tr>' + rowRemovedContents + '</tr>');
}
}
rowRemovedContents = null;
});
$('.DeleteRow').live('click', function(e)
{
e.preventDefault;
$(this).parent().parent().remove();
});
Step 5: Ajax POST Table Contents to the Server (before button event)
There is a ton of ways to do this, but my goal was to allow users to edit the table and when they were all done with all their edits they could hit “Save” and everything would then be written to the DB. Since ASP.NET doesn’t give you access to dynamic table rows, I bound a AJAX post event to the “Save” button that sends the table contents to the server, stores in cache, and then uses the cache in the traditional postback “Save” event.
// After the DOM has loaded, bind the ASP.NET save button
$(document).ready(function ()
$('#<%= btnSave.ClientID %>').click(function (e) {
return PostTable();
});
}
// Post all rows to the server and put into Cache
function PostTable()
{
// Normally I'll get the ID from the QueryString, but it could also be grabbed from a hidden element in the form.
var crossId = 1;
var jsonRequest = { crosses: GetAllViewRowsAsCrossObjects(), crossId: crossId };
$.ajax({
type: 'POST',
url: 'Demo.aspx/CacheTable',
data: JSON.stringify(jsonRequest),
contentType: 'application/json; charset=utf-8',
dataType: 'json',
success: function (data, text)
{
return true;
},
error: function (request, status, error)
{
return false;
}
});
}
Important Note: If you want to access a page method via jQuery $.ajax(), then you must make the function static and pass the case sensitive parameters with the expected data type(s) in the ajax call.
public partial class Demo: System.Web.UI.Page
{
private static string _cacheKey = "CacheTable_" + HttpContext.Current.User.Identity.Name;
[WebMethod]
public static void CacheTable(List<Cross> crosses, int crossId)
{
if (crosses != null && crosses.Count > 0)
{
HttpContext.Current.Cache.Remove(_cacheKey);
HttpContext.Current.Cache.Insert(_cacheKey, crosses, null, DateTime.Now.AddSeconds(3600), Cache.NoSlidingExpiration);
}
}
}
// Custom Data Transfer Object (DTO)
public class Cross
{
public string ChangeType { get; set; }
public string UpdateType { get; set; }
public string CustomerPart { get; set; }
public string ROHMPart { get; set; }
public string RankStart { get; set; }
public string RankEnd { get; set; }
public double Price { get; set; }
public int UOM { get; set; }
public DateTime ApplyDate { get; set; }
public string Remarks { get; set; }
public string Status { get; set; }
public string StatusNote { get; set; }
}
Working Demo of using jQuery to allow CRUD edits to a HTML TABLE.
ASP.NET Note **If you run into issues on the amount of rows you can postback to the server in ASP.NET via AJAX & JSON, you’ll need to edit your “maxJsonLength” in your web.config.
<system.web.extensions>
<scripting>
<webServices>
<jsonSerialization maxJsonLength="2097152"/>
</webServices>
</scripting>
</system.web.extensions>