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

ru-RU | created at: 7/15/2013 | published: 7/15/2013 | updated at: 1/1/2018 | number of views: 7061

В этой статье будем строить форму Master/Detail на JavaScript с использованием KnockoutJs. Цель статьи: практическое применения контрола DataSource из nuget-пакета JsSite с ASP.NET Web API.

Подготовим проект

1. Создаем новый проект по шаблону ASP.NET MVC 4 (я выбрал “basic”). На момент создания статьи ASP.NET MVC 5 находится в статусе beta. Изучив указанные нововведения, могу предложить, что пример будет работать и версией MVC 5.

2. Запускаем процедуру обновления всех nuget-пакетов. Лог обработки команды показывать не буду.

3. Устанавливаем недостающие пакеты. Во-первых, пакет JsSite для начала:

PM> Install-Package jssite
Attempting to resolve dependency 'toastr (≥ 1.1.4.2)'.
Attempting to resolve dependency 'jQuery (≥ 1.6.3)'.
Attempting to resolve dependency 'AmplifyJS (≥ 1.1.0)'.
Attempting to resolve dependency 'knockoutjs (≥ 2.2.1)'.
Attempting to resolve dependency 'Knockout.Mapping (≥ 2.4.0)'.
Attempting to resolve dependency 'Knockout.Validation (≥ 1.0.1)'.
Attempting to resolve dependency 'underscore.js (≥ 1.4.3)'.
Attempting to resolve dependency 'Moment.js (≥ 1.7.2)'.
Attempting to resolve dependency 'infuser (≥ 0.2.1)'.
Attempting to resolve dependency 'TrafficCop (≥ 0.3.0)'.
Attempting to resolve dependency 'Knockout.js_External_Template_Engine (≥ 2.0.0)'.
Installing 'Knockout.js_External_Template_Engine 2.0.5'.
Successfully installed 'Knockout.js_External_Template_Engine 2.0.5'.
Installing 'JsSite 0.6.1'.
Successfully installed 'JsSite 0.6.1'.
Adding 'Knockout.js_External_Template_Engine 2.0.5' to JsSiteDataSourceDemo.
Successfully added 'Knockout.js_External_Template_Engine 2.0.5' to JsSiteDataSourceDemo.
Adding 'JsSite 0.6.1' to JsSiteDataSourceDemo.
Successfully added 'JsSite 0.6.1' to JsSiteDataSourceDemo.

PM>

Далее устанавливаем SampleData, чтобы были данные, с которыми можно ставить эксперименты:

PM> Install-Package SampleData
Installing 'SampleData 1.2.2'.
Successfully installed 'SampleData 1.2.2'.
Adding 'SampleData 1.2.2' to JsSiteDataSourceDemo.
Successfully added 'SampleData 1.2.2' to JsSiteDataSourceDemo.

PM>

А еще для разбивки данных на страницы нам потребуется PagedListExt:

PM> Install-Package PagedListExt
Installing 'PagedListExt 0.6.6'.
Successfully installed 'PagedListExt 0.6.6'.
Adding 'PagedListExt 0.6.6' to JsSiteDataSourceDemo.
Successfully added 'PagedListExt 0.6.6' to JsSiteDataSourceDemo.

PM>

4. Теперь правим BundleConfig.cs, чтобы подключить установленные скрипты. У меня после правки стал выглядеть следующим образом:

public static void RegisterBundles(BundleCollection bundles) {
    bundles.Add(new ScriptBundle("~/bundles/jquery").Include(
                "~/Scripts/jquery-{version}.js"));

    bundles.Add(new ScriptBundle("~/bundles/third").Include(
                "~/Scripts/globalize.js",
                "~/Scripts/cultures/globalize.culture.ru.js",
                "~/Scripts/cultures/globalize.culture.ru-RU.js",
                "~/Scripts/cultures/moment.min.js",
                "~/Scripts/cultures/toastr.min.js",
                "~/Scripts/cultures/underscore.min.js",
                "~/Scripts/underscore.js",
                "~/Scripts/moment.js",
                "~/Scripts/infuser.js",
                "~/Scripts/TrafficCop.js",
                "~/Scripts/amplify.js"));

    bundles.Add(new ScriptBundle("~/bundles/knockout").Include(
                "~/Scripts/knockout-2.2.1.debug.js",
                "~/Scripts/knockout.mapping-latest.debug.js",
                "~/Scripts/koExternalTemplateEngine.js",
                "~/Scripts/knockout.command.js",
                "~/Scripts/knockout.dirtyFlag.js",
                "~/Scripts/knockout.validation.debug.js"));

    bundles.Add(new ScriptBundle("~/bundles/bootstrap").Include(
                "~/bootstrap/js/bootstrap.js"));

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

    bundles.Add(new ScriptBundle("~/bundles/jqueryui").Include(
                "~/Scripts/jquery-ui-{version}.js"));

    bundles.Add(new ScriptBundle("~/bundles/jqueryval").Include(
                "~/Scripts/jquery.unobtrusive*",
                "~/Scripts/jquery.validate*"));

    // Use the development version of Modernizr to develop with and learn from. Then, when you're
    // ready for production, use the build tool at http://modernizr.com to pick only the tests you need.
    bundles.Add(new ScriptBundle("~/bundles/modernizr").Include(
                "~/Scripts/modernizr-*"));

    bundles.Add(new StyleBundle("~/bootstrap/css")
        .Include("~/bootstrap/css/bootstrap.css")
        .Include("~/bootstrap/css/bootstrap-responsive.css"));

    bundles.Add(new StyleBundle("~/Content/css").Include("~/Content/site.css"));

    bundles.Add(new StyleBundle("~/Content/themes/base/css").Include(
                "~/Content/themes/base/jquery.ui.core.css",
                "~/Content/themes/base/jquery.ui.resizable.css",
                "~/Content/themes/base/jquery.ui.selectable.css",
                "~/Content/themes/base/jquery.ui.accordion.css",
                "~/Content/themes/base/jquery.ui.autocomplete.css",
                "~/Content/themes/base/jquery.ui.button.css",
                "~/Content/themes/base/jquery.ui.dialog.css",
                "~/Content/themes/base/jquery.ui.slider.css",
                "~/Content/themes/base/jquery.ui.tabs.css",
                "~/Content/themes/base/jquery.ui.datepicker.css",
                "~/Content/themes/base/jquery.ui.progressbar.css",
                "~/Content/themes/base/jquery.ui.theme.css"));
}

Следует иметь в виду, что все скрипты (или почти все), которые будут созданы в процессе работы над статьёй будут добавляться в bundle/site (строка 29).

5. Теперь надо создать HomeController и представление Index. Дело в том, что из всех любезно предложенных студией шаблонов, я выбрал для своего проекта Basic шаблон, который не включает в себя куча разных ненужных “ненужностей”, но при этом не имеет контролера по умолчанию. Хотя в настройках маршрутов RouteConfig.cs, название контролера по умолчанию указано “Home”.

Проект готов к работе. Теперь можно приступить непосредственно к реализации Master/Details паттерну. На первом этапе создадим сервисы Web API.

Сервисы WEB API

Первый из них будет работать с классом Person из сборки SampleData. Пусть для начала он выглядит таким образом:

public class PersonApiController : ApiController {

    private List _listOfPerson = new List();

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

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

    public HttpResponseMessage PostPerson(Person person) {
        throw new NotImplementedException();
    }

    public HttpResponseMessage PutPerson(Person person) {
        throw new NotImplementedException();
    }

    public HttpResponseMessage DeletePerson(Person person) {
        throw new NotImplementedException();
    }
}

Class site.m.Person.js

Перед тем как создать сам файл сервиса, надо бы создать класс (ViewModel) как ViewModel для Person на Javascript:

(function (site, ko) {

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

        me.age = ko.observable(data.Age);
        me.country = ko.observable(data.Country);
        me.departmentId = ko.observable(data.DepartmentId);
        me.description = ko.observable(data.Description);
        me.id = ko.observable(data.Id);
        me.gender = ko.observable(data.Gender);
        me.isMember = ko.observable(data.IsMember);
        me.name = ko.observable(data.Name);
        me.weight = ko.observable(data.Weight);

        me.selected = ko.observable(false);

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

Строки 6-14 определяют существующие поля и свойства класса Person, который мы взяли из пакета SampleData.

Строка 16 выполняет требование DataSource контрола, который, по нашей договоренности, может установить это свойство в значение “true”, если пользователь сделает выбор этой сущности (дальше будет понятнее).

Не забудьте добавить ссылку на этот файл в представлении (view) или в BundleConfig.cs.

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

ViewModel для представления (View)

Наверное, вы уже обратили внимание на то что, в строке 4 предыдущего листинга присутствует ссылка на файл site.vm.homeIndex.js? Это как раз тот самый viewModel, который мы должны создать в этом разделе. Этот файл (ViewModel) является отправной точкой, именно он запускает весь механизм. На текущий момент его содержимое такое:

/// <reference path="site.controls.js" />
/// <reference path="site.core.js" />
/// <reference path="../knockout-2.2.1.debug.js" />


$(function() {

    "use strict";

    site.vm.homeIndex = function () {
        var clock = new site.controls.Clock(),
            dsPerson = new site.controls.DataSource({
                autoLoad: true,
                service : site.services.person
            });

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


    ko.applyBindings(site.vm.homeIndex);
});

В строке 11 создаем объект “часы”. Как я уже упоминал статьях опубликованных ранее, это делается для того, чтобы проверить, что необходимые скрипты загружены на странице, и не просто загружены, а “правильной” последовательности. Это своего рода тест конфигурации скриптов на странице. Я в разметку Index.cshtml добавил span-тег, чтобы часы заработали и запустил проект.

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

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

Часы заработали. Далее добавил строки 12-15.

В строке 12-15 создаем экземпляр контрола DataSource. Параметрами DataSource для него служат autoLoad (необязательный) со значение true и service (обязательный) со значением site.services.person. Этот сервис мы будем создаем в следующем разделе.

Сервис для DataSource

Скажу честно, я для создания сервисов для DataSource использую шаблон (snippet или Template от Resharper) в силу того, что для различных сущностей сервисы практически не отличаются. Вот полный текст работающего сервиса:

/// <reference path="site.core.js" />


(function(site) {

    site.services.person = function() {
        var init = function() {
            site.amplify.request.define("getperson", "ajax", {
                url: "/api/personapi",
                dataType: "json",
                type: "GET",
                cache: false
            });
            site.amplify.request.define("postperson", "ajax", {
                url: "/api/personapi",
                dataType: "json",
                contentType: "application/json; charset=utf-8",
                type: "POST",
                cache: false
            });
            site.amplify.request.define("putperson", "ajax", {
                url: "/api/personapi",
                dataType: "json",
                contentType: "application/json; charset=utf-8",
                type: "PUT",
                cache: false
            });
            site.amplify.request.define("delperson", "ajax", {
                url: "/api/personapi",
                dataType: "json",
                contentType: "application/json; charset=utf-8",
                type: "DELETE",
                cache: false
            });
        },
            mapItem = function(data) {
                return new site.m.Person(data);
            },
            mapItems = function(data) {
                var mapped = [];
                site._.each(data, function(item) {
                    mapped.push(mapItem(item));
                });
                return mapped;
            },
            getData = function(params, back) {
                if (typeof back !== "function") throw new Error("callback not a function");
                if (!params) throw new Error("queryParams notis null");
                return site.amplify.request({
                    resourceId: "getperson",
                    data: { qp: ko.toJSON(params) },
                    success: function(json) {
                        if (json) {
                            if (json.success) {
                                params.total(json.total);
                                var result = mapItems(json.items);
                                back(result);
                                return;
                            }
                            if (json.warning) {
                                site.logger.warning(json.warning);
                            }
                            if (json.error) {
                                site.logger.error(json.error);
                            }
                        }
                        back();
                    },
                    error: function() {
                        site.logger.error("Ошибка загрузки сущности\
                            \"Пользователь\" (method \"get\") Person");
                        back();
                        return;
                    }
                });
            },
            getDataById = function(params, back) {
                if (typeof back !== "function") throw new Error("callback not a function");
                if (!params) throw new Error("queryParams notis null");
                return site.amplify.request({
                    resourceId: "getperson",
                    data: { id: params },
                    success: function(json) {
                        if (json) {
                            if (json.success) {
                                var result = mapItem(json.item);
                                back(result);
                                return;
                            }
                            if (json.warning) {
                                site.logger.warning(json.warning);
                            }
                            if (json.error) {
                                site.logger.error(json.error);
                            }
                        }
                        back();
                    },
                    error: function() {
                        site.logger.error("Ошибка загрузки сущности \
                        \"Должность\" (method \"get\") Person");
                        back();
                        return;
                    }
                });
            },
            postData = function(params, back) {
                if (typeof back !== "function") throw new Error("callback not a function");
                return site.amplify.request({
                    resourceId: "postperson",
                    data: ko.toJSON(params),
                    success: function(json) {
                        if (json) {
                            if (json.success) {
                                site.logger.success(json.success);
                                back(new mapItem(json.item));
                                return;
                            }
                            if (json.warning) {
                                site.logger.warning(json.warning);
                            }
                            if (json.info) {
                                site.logger.info(json.info);
                            }
                            if (json.error) {
                                site.logger.error(json.error);
                            }
                        }
                        back();
                    },
                    error: function() {
                        site.logger.error("Ошибка сохранения сущности\
                         \"Пользователь\" (method \"post\") Person");
                        back();
                        return;
                    }
                });
            },
            putData = function(params, back) {
                if (typeof back !== "function") throw new Error("callback not a function");
                return site.amplify.request({
                    resourceId: "putperson",
                    data: ko.toJSON(params),
                    success: function(json) {
                        if (json) {
                            if (json.success) {
                                site.logger.success(json.success);
                                back(new mapItem(json.item));
                                return;
                            }
                            if (json.warning) {
                                site.logger.warning(json.warning);
                            }
                            if (json.error) {
                                site.logger.error(json.error);
                            }
                        }
                        back();
                    },
                    error: function() {
                        site.logger.error("Ошибка обновления сущности\
                         \"Пользователь\" (method \"put\") Person");
                        back();
                        return
                    }
                });
            },
            delData = function(params, back) {
                if (typeof back !== "function") throw new Error("callback not a function");
                return site.amplify.request({
                    resourceId: "delperson",
                    data: ko.toJSON(params),
                    success: function(json) {
                        if (json) {
                            if (json.success) {
                                site.logger.success(json.success);
                                back(new mapItem(json.item));
                                return;
                            }
                            if (json.warning) {
                                site.logger.warning(json.warning);
                            }
                            if (json.error) {
                                site.logger.error(json.error);
                            }
                        }
                        back();
                    },
                    error: function() {
                        site.logger.error("Ошибка удаления сущности\
                             \"Пользователь\" (method \"del\") Person");
                        back();
                        return;
                    }
                });
            };

        init();

        return {
            getDataById: getDataById,
            postData: postData,
            getData: getData,
            putData: putData,
            delData: delData
        };
    }();

})(site);

Теперь всё готово для первого запуска.

131-0

Заключение

В качестве заключения скажу следующее, в данной части мы подготовили проект, а именно: Web API сервис, ViewModel на JavaScript для класса Person, JavaScript обертку для Web API, главную страницу, ViewModel для главной странице.

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