jqGrid and ASP.NET MVC – Form Editing with TinyMCE and file upload

In one of comments to my jqGrid strongly typed helper post I was asked for form editing demo with file upload and TinyMCE integration, so here it comes.
The demo will provide a CRUD functionality for Employees table from Northwind database. I will skip Employee and EmployeesRepository classes here (full source code for entire sample can be downloaded here) and go straight to ViewModel which will be used by the helper:
public class EmployeeViewModel
{
#region Properties
[ScaffoldColumn(false)]
public int Id { get; set; }

[JqGridColumnSortable(false)]
[JqGridColumnFormatter("$.photoFormatter", UnFormatter = "$.photoUnFormatter")]
[JqGridColumnLayout(JqGridAlignments.Center, Width = 35)]
[JqGridColumnEditable(true, EditType = JqGridColumnEditTypes.File)]
public string Photo { get; private set; }

[StringLength(25)]
[JqGridColumnSortable(true)]
[DisplayName("Title of courtesy")]
[JqGridColumnEditable(true, EditType = JqGridColumnEditTypes.Text)]
public string TitleOfCourtesy { get; set; }

[Required]
[StringLength(10)]
[DisplayName("First name")]
[JqGridColumnSortable(true)]
[JqGridColumnEditable(true, EditType = JqGridColumnEditTypes.Text)]
public string FirstName { get; set; }

[Required]
[StringLength(20)]
[DisplayName("Last name")]
[JqGridColumnSortable(true)]
[JqGridColumnEditable(true, EditType = JqGridColumnEditTypes.Text)]
public string LastName { get; set; }

[StringLength(30)]
[JqGridColumnSortable(true)]
[JqGridColumnEditable(true, EditType = JqGridColumnEditTypes.Text)]
public string Title { get; set; }

[AllowHtml]
[JqGridColumnSortable(false)]
[JqGridColumnLayout(JqGridAlignments.Left, Width = 450)]
[JqGridColumnEditable(true, EditType = JqGridColumnEditTypes.TextArea)]
public string Notes { get; set; }
#endregion

#region Constructors
public EmployeeViewModel()
{ }

public EmployeeViewModel(Employee employee)
{
this.Id = employee.Id;
this.TitleOfCourtesy = employee.TitleOfCourtesy;
this.FirstName = employee.FirstName;
this.LastName = employee.LastName;
this.Title = employee.Title;
this.Notes = employee.Notes;

RouteValueDictionary photoUrlRouteData = new RouteValueDictionary();
photoUrlRouteData["controller"] = "Home";
photoUrlRouteData["action"] = "EmployeePhoto";
photoUrlRouteData["id"] = employee.Id;
Photo = RouteTable.Routes.GetVirtualPathForArea(HttpContext.Current != null ? HttpContext.Current.Request.RequestContext : null, null, photoUrlRouteData).VirtualPath;
}
#endregion
}
That's a lot of attributes, but we will focus only on two properties - Photo for which we want file upload functionality and Notes which we want to integrate with TinyMCE. We will start with Notes. Two most important attributes here are AllowHtml (so TinyMCE can post some HTML for this property) and JqGridColumnEditable (we want jqGrid to generate a TextArea for us, so we can attach TinyMCE to it). The only problem with TinyMCE here is that it can't be attached to an element which is not visible. Because of that we have to recreate it every time the form is displayed. There are two events in jqGrid navigator action options, which will allow us to do it easily: afterShowForm and onClose. Here goes the JavaScript functions for those events:
$.onAfterShowForm = function(formSelector) {
//Add TinyMCE editor instance
$('textarea', formSelector).attr('cols', '50').attr('rows', '15').tinymce({
theme : 'advanced',
theme_advanced_toolbar_location : 'top'
});
};

$.onClose = function(formSelector) {
//Remove TinyMCE editor instance
$('textarea', formSelector).tinymce().remove();
};
So TinyMCE is working, what about the file upload? We can set the edittype to file and than proper input element will be created by jqGrid, but this is where the support ends - jqGrid will not post the file for us. Tony Tomov suggests creating an additional submit button for this, but this way you need to post data in two steps. I really wanted to have all data posted at once, so I decided to go a little bit "hacky" here. Please be aware, that following solution will completely replace jqGrid built in post functionality - if you need any part of it, you need to write it yourself here. I'm going to use Ajax File Uploader plugin, to post entire form at once. To achieve this I will change some of the form attributes in onInitializeForm event:
//WARNING: This will completely replace build in jqGrid post behavior
$.onInitializeForm = function(formSelector) {
//Set enctype='multipart/form-data'
$(formSelector).attr('enctype', 'multipart/form-data')
//method='POST'
.attr('method', 'POST')
//remove onsubmit
.removeAttr('onsubmit')
//attach Ajax File Uploader plugin
.iframePostForm({
//When post request is completed
complete: function(response) {
//check if operation was successful
if(response != 'success') {
//if not, then display error message
$('#FormError>td', formSelector).html(response);
$('#FormError', formSelector).show();
} else {
var id = $('#id_g', formSelector).val();
//if yes, reload jqGrid
$('#employees').trigger('reloadGrid');
//and restore current selection
setTimeout(function(){
$('#employees').jqGrid('setSelection', id);
}, 1000);
}
//Remove class from submit button
$('#iframe_submit').removeClass('ui-state-active');
}
});
//Create inputs for 'id' and 'oper' keys which jqGrid adds to request by default
$('#id_g', formSelector).after($('<input />').attr('id', 'iframe_id').attr('type', 'text').attr('name', 'id'));
$('#id_g', formSelector).after($('<input />').attr('id', 'iframe_oper').attr('type', 'text').attr('name', 'oper'));
//Change the submit button id, so jqGrid will not attach its click event
$('#sData').attr('id', 'iframe_submit')
//and attach our click event
.click(function(e) {
//When user clicks the submit button hide error message
$('#FormError', formSelector).hide();
var id = $('#id_g', formSelector).val();
//set values for 'id' and 'oper' inputs
$('#iframe_id', formSelector).val(id);
$('#iframe_oper', formSelector).val((id == '_empty') ? 'add' : 'edit');
//set action url based on operation type
$(formSelector).attr('action', (id == '_empty') ? '@Url.Action("InsertEmployee")' : '@Url.Action("UpdateEmployee")')
//add class to submit button
$('#iframe_submit').addClass('ui-state-active');
//and submit form
$(formSelector).submit();
return false;
});
};
Now we can create the view. First we should instantiate jqGrid helper with all the desired options:
@{
ViewBag.Title = "jqGrid in ASP.NET MVC - Employees [Form Editing]";

var grid = new Lib.Web.Mvc.JQuery.JqGrid.JqGridHelper<jqGrid.DungDepTrai.Models.EmployeeViewModel>("employees",
dataType: Lib.Web.Mvc.JQuery.JqGrid.JqGridDataTypes.Json,
methodType: Lib.Web.Mvc.JQuery.JqGrid.JqGridMethodTypes.Post,
pager: true,
rowsNumber: 10,
sortingName: "LastName",
sortingOrder: Lib.Web.Mvc.JQuery.JqGrid.JqGridSortingOrders.Asc,
url: Url.Action("FindEmployees"),
width: 1077,
viewRecords: true
).Navigator(new Lib.Web.Mvc.JQuery.JqGrid.JqGridNavigatorOptions() { Search = false, View = false },
editActionOptions: new Lib.Web.Mvc.JQuery.JqGrid.JqGridNavigatorActionOptions() { Url = Url.Action("UpdateEmployee"), OnInitializeForm = "$.onInitializeForm", OnAfterShowForm = "$.onAfterShowForm", OnClose = "$.onClose" },
addActionOptions: new Lib.Web.Mvc.JQuery.JqGrid.JqGridNavigatorActionOptions() { Url = Url.Action("InsertEmployee"), OnInitializeForm = "$.onInitializeForm", OnAfterShowForm = "$.onAfterShowForm", OnClose = "$.onClose" },
deleteActionOptions: new Lib.Web.Mvc.JQuery.JqGrid.JqGridNavigatorActionOptions() { Url = Url.Action("DeleteEmployee") }
);
}
Then we can get required HTML out of it:
@grid.GetHtml()
And add the JavaScript:
<script src="@Url.Content("~/Scripts/jquery-1.4.4.min.js")" type="text/javascript"></script>
<script src="@Url.Content("~/Scripts/jquery.jqGrid.locale-en-3.8.2.js")" type="text/javascript"></script>
<script src="@Url.Content("~/Scripts/jquery.jqGrid-3.8.2.min.js")" type="text/javascript"></script>
<script src="@Url.Content("~/Scripts/jquery.iframe-post-form.min.js")" type="text/javascript"></script>
<script src="@Url.Content("~/Scripts/TinyMCE/tiny_mce.js")" type="text/javascript"></script>
<script src="@Url.Content("~/Scripts/TinyMCE/jquery.tinymce.js")" type="text/javascript"></script>
<script type="text/javascript">
$(document).ready(function () {
@grid.GetJavaScript()
});

//Every photo have constant URL, this formatter will make them refresh at every reload <-- only for presentation purposes
$.photoFormatter = function(cellvalue, options, rowObject) {
return "<img style='width:25px' src='" + cellvalue + "&rand=" + (Math.random() * 10000000) + "' />";
};

//Always unformat photo as empty value
$.photoUnFormatter = function(cellvalue, options, cellobject) {
return '';
};

//WARNING: This will completely replace build in jqGrid post behavior
$.onInitializeForm = function(formSelector) {
...
};

$.onAfterShowForm = function(formSelector) {
...
};

$.onClose = function(formSelector) {
...
};
</script>
The only thing that is missing here is some server side code for handling the requests:
public ViewResult Employees()
{
return View();
}

[AcceptVerbs(HttpVerbs.Post)]
public ActionResult FindEmployees(JqGridRequest request)
{
int totalRecordsCount = _employeesRepository.GetCount();

JqGridResponse response = new JqGridResponse()
{
TotalPagesCount = (int)Math.Ceiling((float)totalRecordsCount / (float)request.RecordsCount),
PageIndex = request.PageIndex,
TotalRecordsCount = totalRecordsCount
};
response.Records.AddRange(from employee in _employeesRepository.FindRange(String.Format("{0} {1}", request.SortingName, request.SortingOrder), request.PageIndex * request.RecordsCount, request.RecordsCount)
select new JqGridRecord<EmployeeViewModel>(Convert.ToString(employee.Id), new EmployeeViewModel(employee)));

return new JqGridJsonResult() { Data = response };
}

[NoCache]
public ActionResult EmployeePhoto(int id)
{
//We should keep images in file system
string employeePhotoPath = GetEmployeePhotoPath(id);
if (!System.IO.File.Exists(employeePhotoPath))
{
Employee employee = _employeesRepository.FindByKey(id);
if (employee.Photo != null)
{
using (FileStream employeePhotoStream = new FileStream(employeePhotoPath, FileMode.Create, FileAccess.Write))
//Skip Ole bytes array
employeePhotoStream.Write(employee.Photo, 78, employee.Photo.Length - 78);
}
else
//You might want to return some default image here
return new EmptyResult();
}
return File(employeePhotoPath, "image/bmp");
}

[AcceptVerbs(HttpVerbs.Post)]
public ContentResult InsertEmployee([Bind(Exclude = "Id")]EmployeeViewModel viewModel, HttpPostedFileBase photo)
{
if (ModelState.IsValid)
{
try
{
if (photo != null && photo.ContentLength > 0 && photo.ContentType != "image/bmp")
return Content("Photo must be in BMP format.");
else
{
Employee employee = new Employee();
employee.LastName = viewModel.LastName;
employee.Notes = viewModel.Notes;
employee.Title = viewModel.Title;
employee.TitleOfCourtesy = viewModel.TitleOfCourtesy;
employee.FirstName = viewModel.FirstName;
_employeesRepository.Add(ref employee);

_employeesRepository.SaveChanges();

photo.SaveAs(GetEmployeePhotoPath(employee.Id));
}
}
catch
{
//There should be some decent exception handling here
return Content("An error occurred during employee insert.");
}

return Content("success");
}
else
return Content("Invalid employee data.");
}

[AcceptVerbs(HttpVerbs.Post)]
public ContentResult UpdateEmployee(EmployeeViewModel viewModel, HttpPostedFileBase photo)
{
if (ModelState.IsValid)
{
try
{
Employee employee = _employeesRepository.FindByKey(viewModel.Id);
if (employee != null)
{
if (photo != null && photo.ContentLength > 0)
{
if (photo.ContentType == "image/bmp")
photo.SaveAs(GetEmployeePhotoPath(viewModel.Id));
else
return Content("Photo must be in BMP format.");
}

employee.LastName = viewModel.LastName;
employee.Notes = viewModel.Notes;
employee.Title = viewModel.Title;
employee.TitleOfCourtesy = viewModel.TitleOfCourtesy;
employee.FirstName = viewModel.FirstName;

_employeesRepository.SaveChanges();
}
else
return Content("Couldn't found employee for update.");
}
catch
{
//There should be some decent exception handling here
return Content("An error occurred during employee update.");
}

return Content("success");
}
else
return Content("Invalid employee data.");
}

[AcceptVerbs(HttpVerbs.Post)]
public HttpStatusCodeResult DeleteEmployee(int id)
{
try
{
_employeesRepository.Delete(id);
_employeesRepository.SaveChanges();

return new HttpStatusCodeResult(200, "Succeeded");
}
catch
{
//There should be some decent exception handling here
return new HttpStatusCodeResult(500, "An error occurred during employee delete.");
}
}
Now we can play with the demo.
I hope you will find it helpful.