Как стать автором
Обновить

Адаптивное многоуровневое меню сайта с саморегистрируемыми пунктами на основе директивы AngularJS

JavaScriptAngular
Из песочницы
Существует вполне обоснованное мнение, что найденный в Интернете чужой код намного лучше собственноручно написанного, т.к. его уже оттестировали тысячи ленивых разработчиков. Собственно поэтому, когда передо мной возникла задача, описанная в названии статьи, я решил не изобретать велосипед, а найти готовое решение. К моему удивлению, ни на англоязычных, ни на русскоязычных ресурсах ничего подходящего под мои запросы на основе ангуляра я не нашел. Поэтому было принято решение написать код самостоятельно и поделиться им с общественностью.

Возможности меню, реализованные в данной статье:
  1. Вся начинка меню спрятана под капотом директивы. При верстке html страницы указывается лишь DOM-элемент с директивой, что повышает читабельность кода.
  2. У меню есть возможность создавать пункты с бесконечным уровнем вложенностей.
  3. Подсветка активной страницы в меню осуществляется не только на первом уровне, но и на любом уровне вложенности.
  4. Возможность зарегистрировать пункт меню на этапе конфигурации приложения.
  5. Возможность отображения/сокрытия конкретных пунктов меню в зависимости от прав доступа текущего пользователя.

Исходный код директивы можно посмотреть тут.

Писать все с нуля, естественно, я не стал, поэтому ниже привожу список позаимствованных материалов:

Просмотр списка
  1. AngularJS — супергероический фреймворк от гугла, реализующий MVVM шаблон проектирования архитектуры приложения.
  2. UI-router — ангуляровский модуль, без которого немыслимо проектирование приложений, основанных на состояниях.
  3. Angular-permission — ангуляровский модуль (работает только в паре с ui-router), упрощающий контроль доступа и авторизацию на стороне клиента.
  4. Bootstrap 3 — CSS-фреймворк, ускоряющий верстку адаптивных страниц.
  5. Yeoman-генератор — консольная утилита для автоматического построения структуры проекта.
  6. Bower — менеджер пакетов, упрощающий установку и обновление зависимостей проекта.
  7. Gulp — потоковый сборщик проектов на JS.
  8. NodeJS — среда разработки серверной части.

P.S.: пункты 5-8 необязательны, но существенно упрощают жизнь современного front-end разработчика.

Первым неприятным сюрпризом для меня стала воспроизводимость проекта. Мир web-разработки не стоит на месте, каждый день выходят новые версии вышеперечисленных продуктов и моё меню, лениво написанное в завалявшемся пару месяцев назад проекте, напрочь отказывалось работать в проекте, собранном недавно. Ниже приведен список проблем, с которыми я столкнулся.

Просмотр проблем
  1. Последняя версия UI-router выпадает с ошибкой, если в объекте params есть поля со значениями, которые приравниваются к логическому отрицанию (false, 0, undefined, null или пустая строка). Решения проблемы я не нашел, поэтому откатился до последней работоспособной версии «0.2.13».
  2. Генератор 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);
      });
      


  3. Так как директива написана на бутстрапе, то, естественно, она требует его компонентов, в частности, библиотеку jQuery. При создании проекта, Yeoman будет спрашивать про необходимость подключения jquery, bootstrap и как с ним работать (ангуляровские директивы ui-bootstrap или AngularStrap, официальное применение bootstrap с jQuery либо чистый CSS). Тут есть небольшой подвох. При установке, еще до выбора вышеперечисленных опций, будет предложено добавить в проект jQuery. Обязательно нужно выбрать эту опцию, иначе останемся без важных зависимостей и все сломается.
    P.S.: на самом деле исправить данный момент не сложно. Всего лишь нужно подшаманить код самой директивы и можно вообще обойтись без jQuery, но, как говорится, «работает — не трогай (с)».

  4. Если возникнет желание побаловаться в проекте гугловским 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>


По сути, это модификация стандартного меню из документации бутстрапа с небольшими нюансами:

Показать особенности

  1. Список пунктов меню генерируется с помощью директивы ng-repeat, которая клонирует заготовленный html-шаблон, подставляя в него данные из массива пунктов меню, который определяется в текущем скоупе директивы. Отмечу, что в шаблоне используется так называемое одноразовое присваивание (one time binding), синтаксис которого — две точки возле переменной (например {{::name}} ). Дело в том, что на каждую переменную ангуляр создает отдельного слушателя (watcher), который проверяет ее изменение при каждом дайджесте (проверка на изменение всех переменных в текущем скоупе до тех пор, пока их значения меняются, по окончанию происходит отрисовка DOM с новыми значениями). Так как пункты нашего меню — величины постоянные, то имеет смысл отрисовать их один раз, сократив при этом число слушателей и повысив производительность.

  2. Вложенные подпункты собираются рекурсивно при помощи ng-include. Рекурсивная часть шаблона хранится в теге script c атрибутом type=«text/ng-template». Браузер не знает такой тип скрипта и не обрабатывает эту часть DOM, однако директива ng-include вставляет лишь содержимое скрипта в нужном месте, что позволяет браузеру нормально обрабатывать DOM элемент.
    Сама вложенность контролируется директивой ng-if, которая проверяет, является ли текущий элемент массивом пунктов или строкой с названием пункта. Проверка осуществляется при помощи так называемой «утиной типизации», если перед нами массив, то он имеет методы массива (push, pop и т.д.), обращение к которым без () вернет нам функцию, которая приравнивается к логическому true. Если перед нами строка, то такое обращение к методу массива вернет undefined.

  3. Существует внегласное правило работы с директивами ангуляра, которое гласит: «директива не должна изменять элементы DOM-дерева вне своего элемента». Для работы раскрывающихся пунктов меню требуются слушатели, которые будут отслеживать события клика, наведения и покидания курсором элемента. Можно было бы использовать обычный поиск элементов по селекторам элементов DOM дерева и навешать на них слушателей. Но в большом проекте существует вероятность, что кто-то другой будет использовать идентичные названия селекторов. Последствия такого события непредсказуемы :) Для подобных случаев предусмотрены директивы ng-click, ng-mouseenter и ng-mouseleave, которые были навешаны на соответствующие элементы.


Далее вкратце рассмотрим css файл:

Показать CSS
@import url(http://fonts.googleapis.com/css?family=Gloria+Hallelujah);
.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:

Показать 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).

Отмечу, что сделано правильно с моей точки зрения:
  1. Директива имеет изолированный скоуп, в который пробрасываются параметры name и sref через атрибуты элемента. Т.е. в большом проекте меньше шанс нарваться на неприятности.
  2. Сложные конструкции (нахождение элемента, проверка атрибута) вынесены в переменные. Название переменных и функций говорит об их назначении.
    Хорошим тоном считается присвоение имени в виде верблюжьей нотации. Также, если в коде идет объявление нескольких переменных подряд, нет смысла постоянно писать var, можно просто перечислить переменные через запятую, а еще лучше указать каждую из них с новой строки. Это повышает читаемость кода.

Что сделано неправильно:
  1. Код слишком сложный, некоторые функции можно разбить на более простые. Основное правило: мысленно произносим, что делает функция и если во фразе проскакивает буква «И», значит, функцию нужно делить на более простую.
  2. Слишком тривиальные комментарии. Хороший код должен говорить сам за себя, что он делает. Комментариев требуют либо сложные в понимании моменты, либо те участки кода, в которых Вы выбрали более сложное решение вместо простого, т.к. что то в простом Вас не устроило.
    В данном случае комментарии написаны, чтобы читателю было проще вникнуть в суть вопроса.

Файл navbar.provider.js

Итак, наша директива реализована и работает, но откуда брать список пунктов меню? Можно описать массив пунктов в самой директиве, но это неудобно при последующем добавлении/удалении состояний приложения. Каждый раз придется лезть в массив пунктов директивы, искать в нем нужное место и добавлять новый. А при удалении состояния вообще можно забыть про наличие пункта в меню, что приведет к ошибкам при попытке пользователя посетить страницу.
Выход из ситуации очевиден — необходимо регистрировать каждый пункт меню непосредственно возле описания конкретного состояния. Тут есть небольшой нюанс. Порядок инициализации ангуляровского приложения следующий:

  1. подключение зарегистрированных модулей ангуляра (module),
  2. регистрация провайдеров (provider),
  3. обработка секции config (выполняется один раз при инициализации приложения),
  4. регистрация factory, service, value, constant,
  5. обработка секции run (выполняется каждый раз при смене состояния),
  6. регистрация контроллеров и директив.

Исходя из очереди, нам подходит секция config, для которой доступен только provider. К провайдеру можно достучаться из любой части приложения просто подключив его имя в зависимости. На этапе конфига провайдер доступен по своему имени с добавкой «Provider», т.е., например, если имя нашего провайдера navbarList — то в секции конфиг он будет доступен под именем navbarListProvider.
Код нашего провайдера представлен ниже:

Показать navbar.provider.js
'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 принимает на вход объект со следующими полями:

  1. priority — численное значение приоритета, по которому сортируется список,
  2. permission — необязательный объект, содержащий одно из двух полей:
    • except — массив запрещенных ролей пользователя,
    • only — массив разрешенных ролей пользователя,
  3. location — необязательный объект, содержащий два поля:
    • place — массив имен, по которым строится вложенное меню,
    • priority — массив такой же длины, содержащий численное значение приоритета каждого пункта вложенности соответственно,
  4. 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
Смотреть 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. Код вынесен в отдельную фабрику для повышения читаемости и модульности (не всем нужен данный функционал).
Фабрика состоит из двух методов:

  1. acceptPermission — рекурсивно проходимся по массиву пунктов меню и удаляем запрещенные.
  2. 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 объекта:

Смотреть navbar.decorator.js
'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]
   		  }
		}
        });
	});

Согласитесь — более элегантно.

И напоследок небольшой лайфхак: создавайте в своих проектах под каждое состояние не только отдельную папку, но и отдельный ангуляровский модуль, и подключайте его в список зависимостей. Это существенно сократит Ваше время при удалении/переносе состояний из проекта. Достаточно будет удалить модуль из списка зависимостей и папку с состоянием.

Спасибо за внимание, всем удачи.
Теги:angularjavascriptдирективынавигация по сайтувыпадающее меню
Хабы: JavaScript Angular
Всего голосов 8: ↑6 и ↓2+4
Просмотры14K

Похожие публикации

Лучшие публикации за сутки