ASP.NET MVC: DataSource на JavaScript или обертка на Web API сервис (часть 2)

ru-RU | создано: 16.07.2013 | опубликовано: 16.07.2013 | обновлено: 01.01.2018 | просмотров за всё время: 5750

В прошлой части статьи была проделана огромная работа по подготовке проекта к дальнейшему функционалу. В этой части будем доводить до логического завершения начатое. Добавим пейджинг, сделаем фильтрацию, “прикрутим” дополнительную детализацию.

Что к чему

В прошлой статье есть ссылка на проект, его мы и будем доводить до ума. В противном случае, вы можете скачать уже обновленный проект и поэкспериментировать с ним (ссылка в конце статьи)

Pager

Чтобы заработал пейджинг, надо добавить функционал разбития на страницы на стороне Web API. Добавим обработку Index и Size:


public HttpResponseMessage GetPersons(JsonQueryParams query) {
    var size = query.Size.HasValue ? query.Size.Value : 10;
    var items = _listOfPerson.OrderBy(x => x.Name).AsQueryable();
    if (query.Index.HasValue) {
        items = items.Skip(size * query.Index.Value).Take(size);
    }

    return Request.CreateResponse(HttpStatusCode.OK,
                                    new {
                                        success = "все данные",
                                        total = _listOfPerson.Count,
                                        items = items.ToList()
                                    });
}

После доработки наша страница отобразила только 10 записей.

134-0

Чтобы а нас появился пейджер, надо просто немного поправить html-разметку. Я добавил одну строку под таблицу:

@{
    ViewBag.Title = "DataSource: Master/Details";
}

<span data-bind="text: clock.time"></span>

<table>
    <thead>
        <tr>
            <th>Name</th>
            <th>Age</th>
            <th>Weight</th>
        </tr>
    </thead>
    <tbody data-bind="foreach: dsPerson.items">
        <tr>
            <td data-bind="text: name"></td>
            <td data-bind="text: age"></td>
            <td data-bind="text: weight"></td>
        </tr>
    </tbody>
</table>

<div data-bind="pager: dsPerson"></div>

@section scripts
{
    <script src="~/Scripts/app/site.m.person.js"></script>
    <script src="~/Scripts/app/site.vm.homeIndex.js"></script>
}

Строка 24 подключила пейджер на страницу:

134-1

Внешний вид или ода Twitter Bootstrap

Немного не приглядный вид, давайте подключим какой-нибудь стиль или несколько. Я воспользуюсь Twitter Bootstrap. Я скачал bootstrap.zip распаковал его в папку bootstrap и добавил ссылки на файлы в BundleConfig. Запистил проект, нажал F5 и …:

134-2

BusyIndicator

Теперь более приглядный вид. Добавим немного WEB 2.0, то есть увеличим индекс дружелюбности :) Для этого выведем индикатор обработки запроса.

134-3

Для того чтобы заработал BusyIndicator, я немного поправил Index.cshtml:

<div data-bind="blockUI: dsPerson.indicator">

    <table class="table table-bordered">
        <thead>
            <tr>
                <th>Name</th>
                <th>Age</th>
                <th>Weight</th>
            </tr>
        </thead>
        <tbody data-bind="foreach: dsPerson.items">
            <tr>
                <td data-bind="text: name"></td>
                <td data-bind="text: age"></td>
                <td data-bind="text: weight"></td>
            </tr>
        </tbody>
    </table>

    <div data-bind="pager: dsPerson"></div>
</div>

А если быть точнее, то я просто обернул весь (смотри строка 1 и 21) контент в div, который блокируется, чтобы избежать команд пользователя во время выполнения запроса.

Управления размером страниц (Pager size)

Я добавил еще немного html-разметки:

<span class="pull-right" data-bind="if: dsPerson.hasItems()">
    <span class="icon-eye-open"></span>
    <select data-bind="options: site.cfg.pageSizes, 
                       value: dsPerson.queryParams.size"></select>
    <span class="icon-filter"></span><span data-bind="    
                       text: dsPerson.queryParams.total"></span>
</span>

И после этого, у меня появилась возможность выбрать размер страницы, а так же смотреть общее количество записей. Вот установлен размер страницы равный 5:

134-4

Простая фильтрация на основе QueryParams

А теперь добавим возможность фильтровать пользователей по имени. Для это надо доработать метод сервиса:

public HttpResponseMessage GetPersons(JsonQueryParams query) {
    var items = _listOfPerson.OrderBy(x => x.Name).AsQueryable();
    if (query != null) {
        var size = query.Size.HasValue ? query.Size.Value : 10;
        if (query.Filters != null && query.Filters.FilterParams.Select(x => x.Name).Contains("Name")) {
            var param = query.Filters.FilterParams.FirstOrDefault(x => x.Name.Equals("Name"));
            if (param != null && param.Value != null) {
                var filter = param.Value.ToString();
                if (!string.IsNullOrEmpty(filter)) {
                    items = items.Where(x => x.Name.Contains(filter));
                }
            }
        }
        if (query.Index.HasValue && items.Count() > size) {
            items = items.Skip(size * query.Index.Value).Take(size);
        }
    }
    return Request.CreateResponse(HttpStatusCode.OK,
                                    new {
                                        success = "все данные",
                                        total = items.Count(),
                                        items = items.ToList()
                                    });
}

Следует обратить внимание на строки 5-13, где проверяется наличие параметра в списке фильтров. После этого надо расширить queryParams для DataSource:

site.vm.homeIndex = function () {
    var clock = new site.controls.Clock(),
        queryParamsFilter = {
            "filters": {
                "logicalOperator": "And",
                "filterParams": [
                    {
                        "Name": "Name",
                        "Operator": "Contains",
                        "Value": ko.observable(),
                        "DisplayName": "Имя"
                    }
                ]
            }
        },
        dsPerson = new site.controls.DataSource({
            autoLoad: true,
            service: site.services.person
        }, queryParamsFilter);

    dsPerson.queryParams.filters.filterParams[0].Value.subscribe(function () {
        dsPerson.getData();
    });

    return {
        dsPerson: dsPerson,
        clock: clock
    };
}();

Строка 3-15: Создаем объект для переопределения настроек по умолчанию для QueryParams, который является структурированным параметром для DataSource.

Строка 10: Указываем, что параметр должен ko.observable().

Строка 21-23: Подписываемся на обновления параметра. При обновлении происходит перезагрузка данных. Если учесть, что измененный параметр (в силу магии KnockoutJs) сразу же применяется к QueryParams, то нам достаточно просто перезапросить новый набор данных с учетом фильтра.

Осталось добавить поле для ввода значения фильтра:

<input type="text" data-bind="value: dsPerson.queryParams.filters.filterParams[0].Value,
          valueUpdate: 'afterkeydown'" />

Поле ввода напрямую привязываю к параметру фильтрации и запускаю приложение. Для того чтобы обновить скрипты на странице, нажимаю F5 и вводу букву “J” в поле фильтра:

134-10

Master/Details

Как известно, в связки “Master/Details” используется два источника данных. А зависимость между ними сводится к простой формуле: “Обновился главный – обнови зависимые”. В нашем примере уже есть один источник данных, для второго придется сделать практически те же самые манипуляции: Web API сервис, JavaScript обертку и всё остальное.

Хорошим примером для построения такой зависимости, я построю связку на двух классах из пакета SampleData. Класс Person и класс Department связаны по типу связи “мастер/детализация”. Создадим Web API контролер для класса Department:

public class DepartmentApiController : ApiController {

    private readonly List<Department> _listOfPerson = new List<Department>();

    public DepartmentApiController() {
        _listOfPerson.AddRange(People.GetDepartments());
    }

    public HttpResponseMessage GetDepartments(JsonQueryParams query) {
        var items = _listOfPerson.OrderBy(x => x.Name).AsQueryable();
        var total = _listOfPerson.Count;
        if (query != null) {
            var size = query.Size.HasValue ? query.Size.Value : 10;
            if (query.Filters != null
                && query.Filters
                .FilterParams
                .Select(x => x.Name)
                .Contains("Name")) {
                var param = query.Filters.FilterParams
                    .FirstOrDefault(x => x.Name.Equals("Name"));
                if (param != null && param.Value != null) {
                    var filter = param.Value.ToString();
                    if (!string.IsNullOrEmpty(filter)) {
                        items = items.Where(x => x.Name.Contains(filter));
                        total = items.Count();
                    }
                }
            }
            if (query.Index.HasValue && items.Count() > size) {
                items = items.Skip(size * query.Index.Value).Take(size);
            }
        }
        return Request.CreateResponse(HttpStatusCode.OK,
                                        new {
                                            success = "все данные",
                                            total,
                                            items = items.ToList()
                                        });
    }

    public HttpResponseMessage GetDepartment(int id) {
        return Request.CreateResponse(HttpStatusCode.OK,
                                        new {
                                            success = "один по идентификатору",
                                            item = _listOfPerson.Where(x => x.Id.Equals(id))
                                        });
    }

    public HttpResponseMessage PostDepartment(Department department) {
        throw new NotImplementedException();
    }

    public HttpResponseMessage PutDepartment(Department department) {
        throw new NotImplementedException();
    }

    public HttpResponseMessage DeleteDepartment(Department department) {
        throw new NotImplementedException();
    }
}

Web API cервис успешно запустился, теперь сделаем JavaScript-обертка сервиса. Я не буду приводить его код потому что, практические нет никакого отличия от сервиса для Web API Person. Я также добавил упоминание о нем в файл BundleConfig.cs (строка 7).

bundles.Add(new ScriptBundle("~/bundles/site").Include(
            "~/Scripts/app/site.core.js",
            "~/Scripts/app/site.core.js",
            "~/Scripts/app/site.controls.js",
            "~/Scripts/app/site.bindingHandlers.js",
            "~/Scripts/app/site.services.person.js",
            "~/Scripts/app/site.services.department.js",
            "~/Scripts/app/site.utils.js"));

В сервисе site.services.department.js упоминается site.m.Department, и мне потребуется создать класс ViewModel на JavaScript для Department:

(function (site, ko) {

    site.m.Department = function (dto) {
        var me = this, data = dto || {};

        me.id = ko.observable(data.Id);
        me.name = ko.observable(data.Name);

        me.selected = ko.observable(false);

        return me;
    };
})(site, ko)

Для того чтобы показать два источника данных рядом, я немного поправил html-разметку, предварительно добавив код для отображения dsDepartment:

<div data-bind="blockUI: dsDepartment.indicator" class="span6">
    <div class="pull-left">
        <i class="icon-filter"></i>
        <input type="text" 
               data-bind="value: dsDepartment.queryParams.filters.filterParams[0].Value,
                    valueUpdate: 'afterkeydown'" class="span2" />
    </div>

    <div class="pull-right" data-bind="if: dsDepartment.hasItems()">
        <i class="icon-eye-open"></i>
        <select data-bind="options: site.cfg.pageSizes,
                value: dsDepartment.queryParams.size" class="span1"></select>
        <i class="icon-filter"></i>
        <span data-bind="text: dsDepartment.queryParams.total"></span>
    </div>

    <table class="table table-bordered">
        <thead>
            <tr>
                <th>Name</th>
            </tr>
        </thead>
        <tbody data-bind="foreach: dsDepartment.items">
            <tr>
                <td data-bind="text: name"></td>
            </tr>
        </tbody>
    </table>

    <div data-bind="pager: dsDepartment"></div>
</div>

Отличия от dsPerson вообще никакого нет. (В будущем планируется сделать контрол типа GridView для DataSource). После всех нововведений мне остается во ViewModel страницы добавить еще один DataSource, тот самый – dsDepartment:

site.vm.homeIndex = function () {
    var clock = new site.controls.Clock(),
        queryParamsFilter = {
            "filters": {
                "logicalOperator": "And",
                "filterParams": [
                    {
                        "Name": "Name",
                        "Operator": "Contains",
                        "Value": ko.observable(),
                        "DisplayName": "Имя"
                    }
                ]
            }
        },
        queryParamsFilter0 = {
            "filters": {
                "logicalOperator": "And",
                "filterParams": [
                    {
                        "Name": "Name",
                        "Operator": "Contains",
                        "Value": ko.observable(),
                        "DisplayName": "Имя"
                    }
                ]
            }
        },
        dsPerson = new site.controls.DataSource({
            autoLoad: true,
            service: site.services.person
        }, queryParamsFilter),
        dsDepartment = new site.controls.DataSource({
            autoLoad: true,
            service: site.services.department
        }, queryParamsFilter0);

    dsPerson.queryParams.filters.filterParams[0].Value.subscribe(function () {
        dsPerson.getData();
    });
        
    dsDepartment.queryParams.filters.filterParams[0].Value.subscribe(function () {
        dsDepartment.getData();
    });

    return {
        dsDepartment: dsDepartment,
        dsPerson: dsPerson,
        clock: clock
    };
  51:  }();

Строка 16-28: Создаем параметр для dsDepartment.

Строка 33-36: Создаем еще один DataSource, параметром для service передаем наш свежеиспеченный site.services.department.

Строка 47: Не забываем “вытащить” наружу объект для UI.

В результате можно увидеть следующее:

134-11

У нас на данный момент два DataSource, которые не связаны ни коим образом между собой, но у обоих уже работает постраничная выборка и минимальная фильтрация по полю name.

Selected = true? Легко!

1. Для начала надо отключить автоматическую загрузку записей для dsPerson установив свойство autoLoad в значение false.

2. Теперь надо немного подправить разметку для dsDepartment. Надо сделать чтобы при клике на запись Department эта запись становилась выбранной, то есть свойство selected получало значение true.

<table class="table table-bordered">
    <thead>
        <tr>
            <th>Name</th>
        </tr>
    </thead>
    <tbody data-bind="foreach: dsDepartment.items">
        <tr data-bind="css: {'info':selected}, click: $parent.dsDepartment.select">
            <td data-bind="text: name"></td>
        </tr>
    </tbody>
</table>

Строка 8: Привязывает событие click на изменение свойства selected. А также визуально подсвечиваем выбранную строку устанавливая CSS для этой строки в значение info.

134-12

Осталось совсем немного: надо добавить параметр DepartmentId в dsPerson и подписаться на изменение этого значения у dsDepartment.

Новый параметр для dsPerson выглядит таким образом (строка 11-16):

queryParamsFilter = {
    "filters": {
        "logicalOperator": "And",
        "filterParams": [
            {
                "Name": "Name",
                "Operator": "Contains",
                "Value": ko.observable(),
                "DisplayName": "Имя"
            },
            {
                "Name": "DepartmentId",
                "Operator": "IsEqualTo",
                "Value": ko.observable(),
                "DisplayName": "Идентификатор подразделения"
            }
        ]
    }
},

У DataSource (dsDepartment) подписываемся на событие выбора Department (строка 5):

dsDepartment = new site.controls.DataSource({
    autoLoad: true,
    service: site.services.department,
    events: {
        selectedHandler: reloadPersons
    }
}, queryParamsFilter0);

Конструкция работает так, как и предполагалось: При выборе подразделения, происходит обновление dsPerson.

134-13

Кстати, не забудьте добавить обработку параметра DepartmentId в Web API сервисе.

Заключение

DataSource достаточно гибкий контрол для работы на UI. Он может очень многое, например:

  • добавлять
  • удалять
  • редактировать
  • получать список
  • получать под ID
  • выбирать
  • работать с коллекцией объектов (не Web API)

В настоящий момент уже существуют некоторые вспомогательные контролы, которые дополняют функционал DataSource:

  • FormView – контрол для отображения модального окна с подменяемым шаблоном.
  • FormEdit – контрол для редактирования в модальном окне сущности с отслеживанием статуса изменения свойств сущности.
  • TreeView – контрол для отображение древовидной структоры.
  • DbLookUp – контрол подбора комплексных сущностей при редактировании.