Существует вполне обоснованное мнение, что найденный в Интернете чужой код намного лучше собственноручно написанного, т.к. его уже оттестировали тысячи ленивых разработчиков. Собственно поэтому, когда передо мной возникла задача, описанная в названии статьи, я решил не изобретать велосипед, а найти готовое решение. К моему удивлению, ни на англоязычных, ни на русскоязычных ресурсах ничего подходящего под мои запросы на основе ангуляра я не нашел. Поэтому было принято решение написать код самостоятельно и поделиться им с общественностью.
Возможности меню, реализованные в данной статье:
- Вся начинка меню спрятана под капотом директивы. При верстке html страницы указывается лишь DOM-элемент с директивой, что повышает читабельность кода.
- У меню есть возможность создавать пункты с бесконечным уровнем вложенностей.
- Подсветка активной страницы в меню осуществляется не только на первом уровне, но и на любом уровне вложенности.
- Возможность зарегистрировать пункт меню на этапе конфигурации приложения.
- Возможность отображения/сокрытия конкретных пунктов меню в зависимости от прав доступа текущего пользователя.
Исходный код директивы можно посмотреть тут.
Писать все с нуля, естественно, я не стал, поэтому ниже привожу список позаимствованных материалов:
- AngularJS — супергероический фреймворк от гугла, реализующий MVVM шаблон проектирования архитектуры приложения.
- UI-router — ангуляровский модуль, без которого немыслимо проектирование приложений, основанных на состояниях.
- Angular-permission — ангуляровский модуль (работает только в паре с ui-router), упрощающий контроль доступа и авторизацию на стороне клиента.
- Bootstrap 3 — CSS-фреймворк, ускоряющий верстку адаптивных страниц.
- Yeoman-генератор — консольная утилита для автоматического построения структуры проекта.
- Bower — менеджер пакетов, упрощающий установку и обновление зависимостей проекта.
- Gulp — потоковый сборщик проектов на JS.
- NodeJS — среда разработки серверной части.
P.S.: пункты 5-8 необязательны, но существенно упрощают жизнь современного front-end разработчика.
Первым неприятным сюрпризом для меня стала воспроизводимость проекта. Мир web-разработки не стоит на месте, каждый день выходят новые версии вышеперечисленных продуктов и моё меню, лениво написанное в завалявшемся пару месяцев назад проекте, напрочь отказывалось работать в проекте, собранном недавно. Ниже приведен список проблем, с которыми я столкнулся.
- Последняя версия UI-router выпадает с ошибкой, если в объекте params есть поля со значениями, которые приравниваются к логическому отрицанию (false, 0, undefined, null или пустая строка). Решения проблемы я не нашел, поэтому откатился до последней работоспособной версии «0.2.13».
- Генератор Yeoman предлагает довольно удобную структуру будущего приложения. В корневом каталоге, помимо служебных, создается каталог src с самим проектом. В нем находится основная html страница и три каталога:
app — каталог с состояниями приложения (рекомендуется под каждое состояние выделять свою папку).
assets — папка со статическим контентом.
components — папка для элементов приложения, которые могут использоваться многократно (в нашем случае это директивы, сервисы, фабрики, провайдеры и т.д.).
В соответствии с такой структурой Yeoman-овский генератор настраивает gulp на мониторинг изменений и подключение файлов к запущенному приложению (все делается автоматически, не нужно подключать зависимости к html-странице вручную).
В последней версии генератора папка components была перемещена в каталог app и, соответственно, были изменены настройки gulp. Чтобы наш проект видел папку components и не выдавал в консоли разработчика ошибку об отсутствии модуля navbar, правим следующие файлы в папке gulp:
- скрипт inject.js
в массив injectScripts добавляем элемент
options.src + '/components/**/*.js'
в массив injectStyles добавляем элемент
options.src + '/components/**/*.css'
- скрипт watch.js — добавляем следующие правила:
gulp.watch(options.src + '/components/**/*.css', function(event) { if(isOnlyChange(event)) { browserSync.reload(event.path); } else { gulp.start('inject'); } }); gulp.watch(options.src + '/components/**/*.js', function(event) { if(isOnlyChange(event)) { gulp.start('scripts'); } else { gulp.start('inject'); } }); gulp.watch(options.src + '/components/**/*.html', function(event) { browserSync.reload(event.path); });
- скрипт inject.js
- Так как директива написана на бутстрапе, то, естественно, она требует его компонентов, в частности, библиотеку jQuery. При создании проекта, Yeoman будет спрашивать про необходимость подключения jquery, bootstrap и как с ним работать (ангуляровские директивы ui-bootstrap или AngularStrap, оффициальное применеие bootstrap с jQuery либо чистый CSS). Тут есть небольшой подвох. При установке, еще до выбора вышеперечисленных опций, будет предложено добавить в проект jQuery. Обязательно нужно выбрать эту опцию, иначе останемся без важных зависимостей и все сломается.
P.S.: на самом деле исправить данный момент не сложно. Всего лишь нужно подщаманить код самой директивы и можно вообще обойтись без jQuery, но, как говорится, «работает — не трогай (с)». - Если возникнет желание побаловаться в проекте гугловским angular-material, который Yeoman предлагает включить в проект, нужно знать, что в таком случае подключится старая версия библиотеки, для которой документация с официального сайта не подходит. Поэтому правильным вариантом будет подключение библиотеки с помощью bower с опцией —save.
С организационными моментами закончили, переходим к написанию самой директивы.
Для удобства, вынесем html-шаблон директивы в отдельный файл.
<div class="container" ng-mouseleave="closeMenu($event)"> <div class="navbar-header"> <button type="button" class="navbar-toggle" ng-click="collapseMenu($event)"> <span class="sr-only">Toggle navigation</span> <span class="icon-bar"></span> <span class="icon-bar"></span> <span class="icon-bar"></span> </button> <a class="link-kukuri" href="#" ui-sref="{{::sref}}" data-letters="{{::name}}">{{::name}}</a> </div> <div id="navbar" class="collapse navbar-collapse" aria-expanded="false" ng-class="navCollapsed"> <ul class="nav navbar-nav navbar-right"> <li ng-repeat="items in navbar" class="{{::menuClass(items.name, 'firstLevel')}} list-status"> <a href="#" ng-if="!items.name.pop" ui-sref="{{items.state}}" ng-mouseenter="closeOnMoveMenu()">{{items.name}}</a> <a href="#" ng-if="items.name.pop" class="dropdown-toggle dropdown-toggle-firstLevel" dropdown-toggle aria-expanded="false" ng-click="expandMenu($event)" ng-mouseenter="expandMenu($event)" ng-mouseleave="closeSubMenu($event)"> {{::items.name[0]}}<b class="caret"></b> </a> <ul ng-if="items.name.pop" class="dropdown-menu" ng-include="'submenu.template'"></ul> </li> </ul> </div> </div> <script type="text/ng-template" id="submenu.template"> <li ng-repeat="items in items.name" ng-if="$index !== 0" class="{{::menuClass(items.name)}} sub-menu"> <a href="#" class="sub-link" ng-if="!items.name.pop" ui-sref="{{::items.state}}" ng-mouseenter="closeOnMoveSubMenu($event)"> {{::items.name}}</a> <a href="#" ng-if="items.name.pop" class="dropdown-toggle" data-toggle="dropdown" ng-click="expandSubMenu($event)" ng-mouseenter="expandSubMenu($event)"> {{::items.name[0]}} </a> <ul ng-if="items.name.pop" class="dropdown-menu" ng-include="'submenu.template'"> </ul> </li> </script>
По сути, это модификация стандартного меню из документации бутстрапа с небольшими нюансами:
- Список пунктов меню генерируется с помощью директивы ng-repeat, которая клонирует заготовленный html-шаблон, подставляя в него данные из массива пунктов меню, который определяется в текущем скоупе директивы. Отмечу, что в шаблоне используется так называемое одноразовое присваивание (one time binding), синтаксис которого — две точки возле переменной (например {{::name}} ). Дело в том, что на каждую переменную ангуляр создает отдельного слушателя (watcher), который проверяет ее изменение при каждом дайджесте (проверка на изменение всех переменных в текущем скоупе до тех пор, пока их значения меняются, по окончанию происходит отрисовка DOM с новыми значениями). Так как пункты нашего меню — величины постоянные, то имеет смысл отрисовать их один раз, сократив при этом число слушателей и повысив производительность.
- Вложенные подпункты собираются рекурсивно при помощи ng-include. Рекурсивная часть шаблона хранится в теге script c атрибутом type=«text/ng-template». Браузер не знает такой тип скрипта и не обрабатывает эту часть DOM, однако директива ng-include вставляет лишь содержимое скрипта в нужном месте, что позволяет браузеру нормально обрабатывать DOM элемент.
Сама вложенность контролируется директивой ng-if, которая проверяет, является ли текущий элемент массивом пунктов или строкой с названием пункта. Проверка осуществляется при помощи так называемой «утиной типизации», если перед нами массив, то он имеет методы массива (push, pop и т.д.), обращение к которым без () вернет нам функцию, которая приравнивается к логическому true. Если перед нами строка, то такое обращение к методу массива вернет undefined. - Существует внегласное правило работы с директивами ангуляра, которое гласит: «директива не должна изменять элементы DOM-дерева вне своего элемента». Для работы раскрывающихся пунктов меню требуются слушатели, которые будут отслеживать события клика, наведения и покидания курсором элемента. Можно было бы использовать обычный поиск элементов по селекторам элементов DOM дерева и навешать на них слушателей. Но в большом проекте существует вероятность, что кто-то другой будет использовать идентичные названия селекторов. Последствия такого события непредсказуемы 🙂 Для подобных случаев предусмотрены директивы ng-click, ng-mouseenter и ng-mouseleave, которые были навешаны на соответствующие элементы.
Далее вкратце рассмотрим css файл:
.navbar-brand {
font-family: «Gloria Hallelujah», Verdana, Tahoma;
font-size: 23px;
}
.sub-menu {
background-color: #333;
}
.sub-menu>a {
color: #9d9d9d !important;
padding-left: 10px !important;
}
.dropdown-menu {
padding: 0px;
margin-left: -1px;
margin-right: -1px;
min-width: 90px !important;
}
.dropdown-submenu {
position:relative;
}
.dropdown-submenu>.dropdown-menu {
top:0;
right:100%;
margin-top:6px;
margin-left:-1px;
-webkit-border-radius:0 6px 6px 6px;
-moz-border-radius:0 6px 6px 6px;
border-radius:0 6px 6px 6px;
}
.dropdown-submenu:hover>a:after {
border-left-color:#ffffff;
}
.dropdown-submenu.pull-left {
float:none;
}
.dropdown-submenu.pull-left>.dropdown-menu {
left:-100%;
margin-left:10px;
-webkit-border-radius:6px 0 6px 6px;
-moz-border-radius:6px 0 6px 6px;
border-radius:6px 0 6px 6px;
}
.dropdown-submenu>a:before {
display:block;
content:" ";
float:left;
width: 0;
height: 0;
border-style: solid;
border-color: transparent #cccccc transparent transparent;
margin-top: 7px;
margin-left: -5px;
margin-right: 10px;
}
.dropdown-submenu-big>a:before {
border-width: 4.5px 7.8px 4.5px 0;
}
.dropdown-submenu-small>a:before {
margin-right: 7px;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 5px solid #cccccc;
}
.dropdown-menu:hover,
.dropdown-toggle:focus,
li>[aria-expanded=«true»],
.navbar-brand:hover,
.sub-menu>a:hover,
.list-status:hover,
.nav .open > a {
color: #fff !important;
background-color: #004444 !important;
}
.menu-active,
.menu-active>a {
font-weight: bold !important;
text-decoration: underline;
}
.navbar-cheat {
width: 100%;
height: 45px;
}
.sub-link:before {
display:block;
content:" ";
float:left;
width: 12px;
height: 5px;
}
/* Kukuri */
.link-kukuri {
font-family: «Gloria Hallelujah»;
outline: none;
text-decoration: none !important;
position: relative;
font-size: 23px;
line-height: 2;
color: #c5c2b8;
display: inline-block;
}
.link-kukuri:hover {
color: #c5c2b8;
}
.link-kukuri:hover::after {
-webkit-transform: translate3d(100%,0,0);
transform: translate3d(100%,0,0);
}
.link-kukuri::before {
content: attr(data-letters);
position: absolute;
z-index: 2;
overflow: hidden;
color: #424242;
white-space: nowrap;
width: 0%;
-webkit-transition: width 0.4s 0.0s;
transition: width 0.4s 0.0s;
}
.link-kukuri:hover::before {
width: 100%;
}
.link-kukuri:focus {
color: #9e9ba4;
}
Ничего особенного, код для меню «одолжил» тут, анимацию лого — тут.
Вот так не спеша, мы добрались до самой интересной части, ради которой и задумывалась эта статья — код директивы меню на ангуляре.
Файл navbar.module.js
'use strict'; (function () { angular.module('navbar', ['ui.router']); })();
Начнем с культуры программирования. Сам ангуляр устроен так, что не позволит Вам сильно накосячить, но считается хорошим тоном использовать строгий режим ‘use strict‘ и оборачивать код модулей в анонимную функцию.
Вы спросите, почему такое большое количество функционала вынесено в отдельный файл? Все очень просто. Одним из плюсов ангуляра является его модульность, что позволяет легко переносить куски функционала из одного проекта в другой. В данном случае мы объявляем отдельный модуль ‘navbar‘, на который в дальнейшем можно навесить директивы, контроллеры, фабрики и прочие радости. При этом при переносе функционала в другой проект достаточно будет лишь подключить в зависимости сам модуль ‘navbar‘. Все остальные зависимости, навешанные на него, не требуют объявления и подтянутся автоматически.
Отдельно отмечу, что вторым аргументом при объявлении модуля идет массив зависимостей, которые требуются для его работы. В данном случае это ‘ui-router‘. Если зависимостей нет, то необходимо указать пустой массив, иначе экспортировать модуль в другое приложение будет невозможно.
Довольно часто требуется проводить предстартовые настройки приложения, которые выполняются до запуска директив, контроллеров и сервисов. Такие операции осуществляются в секции config (выполняется один раз при инициализации приложения) и в секции run (выполняется каждый раз при переходе на состояние, в котором она описана). Очень удобно держать код этих настроек в вышеописанном файле.
Файл navbar.directive.js:
'use strict'; (function () { angular.module('navbar') .directive('navbar', function ($document, $state, navbarList, navPermission) { return { restrict: 'A', scope: { name: '@', sref: '@' }, templateUrl: '/components/navbar.directive/navbar.template.html', link: function (scope, elem) { var openedMenu = null, openedSubMenu = null, username = navPermission.getUser($state.params); // присваиваем нашему DOM элементу необходимые классы и атрибуты для работы bootstrap elem.addClass('navbar navbar-inverse navbar-fixed-top'); elem.attr('role', 'navigation'); // редактируем список пунктов меню в соотвествии с доступом и передаем его в scope директивы if(username) { navPermission.acceptPermission(navbarList.list, username); } scope.navbar = navbarList.list; // открытие/сокрытие меню на телефонах или при узком экране браузера scope.collapseMenu = function ($event) { var navbar = elem.find('#navbar'), expanded = navbar.hasClass('in'); navbar.attr('aria-expanded', !expanded); scope.navCollapsed = (expanded) ? '' : 'in'; closeAllMenu(); stopBubleAndPropagation($event); }; // присвоение класса активного пункта меню соответствующей страницы и класса подменю, если пункт содержит подпункты scope.menuClass = function (item, level) { var status = false, activePage = getActivePage($state.current.name), currentPage = (item.pop) ? item[0] : item, classList = (level === 'firstLevel') ? 'dropdown dropdown-firstLevel ' : 'menu-item dropdown dropdown-submenu ', activeClass = (currentPage === activePage || isActive(item, activePage, status) ) ? 'menu-active' : ''; if(item.pop) { return classList + activeClass; } else { return activeClass; } }; // получение имени активного пункта меню в соответствии с открытой страницей (состоянием) function getActivePage(state, currentList) { var name; if(!currentList) { currentList = scope.navbar; } for(var i = (currentList[0].name) ? 0 : 1; i < currentList.length; i++) { if(currentList[i].state === state) { return currentList[i].name; } else if(currentList[i].name.pop) { name = getActivePage(state, currentList[i].name); } } return name; } // проверка, является ли пункт меню активным function isActive (item, activePage, status) { if(item.pop) { for(var i = 1; i < item.length; i++) { if(item[i].name.pop) { status = isActive(item[i].name, activePage, status); } else if(item[i].name === activePage) { return true; } } } else if(item === activePage) { return true; } return status; } // раскрытие сокрытие подпунктов меню по кликку или наведению мыши (страшная функция, т.к. учтены варианты разного разрешения экрана) scope.expandMenu = function ($event) { var clickedElem = $($event.currentTarget), parentClicked = $($event.currentTarget.parentElement), expanded = clickedElem.attr('aria-expanded'), isOpened = parentClicked.hasClass('open'), attrExpanded = (expanded === 'false'), allOpenedMenu = parentClicked.parent().find('.open'), smallWindow = window.innerWidth < 768, eventMouseEnter = $event.type === 'mouseenter', subMenuAll = elem.find('.dropdown-submenu'); if(!smallWindow || !eventMouseEnter) { allOpenedMenu.removeClass('open'); clickedElem.attr('aria-expanded', attrExpanded); if(isOpened && !eventMouseEnter) { parentClicked.removeClass('open'); } else { parentClicked.addClass('open'); openedMenu = clickedElem; //** } } subMenuAll.removeClass('dropdown-submenu-small dropdown-submenu-big'); if(smallWindow) { subMenuAll.addClass('dropdown-submenu-small'); } else { subMenuAll.addClass('dropdown-submenu-big'); } stopBubleAndPropagation($event); }; // закрытие подменю при наведении на соседний пункт в основном меню scope.closeOnMoveMenu = function () { var smallWindow = window.innerWidth < 768; if(openedMenu && !smallWindow) { var clickedLink = openedMenu, clickedElement = clickedLink.parent(); clickedElement.removeClass('open'); clickedLink.attr('aria-expanded', false); openedMenu = null; } }; // раскрытие сокрытие подпунктов подменю (аналогично функции с 92 строки) scope.expandSubMenu = function ($event) { var elemClicked = $($event.currentTarget.parentElement), smallWindow = window.innerWidth < 768, eMouseEnter = $event.type === 'mouseenter', sameElement = elemClicked.hasClass('open'); if(!smallWindow || !eMouseEnter) { // потом подумать как упростить if(!sameElement && !eMouseEnter || !eMouseEnter || !sameElement) { elemClicked.parent().find('.open').removeClass('open'); } if(!sameElement) { elemClicked.addClass('open'); openedSubMenu = elemClicked; } } stopBubleAndPropagation($event); }; // закрытие подменю при наведении на соседний подпункт в подменю (звучит то как:)) scope.closeOnMoveSubMenu = function ($event) { var smallWindow = window.innerWidth < 768; if(openedSubMenu && !smallWindow) { var clickedElement = openedSubMenu, savedList = clickedElement.parent(), currentList = $($event.target).parent().parent(); if(savedList[0] === currentList[0]) { clickedElement.removeClass('open'); openedSubMenu = null; } } }; scope.closeMenu = closeMenu; // удаляем всех слушателей с документа при его уничтожении var $body = $document.find('html'); elem.bind('$destroy', function() { $body.unbind(); //не хватает проверки на удаленный элемент }); // при клике вне меню - закрываем все открытые позиции $body.bind('click', closeMenu); function closeMenu ($event) { var elemClicked = $event.relatedTarget || $event.target; if(isClickOutNavbar(elemClicked)) { closeAllMenu(); } } // рекурсивно поднимаемся по родителям элемента, чтобы узнать, был клик по меню или нет function isClickOutNavbar(elem) { if($(elem).hasClass('dropdown-firstLevel')) { return false; } if(elem.parentElement !== null) { return isClickOutNavbar(elem.parentElement); } else { return true; } } // закрываем все открытые пункты и подпункты меню function closeAllMenu() { elem.find('.open').removeClass('open'); elem.find('[aria-expanded=true]').attr('aria-expanded', false); } // служебная функция предотвращения действий браузера поумолчанию и всплывающих событий function stopBubleAndPropagation($event) { $event.stopPropagation(); $event.preventDefault(); } } }; }); })();
Сразу отмечу, что я не горжусь кодом, описанным в директиве. Он не представляет большого интереса, т.к. тут всего лишь описан функционал открытия/закрытия меню для разных разрешений экрана и присвоение нужных классов в зависимости от вида пункта. Более или менее полезную информацию несут две рекурсивные функции: проверка клика пользователем вне меню (строка 181) и проверка того, является ли пункт меню активным (строка 70).
Отмечу, что сделано правильно с моей точки зрения:
- Директива имеет изолированный скоуп, в который пробрасываются параметры name и sref через атрибуты элемента. Т.е. в большом проекте меньше шанс нарваться на неприятности.
- Сложные конструкции (нахождение элемента, проверка атрибута) вынесены в переменные. Название переменных и функций говорит об их назначении.
Хорошим тоном считается присвоение имени в виде верблюжьей нотации. Также, если в коде идет объявление нескольких переменных подряд, нет смысла постоянно писать var, можно просто перечислить переменные через запятую, а еще лучше указать каждую из них с новой строки. Это повышает читаемость кода.
Что сделано неправильно:
- Код слишком сложный, некоторые функции можно разбить на более простые. Основное правило: мысленно произносим, что делает функция и если во фразе проскакивает буква «И», значит, функцию нужно делить на более простую.
- Слишком тривиальные комментарии. Хороший код должен говорить сам за себя, что он делает. Комментариев требуют либо сложные в понимании моменты, либо те участки кода, в которых Вы выбрали более сложное решение вместо простого, т.к. что то в простом Вас не устроило.
В данном случае комментарии написаны, чтобы читателю было проще вникнуть в суть вопроса.
Файл navbar.provider.js
Итак, наша директива реализована и работает, но откуда брать список пунктов меню? Можно описать массив пунктов в самой директиве, но это неудобно при последующем добавлении/удалении состояний приложения. Каждый раз придется лезть в массив пунктов директивы, искать в нем нужное место и добавлять новый. А при удалении состояния вообще можно забыть про наличие пункта в меню, что приведет к ошибкам при попытке пользователя посетить страницу.
Выход из ситуации очевиден — необходимо регистрировать каждый пункт меню непосредственно возле описания конкретного состояния. Тут есть небольшой нюанс. Порядок инициализации ангуляровского приложения следующий:
- подключение зарегистрированных модулей ангуляра (module),
- регистрация провайдеров (provider),
- обработка секции config (выполняется один раз при инициализации приложения),
- регистрация factory, service, value, constant,
- обработка секции run (выполняется каждый раз при смене состояния),
- регистрация контроллеров и директив.
Исходя из очереди, нам подходит секция config, для которой доступен только provider. К провайдеру можно достучаться из любой части приложения просто подключив его имя в зависимости. На этапе конфига провайдер доступен по своему имени с добавкой «Provider», т.е., например, если имя нашего провайдера navbarList — то в секции конфиг он будет доступен под именем navbarListProvider.
Код нашего провайдера представлен ниже:
'use strict'; (function () { angular.module('navbar') .provider('navbarList', function () { var list = []; // основная функция добавления пункта в меню this.add = function (obj) { // проверка на правильно заданные параметры расположения пункта if(obj.location) { if(obj.location.place.length !== obj.location.priority.length || !obj.location.place.pop || !obj.location.priority.pop) { console.log('Warning! Bad location params for menu "' + obj.name + '". Skip item'); return; } } // добавление пункта на первый уровень меню при отстутствии местоположения if(!obj.location) { var name = obj.name; for(var i = 0; i < list.length; i++) { // рассказать про тернарный оператор и утиную типизацию var currentName = (list[i].name.pop) ? list[i].name[0] : list[i].name; if(currentName === name) { console.log('Warning! Duplicate menu "' + name + '". Skip item'); return; } } list.push(obj); list.sort(sortByPriority); return; } // поиск пункта, в который нужно добавить подпункт согласно местоположению var place = obj.location.place.shift(), priority = obj.location.priority.shift(); for(i = 0; i < list.length; i++) { // описать в статье, что i блочная не в JS var currentSubName = (list[i].name.pop) ? list[i].name[0] : null; if(place === currentSubName) { list[i].name = changeExistPart(obj, list[i].name); if(priority !== list[i].priority) { console.log('Warning! Priority of menu "' + list[i].name + '" has been changed from "' + list[i].priority + '" to "' + priority + '"'); list[i].priority = priority; list.sort(sortByPriority); } return; } currentName = list[i].name; if(place === currentName) { console.log('Warning! Duplicate submenu "' + place + '". Skip item'); return; } } // ни одно вышеописанное условие не совпало, добавляем новый пункт со всеми вложениями list.push( { name: [place, makeOriginalPart(obj)], priority: priority } ); list.sort(sortByPriority); }; // рекурсивный поиск места в подпунктах меню для вставки нового пункта function changeExistPart(obj, list) { var place = obj.location.place.shift(), priority = obj.location.priority.shift(), // возможно необходимо сделать двойной приоритет searchName = (place) ? place : obj.name; for(var i = 1; i < list.length; i++) { var currentName = (list[i].name.pop) ? list[i].name[0] : list[i].name; if(searchName === currentName) { if(!list[i].name.pop || (!place && list[i].name.pop) ) { console.log('Warning! Duplicate menu "' + searchName + '". Skip item'); return list; } else { list[i].name = changeExistPart(obj, list[i].name); if(priority !== list[i].priority) { console.log('Warning! Priority of menu "' + list[i].name + '" has been changed from "' + list[i].priority + '" to "' + priority + '"'); list[i].priority = priority; list.sort(sortByPriority); } return list; } } } if(!place) { delete obj.location; list.push(obj); } else { list.push({ name: [place, makeOriginalPart(obj)], priority: priority }); } list.sort(sortByPriority); return list; } // рекурсивное создание новой, оригинальной части пункта меню с подпунктами function makeOriginalPart (obj) { var place = obj.location.place.shift(), priority = obj.location.priority.shift(); if(place) { var menu = { priority: priority, name: [place, makeOriginalPart(obj)] }; } else { delete obj.location; menu = obj; } return menu; } // функция сортировки пунктов меню по приоритету function sortByPriority(a, b) { return a.priority - b.priority; } // служебная функция для работы провайдера angularJS this.$get = function () { return { list: list, add: this.add }; }; }); })();
$get — служебная функция, которая, в нашем случае, возвращает метод добавления пункта в меню add и сам список меню list, который хранится в замыкании.
Функция add принимает на вход объект со следующими полями:
- priority — численное значение приоритета, по которому сортируется список,
- permission — необязательный объект, содержащий одно из двух полей:
- except — массив запрещенных ролей пользователя,
- only — массив разрешенных ролей пользователя,
- location — необязательный объект, содержащий два поля:
- place — массив имен, по которым строится вложенное меню,
- priority — массив такой же длины, содержащий численное значение приоритета каждого пункта вложенности соответственно,
- name — строковое имя текущего пункта.
Принцип работы функции add прост. Сперва идет валидация принимаемого на вход объекта, затем осуществляется поиск места для вставки текущего пункта. Если совпадений с пунктами не найдено — вызывается рекурсивная функция makeOriginalPart(), которая возвращает новосозданную часть меню; если совпадение найдено — вызывается changeExistPart(), которая рекурсивно идет на следующий уровень вложенности до тех пор, пока есть совпадения в названии пунктов из массива place.
После каждого добавления пункта выполняется сортировка меню по полю priority.
При написании кода провайдера специально не использовались конструкции else if. Вместо этого в конце условия добавлялся return. Я считаю, данный шаг оправданным, т.к. он повышает читаемость кода. Вообще код провайдера неоднократно оптимизировался. Кому интересно, ниже прикрепляю первую версию.
'use strict'; (function () { angular.module('navbar') .provider('navbarList', function () { var list = []; this.add = addMenu; function addMenu(obj, nestedMenu, currentList) { if(currentList) { list = currentList; } else if(list.length < 1) { list.push(makeOriginalPart(obj)); return; } if(!obj.location || !obj.location.place) { // переделать проверку. Глобально проверять длину place==priority isDuplicate(obj.name, list); list.push(obj); list.sort(sortByPriority); return; } else if(obj.location.place.length > 0){ var searchName = obj.location.place.shift(), priority = (obj.location.priority) ? obj.location.priority.shift() : null; for(var i = (nestedMenu) ? 1 : 0; i < list.length; i++) { var currentName = (list[i].name.pop) ? list[i].name[0] :list[i].name; if(currentName === searchName) { if(list[i].name.pop) { // можно переписать по аналогии с пермишн if(!nestedMenu) { nestedMenu = [list]; } var sublistName = list[i].name.shift(); list[i].name.sort(sortByPriority); list[i].name.unshift(sublistName); list[i].name.priority = priority; // свойство присвоено массиву nestedMenu.push(list[i].name); addMenu(obj, nestedMenu, list[i].name); return; } else { console.log('Warning! Duplicate menu', currentName); } } } if(nestedMenu) { var last = nestedMenu.length - 1; nestedMenu[last].push({ name: [searchName, makeOriginalPart(obj, null, nestedMenu[last]) ], priority: priority }); } } else { last = nestedMenu.length - 1; nestedMenu[last].push(makeOriginalPart(obj, null, nestedMenu[last])); } if(nestedMenu) { // changeExistPart возвращает ундефайнед при правильной архитектуре nestedMenu[nestedMenu.length - 1].sort(sortByPriority); list = changeExistPart(nestedMenu); } else { if(priority) { // переделать проверку. Глобально проверять длину place==priority obj.location.priority.unshift(priority); } obj.location.place.unshift(searchName); list.push(makeOriginalPart(obj, null, list)); list.sort(sortByPriority); } } function changeExistPart(nestedMenu) { if(nestedMenu.length > 1) { var subList = nestedMenu.pop(), priority = subList.priority, searchName = subList[0], last = nestedMenu.length - 1; for(var i = 1; i < nestedMenu[last].length; i++) { var currentName = (nestedMenu[last][i].name.pop) ? nestedMenu[last][i].name[0] : ''; if(searchName === currentName){ nestedMenu[last][i].name = subList; nestedMenu[last][i].priority = priority; return changeExistPart(nestedMenu); } } return changeExistPart(nestedMenu); // ошибка в архитектуре. Эта строка должна быть не нужна } else { return nestedMenu[0]; } } function makeOriginalPart(obj, menu, currentList){ if(!menu) { isDuplicate(obj.name, currentList); menu = { name: obj.name, priority: obj.priority, state: obj.state, permissions: obj.permissions }; } if(obj.location.place.length > 0) { var currentLocation = obj.location.place.pop(), priority = (obj.location.priority) ? obj.location.priority.pop() : null, currentMenu = { priority: priority, name: [currentLocation, menu] }; return makeOriginalPart(obj, currentMenu); } else { return menu; } } function isDuplicate(name, list) { if(!list || list.length < 1) { return; } for(var i = (list[0].name) ? 0 : 1; i < list.length; i++) { var currentName = (list[i].name.pop) ? list[i].name[0] : list[i].name; if(currentName === name) { console.log('Warning! Duplicate menu', currentName); } } } function sortByPriority(a, b) { return a.priority - b.priority; } this.$get = function () { return { list: list, add: this.add }; }; }); })();
Файл navbar.permission.js
'use strict'; (function () { angular.module('navbar') .factory('navPermission', function (Permission, $q) { // перебираем все роли и возвращаем подходящую в виде промиса function getUser(params) { var users = Permission.roleValidations, names = Object.keys(users), promisesArr = []; for(var i = 0; i < names.length; i++) { var current = names[i], validUser = $q.when( users[current](params) ); promisesArr.push(validUser); } return $q.all(promisesArr).then(function (users) { for(var i = 0; i < users.length; i++) { if(users[i]) { return names[i]; } } return null; }); } // если пришел промис, ждем его разрешения и меняем меню, если пользователь - сразу меняем меню function acceptPermission (list, username) { if(!username.then) { return changeList(list, username); } else { return username.then(function (username) { return changeList(list, username); }); } } // рекурсивно пробегаемся по массиву меню и удаляем пункты, которые запрещены для текущей роли function changeList(list, username) { for(var i = (list[0].name) ? 0 : 1; i < list.length; i++) { if(list[i].permissions) { if(list[i].permissions.except) { var except = list[i].permissions.except; for(var j = 0; j < except.length; j++) { if(except[j] === username) { list.splice(i--, 1); } } } else if(list[i].permissions.only) { var only = list[i].permissions.only, accessDenided = true; for(j = 0; j < only.length; j++) { if(only[j] === username) { accessDenided = false; } } if(accessDenided) { list.splice(i--, 1); } } } else if(list[i].name.pop) { list[i].name = changeList( list[i].name, username); if(list[i].name.length === 1 ) { list.splice(i--, 1); } } } return list; } // возвращаем созданные методы фабрики return { getUser: getUser, acceptPermission: acceptPermission }; }); })();
Скрипт фильтрации меню в зависимости от уровня доступа, определенного модулем angular-permission. Код вынесен в отдельную фабрику для повышения читаемости и модульности (не всем нужен данный функционал).
Фабрика состоит из двух методов:
- acceptPermission — рекурсивно проходимся по массиву пунктов меню и удаляем запрещенные.
- getUser — метод определения текущей роли пользователя. Очевидно, что в реальном проекте роль пользователя может определяться не только локально, но и на сервере. Поэтому роль пользователя определяется асинхронно с применением промисов.
Файл navbar.decorator.js
По сути, все задуманное мной реализовано, посмотрим, как это работает. Ниже пример кода объявления состояния «персидская кошка» с регистрацией данного пункта подменю в цепочке подуровней «живые существа» => «млекопитающие» => «кошки». Пункт доступен всем пользователям, кроме «anonymous» и «banned».
.config(function ($stateProvider, navbarListProvider) { // объявляем текущее состояние $stateProvider .state('persianCat', { url: '/персидская кошка', templateUrl: 'app/cats/persianCat.html', controller: 'persianCatCtrl', permissions: { except: ['anonymous', 'banned'], redirectTo: 'login' } }); // добавляем пункт в меню navbarListProvider.add({ state: 'persianCat', name: 'персидская кошка', permissions: { except: ['anonymous', 'banned'] }, priority: 20, location: { place: ['живые существа', 'млекопитающие', 'кошки'], priority: [10, 10, 10] } }); });
Вроде бы все работает, но, согласитесь — некрасиво? Почти вся информация, необходимая для объявления пункта меню дублируется при объявлении состояния. Чтобы объединить все воедино воспользуемся функцией декоратором, которую любезно нам предоставили разработчики модуля UI-router. Фактически, декоратор создает обертку вокруг существующей функции и позволяет менять ее функционал. Ниже представлен код декорирования нашего метода «.state», который позволяет обрабатывать поле menu из передаваемого в state объекта:
'use strict'; (function() { angular.module('navbar') .config(function ($stateProvider, navbarListProvider) { // добавляем в метод state функционал регистрации пунктов меню $stateProvider.decorator('state', function (obj) { var menu = obj.menu, permissions = (obj.data) ? obj.data.permissions : null; // если в коде не указана регистрация текущего стейта в меню - ничего не делаем if(!menu) { return; } menu.state = obj.name; // регистрируем права доступа пункта при их наличии if(permissions) { menu.permissions = {}; if(permissions.except) { menu.permissions.except = permissions.except; } else if(permissions.only) { menu.permissions.only = permissions.only; } else { delete menu.permissions; } } // регистрируем пункт меню по скомпонованному объекту menu navbarListProvider.add(menu); }); }); })();
Теперь объявление нашего состояния с регистрацией в меню выглядит так:
.config(function ($stateProvider) { $stateProvider .state('persianCat', { url: '/персидская кошка', templateUrl: 'app/cats/persianCat.html', controller: 'persianCatCtrl', permissions: { except: ['anonymous', 'banned'], redirectTo: 'login' }, menu: { name: 'персидская кошка', priority: 20, location: { place: ['живые существа', 'млекопитающие', 'кошки'], priority: [10, 10, 10] } } }); });
Согласитесь — более элегантно.
И напоследок небольшой лайфхак: создавайте в своих проектах под каждое состояние не только отдельную папку, но и отдельный ангуляровский модуль, и подключайте его в список зависимостей. Это существенно сократит Ваше время при удалении/переносе состояний из проекта. Достаточно будет удалить модуль из списка зависимостей и папку с состоянием.
Спасибо за внимание, всем удачи.
ссылка на оригинал статьи http://habrahabr.ru/post/259347/
Добавить комментарий