Довольно часто перед разработчиком встает задача представить множество объектов в табличном формате.
Динамически построить html-таблицу достаточно просто, однако добавить ей дополнительного функционала вроде поиска и сортировки если и не сложно, то, по крайней мере, достаточно муторно.
Вторая проблема, с которой можно часто столкнуться – это объем результирующих данных. Тут нам на помощь приходит технология Ajax. И, как следствие, дополнительный объем повторяющегося кода, требующего времени на написание и отладку.

Сейчас я занимаюсь разработкой на ASP.NET MVC и в какой-то момент я озадачился созданием HTML-помощника, использование которого помогло бы мне минимальным затратами добавлять вывод данных на страницу.


Результат, который он выдает, будет следующий:
image
На стороне клиента оболочка построена с помощью jQuery библиотеки для работы с таблицами DataTables.

Использование


Создание такой таблицы требует на представлении совсем небольшого объема кода. Тело и заголовок представления может выглядеть так:
<head id="Head1" runat="server">
<link href="../../Content/Site.css" rel="stylesheet" type="text/css" />
<script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jquery/1.4.2/jquery.min.js"></script>
<link href="http://ajax.googleapis.com/ajax/libs/jqueryui/1.8/themes/base/jquery-ui.css" rel="stylesheet" type="text/css"/>
<script src="http://ajax.googleapis.com/ajax/libs/jqueryui/1.8/jquery-ui.min.js" type="text/javascript"></script>
<style type="text/css">
.dataTables_div { width: 1000px }
</style>
<title>Index</title>
</head>
<body>
<%= Html.AjaxDataTable("brousersTable",
"dataTables_div",
"браузеров",
"../DataTable/GetBrousersList",
new List<ADT_TR>()
.AddTR(new ADT_TR() { Title = "Название", Width = "30%" })
.AddTR(new ADT_TR() { Title = "Версия", Width = "20%" })
.AddTR(new ADT_TR() { Title = "Рейтинг", SingleFilterTitle = "Поиск по рейтингу", Width = "20%", IsHaveSingleFilter = true })
.AddTR(new ADT_TR() { Title = "Описание", Width = "30%", InMultiFilter = false, IsSortable = false })
) %>
</body>


Здесь надо уточнить, что я добавил для класса List<ADT_TR> метод расширения AddTR, возвращающий на выходе так же класс List<ADT_TR>:
public static IList<ADT_TR> AddTR(this IList<ADT_TR> list, ADT_TR tr)
{
list.Add(tr);
return list;
}


Действие контроллера будет выглядеть еще более кратко:
public ActionResult List()
{
return View();
}


HTML-помощник


Итак, посмотрев на результат, обратим свое внимание на код самого HTML-помощника:
/// <summary>
/// HTML-помощник для построения Ajax-таблицы
/// </summary>
/// <param name="tableId">ID результирующей таблицы</param>
/// <param name="divClass">Название стиля для контейнера, в котором будет содержаться таблица</param>
/// <param name="element">Название элемента данных таблицы в родительском падеже</param>
/// <param name="AJAXLink">Ссылка для GET запроса для получения текущей страницы данных</param>
/// <param name="columns">Список ADT_TR - параметров для вывода столбцов таблицы</param>
/// <returns></returns>
public static string AjaxDataTable(this HtmlHelper html,
string tableId, string divClass, string element, string AJAXLink, IList<ADT_TR> columns)
{
TagBuilder content = new TagBuilder("div");
content.AddCssClass(divClass);
content.InnerHtml = GetTable(tableId, columns.Count, columns);
content.InnerHtml += GetScript(tableId, element, columns, AJAXLink);
return content.ToString();
}


Из входных параметров вопрос может вызвать только класс “ADT_TR”, приведем его описание:
public class ADT_TR
{
/// <summary>
/// Заголовок столбца
/// </summary>
public string Title { get; set; }
/// <summary>
/// Название локального фильтра
/// </summary>
public string SingleFilterTitle { get; set; }
/// <summary>
/// Активизирует сортировку по столбцу
/// </summary>
public bool? IsSortable { get; set; }
/// <summary>
/// Участвует ли в общей фильтрации
/// </summary>
public bool? InMultiFilter { get; set; }
/// <summary>
/// Ширина столбца
/// </summary>
public string Width { get; set; }
/// <summary>
/// Имеет ли личный фильтр
/// </summary>
public bool? IsHaveSingleFilter { get; set; }
}


Теперь посмотрим на методы GetTable и GetScript.
Первый метод отрисовывает таблицу, добавляя в тело таблицы начальную строку “Данные грузятся с сервера” и, в случае, если хотя бы один столбец имеет включенный личный фильтр, то отрисовывает соответствующий набор тегов input в подвале таблицы:
private static string GetTable(string tableId, int count, IList<ADT_TR> columns)
{
TagBuilder table = new TagBuilder("table");
table.MergeAttribute("id", tableId);
table.AddCssClass("display");
table.MergeAttribute("cellpadding", "0");
table.MergeAttribute("cellspacingv", "0");
table.MergeAttribute("border", "0");

#region tbody
TagBuilder td = new TagBuilder("td");
td.MergeAttribute("colspan", count.ToString());
td.AddCssClass("dataTables_empty");
td.InnerHtml = DataTableResource.LoadData;

TagBuilder tr = new TagBuilder("tr");
tr.InnerHtml = td.ToString();

TagBuilder tbody = new TagBuilder("tbody");
tbody.InnerHtml = tr.ToString();

table.InnerHtml = tbody.ToString();
#endregion

#region tfoot
if (columns.Where(c => c.IsHaveSingleFilter.HasValue && c.IsHaveSingleFilter.Value).Count() > 0)
{
TagBuilder tr_f = new TagBuilder("tr");
tr_f.InnerHtml = "";
int ii = 0;
foreach (ADT_TR atd_tr in columns)
{
TagBuilder th_f = new TagBuilder("th");
th_f.InnerHtml = (atd_tr.IsHaveSingleFilter.HasValue && atd_tr.IsHaveSingleFilter.Value) ?
"<input type=\"text\" name=\"search_" + ii + "\" value=\"" + (!string.IsNullOrEmpty(atd_tr.SingleFilterTitle) ? atd_tr.SingleFilterTitle : string.Format(DataTableResource.SingleSearch, atd_tr.Title)) + "\" class=\"search_init\" style=\"width:80%\" />" :
"<input type=\"text\" name=\"search_" + ii + "\" value=\"" + (!string.IsNullOrEmpty(atd_tr.SingleFilterTitle) ? atd_tr.SingleFilterTitle : string.Format(DataTableResource.SingleSearch, atd_tr.Title)) + "\" class=\"search_init\" style=\"display:none\" />";
tr_f.InnerHtml += th_f.ToString();
ii++;
}
TagBuilder tfoot = new TagBuilder("tfoot");
tfoot.InnerHtml = tr_f.ToString();
table.InnerHtml += tfoot.ToString();
}
#endregion

return table.ToString();
}


Второй метод добавляет на страницу необходимые ссылки на внешние скрипты, а так же встроенные непосредственно в страницу. Внимание, в примере для простоты используются внешние хранилища скриптов с сайта datatables, тогда как в пром.-проектах так делать не рекомендуется, т.к. ссылки идут на последнюю stable-версию библиотеки, которая постоянно развивается и в какой-то момент таблица может начать отображаться совсем не так, как было задумано. Итак, код:
private static string GetScript(string tableId, string element, IList<ADT_TR> columns, string AJAXLink)
{


Внешние стили и скрипты:
TagBuilder script0 = new TagBuilder("script");
script0.MergeAttribute("src", "http://datatables.net/release-datatables/media/js/jquery.dataTables.js");
script0.MergeAttribute("type", "text/javascript");

TagBuilder link0 = new TagBuilder("link");
link0.MergeAttribute("href", "http://datatables.net/release-datatables/media/css/demo_table_jui.css");
link0.MergeAttribute("rel", "stylesheet");
link0.MergeAttribute("type", "text/css");

TagBuilder script1 = new TagBuilder("script");
script1.MergeAttribute("src", "http://datatables.net/release-datatables/extras/ColVis/media/js/ColVis.js");
script1.MergeAttribute("type", "text/javascript");

TagBuilder link1 = new TagBuilder("link");
link1.MergeAttribute("href", "http://datatables.net/release-datatables/extras/ColVis/media/css/ColVis.css");
link1.MergeAttribute("rel", "stylesheet");
link1.MergeAttribute("type", "text/css");


Начинаем настраивать таблицу, локализуем текст:
TagBuilder script2 = new TagBuilder("script");
script2.MergeAttribute("type", "text/javascript");
script2.InnerHtml = "$('document').ready(function () { " +
"var oTable = $('#" + tableId + "').dataTable({ " +
"\"oLanguage\": { " +
"\"oPaginate\": { " +
"\"sFirst\": \" " + DataTableResource.FirstPage + " \", " +
"\"sLast\": \" " + DataTableResource.LastPage + " \", " +
"\"sNext\": \" " + DataTableResource.NextPage + " \", " +
"\"sPrevious\": \" " + DataTableResource.PreviousPage + " \"" +
"}, " +
"\"sInfo\": \"" + string.Format(DataTableResource.AboutMassive, element) + "\", " +
"\"sInfoEmpty\": \"" + string.Format(DataTableResource.AboutEmptyMassive, element) + "\", " +
"\"sInfoFiltered\": \"" + string.Format(DataTableResource.AboutSelectFromMassive, element) + "\", " +
"\"sLengthMenu\": \"" + DataTableResource.Select +
" <select><option value='5'>5</option><option value='10'>10</option><option value='20'>20</option><option value='50'>50</option><option value='-1'>" + DataTableResource.AllElements + "</option></select> " +
string.Format(DataTableResource.Element, element) + "\", " +
"\"sSearch\": \"" + DataTableResource.Search + "\", " +
"\"sZeroRecords\": \"" + string.Format(DataTableResource.EmptyMassive, element) + "\"" +
"}, " +
"\"aoColumns\": [";


Формируем настройки столбцов таблицы:
StringBuilder sb = new StringBuilder("");
foreach (ADT_TR tr in columns)
{
if (string.IsNullOrEmpty(tr.Title))
sb.Append(" { null }, ");
else
{
sb.Append(" {" + string.Format(" \"sTitle\": \"{0}\"", tr.Title));
if (tr.InMultiFilter.HasValue)
sb.Append(string.Format(", \"bSearchable\": {0}", tr.InMultiFilter.Value.ToString().ToLower()));
if (tr.IsSortable.HasValue)
sb.Append(string.Format(", \"bSortable\": {0}", tr.IsSortable.Value.ToString().ToLower()));
if (!string.IsNullOrEmpty(tr.Width))
sb.Append(string.Format(", \"sWidth\": \"{0}\"", tr.Width));
sb.Append(" }, ");
}
}


Настраиваем отображение вспомогательных элементов таблицы, а так же AJAX-источник:
script2.InnerHtml += sb.Remove(sb.Length - 2, 2).ToString() +
"], " +
"\"sDom\": 'C<\"clear\"><\"H\"lf>rt<\"F\"ip>', " +
"\"bJQueryUI\": true, " +
"\"oColVis\": { \"buttonText\": \"" + DataTableResource.HideShowColumns + "\" }, " +
"\"sPaginationType\": \"full_numbers\", " +
"\"bProcessing\": true, " +
"\"bServerSide\": true, " +
"\"iDisplayLength\": \"20\", " +
"\"sAjaxSource\": \"" + AJAXLink + "\" " +
"}); ";


Если хотя бы один столбец имеет включенный личный фильтр, то добавляем настройки для поиска по конкретному столбцу:
if (columns.Where(c => c.IsHaveSingleFilter.HasValue && c.IsHaveSingleFilter.Value).Count() > 0)
{
script2.InnerHtml += "var asInitVals = new Array();";

script2.InnerHtml += "$(\"tfoot input\").keyup( function () { " +
" oTable.fnFilter( this.value, $(\"tfoot input\").index(this) ); " +
"} );";

script2.InnerHtml += "$(\"tfoot input\").each( function (i) { " +
" asInitVals[i] = this.value; " +
"} );";
script2.InnerHtml += "$(\"tfoot input\").focus( function () { " +
" if ( this.className == \"search_init\" ) " +
" { " +
" this.className = \"\"; " +
" this.value = \"\"; " +
" } " +
"} );";
script2.InnerHtml += "$(\"tfoot input\").blur( function (i) { " +
" if ( this.value == \"\" ) " +
" { " +
" this.className = \"search_init\"; " +
" this.value = asInitVals[$(\"tfoot input\").index(this)]; " +
" } " +
"} );";
}

script2.InnerHtml += "}); ";
return script0.ToString() + link0.ToString() + script1.ToString() + link1.ToString() + script2.ToString();
}


Можно заметить, что в коде не встречается текста, отображаемого на странице. Я намеренно вынес его в файл с ресурсами для большего удобства эксплуатации помощника. Содержимое файла с ресурсами:
AboutEmptyMassive Нет {0} для отображения
AboutMassive Показано с _START_ по _END_ из _TOTAL_ {0}
AboutSelectFromMassive — выбрано из _MAX_ {0}
AllElements Все
Element {0}
EmptyMassive Нет {0} для отображения
FirstPage Первая
HideShowColumns Скрыть / Показать столбцы
LastPage Последняя
LoadData Данные загружаются с сервера…
NextPage Вперед
PreviousPage Назад
Search Поиск:
Select Показывать по
SingleSearch Поиск по '{0}'

AJAX-источник


Теперь осталось рассмотреть заливку данных в таблицу через Ajax-запрос. Как мы увидели на представлении, за это будет отвечать метод GetBrousersList, сложную часть функционала которого так же можно вынести в отдельный общий класс:

public JsonResult GetBrousersList()
{
NameValueCollection data = Request.QueryString;

var list = BorusersList;

var filteredlist = list.Select
(x => new[] {
x.Title,
x.Version,
x.Rating.ToString(),
x.Description
});

return Json(filteredlist.ToJSON(data), JsonRequestBehavior.AllowGet);
}


И, соответственно, метод ToJSON:
public static ResultModel ToJSON(this IEnumerable<string[]> list, NameValueCollection data)
{


Общий фильтр:
var filteredlist = list
.Where(x => string.IsNullOrEmpty(data["sSearch"])
|| x.Any(y => y.IndexOf(data["sSearch"], StringComparison.InvariantCultureIgnoreCase) >= 0
&& !string.IsNullOrEmpty(data["bSearchable_" + x.GetPosition(y)])
&& bool.Parse(data["bSearchable_" + x.GetPosition(y)]) == true));

Фильтры по столбцам:
int num = 0;
while (num >= 0)
{
if (data.AllKeys.Contains("sSearch_" + num))
{
if (!string.IsNullOrEmpty(data["sSearch_" + num]))
{
int j = num;
filteredlist = filteredlist.Where(x => x[j].IndexOf(data["sSearch_" + j], StringComparison.InvariantCultureIgnoreCase) >= 0);
}
}
else
break;
num++;
}


Сортировка и подмножество по номеру и размеру страницы:
var orderedlist =
((string)data["sSortDir_0"] == "desc" ?
filteredlist.OrderByDescending(x => (x[int.Parse(data["iSortCol_0"])])) :
filteredlist.OrderBy(x => (x[int.Parse(data["iSortCol_0"])])))
.Skip(int.Parse(data["iDisplayStart"]))
.Take(int.Parse(data["iDisplayLength"]));


Заполняем результат:
ResultModel model = new ResultModel()
{
aaData = orderedlist.ToArray(),
iTotalDisplayRecords = filteredlist.Count(),
iTotalRecords = list.Count(),
sEcho = data["sEcho"].ToString()
};

return model;


Класс модели, которую мы возвращаем, достаточно очевиден:
public class ResultModel
{
public string[][] aaData { get; set; }
public int iTotalDisplayRecords { get; set; }
public int iTotalRecords { get; set; }
public string sEcho { get; set; }
}


Модель данных


Напоследок, рассмотрим модель данных, с которой можно посмотреть работу примера, сделаем ее максимально простой:
private static List _BorusersList;
private static List BorusersList
{
get
{
if (_BorusersList == null)
{
_BorusersList = new List();
_BorusersList.Add(new Brouser() { Title = "Internet Explorer", Version = "6.0", Rating = 2,
Description = "Internet Explorer версии 6.0 является не самым лучшим выбором" });
_BorusersList.Add(new Brouser() { Title = "Internet Explorer", Version = "7.0", Rating = 3,
Description = "Internet Explorer версии 7.0 лучше своего предшественника, но хуже последующих версий" });
_BorusersList.Add(new Brouser() { Title = "Internet Explorer", Version = "8.0", Rating = 4,
Description = "Internet Explorer версии 8.0 уже не плох" });
_BorusersList.Add(new Brouser() { Title = "Internet Explorer", Version = "9.0", Rating = 4,
Description = "Internet Explorer версии 9.0 уже не плох" });
_BorusersList.Add(new Brouser() { Title = "Mozilla Firefox", Version = "3.6.13", Rating = 4,
Description = "Mozilla Firefox достойный конкурент на рынке браузеров Explorer'у" });
_BorusersList.Add(new Brouser() { Title = "My Brouser", Version = "0.0.1", Rating = 99,
Description = "Мой браузер - самый лучший, пусть у него всего одна статичная вкладка и путь к сайту правится прямо в коде =)" });
}
return _BorusersList;
}
}
private class Brouser
{
public string Title { get; set; }
public string Version { get; set; }
public int Rating { get; set; }
public string Description { get; set; }
}

Литература


Из полезных источников могу порекомендовать:
  1. Сайт DataTables, на нем присутствует описание всех возможностей библиотеки и богатый набор примеров
  2. Книгу Геннадия Смакова “jQuery Сборник рецептов”, откуда, собственно, я и узнал о библиотеке DataTables.
  3. Книгу Стивена Сандерсона и/или книгу наших соотечественников Гайдара Магданурова и Владимира Юнева по технологии MVC.