Разрабатываем TreeView используя ASP.NET Web API и AngularJS

В данной статье я опишу процесс создания дерева данных без использования сторонних компонентов. Будут использованы ASP.NET Web API и библиотека AngularJS. Для манипуляции DOM используется чистый код JavaScript (без использования дополнительных библиотек наподобие jQuery), а для доступа к данным – технология  ADO.NET (без ORM). Тем самым хочу показать как можно написать простой и понятный код без всяких излишеств.  Для начала создаём пустой проект ASP.NET 4.5.1 в Visual Studio 2013.
 

Загружаем нужные библиотеки через NuGet.



После установки всех нужных пакетов, файл конфигурации packages.config должен выглядеть так:
<?xml version="1.0" encoding="utf-8"?>
<packages>
  <package id="AngularJS.Core" version="1.2.16" targetFramework="net451" />
  <package id="Microsoft.AspNet.WebApi" version="5.1.2" targetFramework="net451" />
  <package id="Microsoft.AspNet.WebApi.Client" version="5.1.2" targetFramework="net451" />
  <package id="Microsoft.AspNet.WebApi.Core" version="5.1.2" targetFramework="net451" />
  <package id="Microsoft.AspNet.WebApi.Owin" version="5.1.2" targetFramework="net451" />
  <package id="Microsoft.AspNet.WebApi.WebHost" version="5.1.2" targetFramework="net451" />
  <package id="Microsoft.Owin" version="2.1.0" targetFramework="net451" />
  <package id="Microsoft.Owin.Host.SystemWeb" version="2.0.1" targetFramework="net451" />
  <package id="Newtonsoft.Json" version="5.0.1" targetFramework="net451" />
  <package id="Owin" version="1.0" targetFramework="net451" />
</packages>

Приложение будет построено по спецификации OWIN. Добавим конфигурационный класс Startup конвейера.
using AngularjsTreeView;
using Microsoft.Owin;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
using Owin;
using System.Net.Http.Formatting;
using System.Web.Http;
 
[assembly: OwinStartup(typeof(Startup))]
 
namespace AngularjsTreeView
{
    public class Startup
    {
        public void Configuration(IAppBuilder appBuilder)
        {
            var httpConfiguration = new HttpConfiguration();
 
            httpConfiguration.Formatters.Clear();
            httpConfiguration.Formatters.Add(new JsonMediaTypeFormatter());
 
            httpConfiguration.Formatters.JsonFormatter.SerializerSettings = 
                new JsonSerializerSettings
            {
                ContractResolver = new CamelCasePropertyNamesContractResolver()
            };
 
            httpConfiguration.Routes.MapHttpRoute(
                name: "DefaultApi",
                routeTemplate: "api/{controller}/{id}",
                defaults: new { id = RouteParameter.Optional });
 
            appBuilder.UseWebApi(httpConfiguration);
        }
    }
}

Создадим страницу для приложения, добавив разметку. Будет использована простая статическая страница.
<!DOCTYPE html>
 
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
    <title>Tree page</title>
    <script src="scripts/angular.js"></script>
    <script src="scripts/application/application.js"></script>
    <script src="scripts/application/controllers/treePageController.js"></script>
    <script src="scripts/Application/services/treePageService.js"></script>
    <script src="scripts/Application/directives/pageTreeDirective.js"></script>
    <link href="/Content/Styles/TreePage.css" rel="stylesheet" />
    <link href="/Content/Styles/PagesTree.css" rel="stylesheet" />
</head>
    < data-ng-app="application" data-ng-controller="treePageController">
        <h1>Tree view example</h1>
        <div class="treeDiv" data-ng-click="clearSelectedNode()">
            <treeview nodes="treePageItem.treeViewPageNodes" temp-data="tempData" 
                      class="treeview" />
        </div>
        <input type="button" data-ng-click="addNewNode()" class="button"
               value="Add node" />
        <input type="button" data-ng-click="removeNode()" class="button"
               value="Remove node" />
        <input type="button" data-ng-click="saveTreeData()" class="button"
               value="Save tree data" /> 
        <div data-ng-class="{updprocess: applicationBlocked}"></div>
    </>
</html>
Контроллер JavaScript для страницы
applicationModule.controller('treePageController',
    function ($scope, treePageService) {
        treePageService.get().then(function(treePageItem) {
            $scope.treePageItem = treePageItem;
        });
 
        $scope.applicationBlocked = false;
 
        $scope.tempData = {};
        $scope.tempData.selectedNode = null;
        $scope.tempData.adjacentNodes = null;
 
        $scope.clearSelectedNode = function() {
            if ($scope.tempData.selectedNode) {
                $scope.tempData.selectedNode.isSelected = false;
                $scope.tempData.selectedNode = null;
                $scope.tempData.adjacentNodes = null;
            }
        };
 
        $scope.addNewNode = function() {
            var nodeName = prompt("Please enter node name.", "Node name");
            if (!nodeName) {
                alert("Value for node name is not valid.");
            }
 
            var rootNodes = $scope.treePageItem.treeViewPageNodes;
 
            if ($scope.tempData.selectedNode) {
                rootNodes = $scope.tempData.selectedNode.childNodes;
            }
 
            var parentId = $scope.tempData.selectedNode ? $scope.tempData.selectedNode.id : null;
 
            var newNode = {
                "isExpanded": false,
                "childNodes": [],
                "id": null,
                "parentId": parentId,
                "nodeName": nodeName,
                "isSelected": false
            }
 
            rootNodes.push(newNode);
        };
 
        $scope.removeNode = function() {
            if (!$scope.tempData.selectedNode) {
                alert("Please select node for remove.");
            }
 
            var index = $scope.tempData.adjacentNodes
                .indexOf($scope.tempData.selectedNode);
 
            if (index > -1) {
                $scope.tempData.adjacentNodes.splice(index, 1);
            }
        };
 
        $scope.saveTreeData = function () {
            $scope.applicationBlocked = true;
            treePageService.update($scope.treePageItem.treeViewPageNodes)
                .success(function (treeDataMessage) {
                if (treeDataMessage.dataProcessedSuccessfully) {
                    alert("Data saved.");
                } else {
                    alert("Was error on server.");
                }
 
                $scope.applicationBlocked = false;
            });
        };
    });

и самое главное в данной статье – код директив для генерации дерева.
applicationModule.directive('treeview', function () {
    return {
        restrict: "E",
        replace: true,
        scope: {
            nodes: '=',
            tempData: '='
        },
        template: '<ul><node ng-repeat="node in nodes" ' +
            'node="node" temp-data="tempData" adjacent-nodes="nodes"></node></ul>'
    };
}
);
 
applicationModule.directive('node', [
    '$compile', function ($compile) {
        return {
            restrict: "E",
 
            replace: true,
 
            scope: {
                node: '=',
                tempData: '=',
                adjacentNodes: '='
            },
 
            template: '<li><div ng-if="node.childNodes.length > 0" ' +
                'ng-click="expandOrCollapseNode(node);" ' +
                'ng-class="{true:\'hitarea collapsable\', ' +
                'false:\'hitarea expandable\'}[node.isExpanded]"></div>' +
                '<span ng-class="{true: \'selectedNode\'}[node.isSelected]" ' +
                'ng-click="nodeClick(node, $event);" temp-data="tempData">' +
                '{{node.nodeName}}</span></li>',
 
            controller: [
                '$scope', '$element', function ($scope, $element) {
                    $scope.nodeClick = function (node, event) {
 
                        if ($scope.tempData.selectedNode) {
                            $scope.tempData.selectedNode.isSelected = false;
                        }
 
                        $scope.tempData.selectedNode = node;
                        $scope.tempData.adjacentNodes = $scope.adjacentNodes;
                        node.isSelected = true;
 
                        event.stopImmediatePropagation();
                    };
 
                    $scope.expandOrCollapseNode = function (pageNode) {
 
                        if (pageNode.isExpanded == true) {
                            pageNode.isExpanded = false;
                        } else {
                            pageNode.isExpanded = true;
                        }
                    };
                }
            ],
 
            link: function (scope, element, attrs) {
                //console.log(angular.isArray(scope.node.childNodes));
                if (angular.isArray(scope.node.childNodes)) {
                    var content = $compile('<treeview ng-if="node.isExpanded"' +
                        'nodes="node.childNodes" temp-data="tempData"></treeview>')(scope);
                    element.append(content);
                }
            }
        };
    }
]);
 
Добавим сервис для AJAX-запросов
applicationModule.factory('treePageService', function ($http, $q) {
    return {
        get: function() {
            var deferred = $q.defer();
            $http.get('/api/TreePage').success(deferred.resolve).error(deferred.reject);
            return deferred.promise;
        },
 
        update: function (treeViewPageNodes) {
            var request = $http({
                method: "post",
                url: "/api/TreePage",
                data: treeViewPageNodes
            });
 
            return request;
        }
    };
});
и контроллер Web API, который будет отвечать на них.
namespace AngularjsTreeView.Api
{
    using DataAccess;
    using DomainModel;
    using System.Collections.Generic;
    using System.Web.Http;
 
    public class TreePageController : ApiController
    {
        private readonly TreeViewDataService treeViewDataService =
            new TreeViewDataService(new TreeViewDataRepository());
 
        public TreePageItem Get()
        {
            return treeViewDataService.GetTreePageItem();
        }
 
        [HttpPost]
        public TreeDataMessage Update(IEnumerable<TreeViewPageNode> treeViewPageNodes)
        {
            return treeViewDataService.Update(treeViewPageNodes);
        }
    }
}
Создадим код серверной логики приложения
namespace AngularjsTreeView.DomainModel
{
    using DataAccess;
    using System.Collections.Generic;
    using System.Diagnostics;
    using System.Linq;
 
    public class TreeViewDataService
    {
        private readonly ITreeViewDataRepository treeViewDataRepository;
 
        public TreeViewDataService(ITreeViewDataRepository treeViewDataRepository)
        {
            this.treeViewDataRepository = treeViewDataRepository;
        }
 
        public TreePageItem GetTreePageItem()
        {
            var treePageItem = new TreePageItem();
 
            var rootTreeNode = new TreeViewPageNode();
            FillPageTreeNode(rootTreeNode, treeViewDataRepository.GetAllTreeViewNodes());
            treePageItem.TreeViewPageNodes = rootTreeNode.ChildNodes;
 
            return treePageItem;
        }
 
        public TreeDataMessage Update(IEnumerable<TreeViewPageNode> treeViewPageNodes)
        {
            var message = new TreeDataMessage() { DataProcessedSuccessfully = true };
 
            var treeViewNodes = new List<TreeViewNode>();
            FillPageTreeDataForUpdate(treeViewPageNodes.ToList(), treeViewNodes);
            treeViewDataRepository.Update(treeViewNodes);
 
            return message;
        }
 
        private void FillPageTreeNode(TreeViewPageNode parentTreeNode, 
            IList<TreeViewNode> treeViewNodes)
        {
            int? parentId = null;
 
            if (parentTreeNode.Id > 0)
                parentId = parentTreeNode.Id;
 
            parentTreeNode.ChildNodes = treeViewNodes
                .Where(node => parentId == node.ParentId).Select(node =>
            {
                var pageTreeNode = new TreeViewPageNode()
                {
                    Id = node.Id,
                    ParentId = node.ParentId,
                    NodeName = node.NodeName,
                    IsExpanded = false,
                };
 
                return pageTreeNode;
 
            }).ToList();
 
            foreach (var treeNode in parentTreeNode.ChildNodes)
            {
                FillPageTreeNode(treeNode, treeViewNodes);
            }
        }
 
        private void FillPageTreeDataForUpdate(IList<TreeViewPageNode> treeViewPageNodes, 
            List<TreeViewNode> treeViewNodes)
        {
            foreach (var treeViewPageNode in treeViewPageNodes)
            {
                Trace.WriteLine(treeViewPageNode.Id);
                if (treeViewPageNode.ChildNodes != null && treeViewPageNode.ChildNodes.Any())
                {
                    FillPageTreeDataForUpdate(treeViewPageNode.ChildNodes.ToList(), treeViewNodes);
                }
 
                treeViewNodes.Add(treeViewPageNode);
            }
        }
    }
}

и код доступа к хранилищу.
namespace AngularjsTreeView.DataAccess
{
    using DomainModel;
    using Microsoft.SqlServer.Server;
    using System.Collections.Generic;
    using System.Data;
    using System.Data.SqlClient;
 
    public class TreeViewDataRepository : RepositoryBase, ITreeViewDataRepository
    {
        #region ITreeViewDataRepository Members
 
        public IList<TreeViewNode> GetAllTreeViewNodes()
        {
            var sqlConnection = new SqlConnection(ConnectionString);
            var sqlCommand = new SqlCommand("dbo.GetTreeViewData", sqlConnection)
            {
                CommandType = CommandType.StoredProcedure,
            };
 
            var resultList = new List<TreeViewNode>();
 
            try
            {
                sqlConnection.Open();
                using (SqlDataReader reader = sqlCommand.ExecuteReader())
                {
                    while (reader.Read())
                    {
                        var item = new TreeViewNode();
 
                        item.Id = int.Parse(reader["Id"].ToString());
                        item.ParentId = reader["ParentId"].TryParseToInt();
                        item.NodeName = reader["NodeName"].ToString();
 
                        resultList.Add(item);
                    }
                }
            }
 
            finally
            {
                sqlConnection.Close();
            }
 
            return resultList;
        }
 
        public void Update(IList<TreeViewNode> treeViewNodes)
        {
            var sqlConnection = new SqlConnection(ConnectionString);
            var sqlCommand = new SqlCommand("dbo.UpdateTreeViewData", sqlConnection)
            {
                CommandType = CommandType.StoredProcedure,
            };
 
            sqlCommand.Parameters.Add("@p_TreeViewData", SqlDbType.Structured);
            var list = new List<SqlDataRecord>();
 
            foreach (var treeViewNode in treeViewNodes)
            {
                var sqlDataRecord = new SqlDataRecord(
                    new SqlMetaData("Id", SqlDbType.Int),
                    new SqlMetaData("ParentId", SqlDbType.Int),
                    new SqlMetaData("NodeName", SqlDbType.NVarChar, 50),
                    new SqlMetaData("IsSelected", SqlDbType.Bit));
 
                sqlDataRecord.SetValue(0, treeViewNode.Id);
                sqlDataRecord.SetValue(1, treeViewNode.ParentId);
                sqlDataRecord.SetString(2, treeViewNode.NodeName);
                sqlDataRecord.SetValue(3, treeViewNode.IsSelected);
 
                list.Add(sqlDataRecord);
            }
 
            sqlCommand.Parameters["@p_TreeViewData"].Value = list;
 
            try
            {
                sqlConnection.Open();
                sqlCommand.ExecuteNonQuery();
            }
 
            finally
            {
                sqlConnection.Close();
            }
        }
 
        #endregion
    }
}
В качестве хранилища используется SQL Server LocalDB.



Используется одна таблица и пара процедур.
CREATE PROCEDURE [dbo].[GetTreeViewData]
AS
	SELECT * FROM dbo.TreeViewData
RETURN 0
 
CREATE PROCEDURE [dbo].[UpdateTreeViewData] 
	@p_TreeViewData udtt_TreeViewData READONLY
AS
BEGIN
	MERGE TreeViewData AS target
	USING(SELECT * FROM @p_TreeViewData) AS SOURCE
	ON target.Id = SOURCE.Id
	WHEN MATCHED THEN 
        UPDATE SET target.ParentId = SOURCE.ParentId
	WHEN NOT MATCHED BY TARGET AND SOURCE.Id IS NULL  THEN
		INSERT(ParentId, NodeName)
		VALUES(SOURCE.ParentId, SOURCE.NodeName)
	WHEN NOT MATCHED BY SOURCE THEN DELETE;
END

Остальные мелочи не буду приводить, дабы не загромождать код. Осталось всё это запустить и увидеть.



Готовый код можно скачать отсюда.