Personal Maps: контроллеры и представления в AngularJS. Часть 6.

Владимир | | AngularJS, JavaScript, Web разработка.

personal maps controller view

Эта статья шестая в цикле о создании небольшого web приложения под названием Personal Maps. Ссылки на все предыдущие части вы найдёте внизу страницы, а сейчас мы продолжим разработку клиентской части приложения и займёмся созданием контроллеров и представлений.

Когда я начинал разрабатывать более-менее сложные приложения на JavaScript, создание этих компонентов было одним из самых сложных моментов. Проблема была в том, что при создании клиентской части я автоматически пытался применять подходы, которые используются на сервере, и из-за этого возникали проблемы. Сбивало то, что и PHP, и JavaScript фреймворки используют модели, представления и контроллеры (MVC архитектура), но архитектура всего приложения при этом отличается.

Когда серверный фреймворк обрабатывает запрос, он с помощью роутера находит нужный метод (action) контроллера и вызывает его. В этом методе мы обрабатываем данные с помощью модели и выводим результат с помощью представления. Для следующего запроса процесс повторяется.

Но JavaScript фреймворк не получает HTTP запросов. Приложение создаётся один раз и работает до тех пор, пока пользователь не перезагрузит страницу или не закроет браузер. Т.е. на странице могут одновременно работать несколько контроллеров, каждый из которых отвечает за свою часть страницы. При этом взаимодействие между ними может быть организовано как с помощью событий, так и напрямую (один контроллер вызывает методы другого). Естественно, последний способ использовать не желательно, т.к. он увеличивает количество зависимостей в приложении. И изменение одного из контроллеров потребует переписывания кода в других.

Кроме того, есть ещё специфика конкретного JS фреймворка. Если в большинстве PHP фреймворков создание контроллеров, моделей и представлений практически не отличается, то разница между созданием этих же компонентов, например, в Backbone и AngularJS очень заметна.

Поэтому, прежде чем переходить к коду нашего приложения, рассмотрим общий принцип создания MVC компонентов в AngularJS.

Примечание. Исходный код размещён на GitHub, также доступна демоверсия приложения.

Source Demo

Создание контроллеров в AngularJS

Фактически контроллером может быть любая JavaScript функция. Достаточно её объявить и указать её название в атрибуте ng-controller какого-нибудь тега.

function MyController() { ... }
<div ng-controller="MyController">...</div>

Эту форму записи не желательно использовать в реальных приложениях, но, тем не менее, она вполне рабочая. Ниже мы рассмотрим другой способ создания контроллера, а сейчас нам важно разобраться, что делает AngularJS.

Для каждого контроллера фреймворк создаёт так называемый Scope. Это объект, который ссылается на модель приложения. Контроллер нужен для того, чтобы инициализировать Scope и добавить ему «поведения» (например, обработчики событий).

Передача данных между контроллером, моделью и представлениями, в том числе и двунаправленное связывание данных, происходит именно с помощью объекта scope.

Модели в AngularJS

В AngularJS моделями являются любые данные, которые хранятся в свойствах объекта scope.

Т.к. scope сам по себе является JavaScript объектом, то его свойствам можно присвоить любые другие объекты любой сложности. Например,

$scope.hello = 'Привет';

или

$scope.delete = function(id) { ... };

Представления в AngularJS

Представлением в AngularJS является DOM (Document Object Model – объектная модель документа) или её часть. Когда мы подключаем контроллер, то указываем для какого-нибудь тега в разметке страницы атрибут ng-controller или ng-view. Этот тег и всё его содержимое и являются представлением для данного контроллера.

Представление может содержать выражения, заключённые в двойные фигурные скобки. AngularJS обрабатывает эти выражения, и показывает пользователю результат. Например,

<div>{{ hello }}</div>

будет преобразовано в

<div>Привет</div>

При этом в контроллере мы должны установить значение свойства hello для объекта $scope. Указывать $scope в представлении не нужно.

Таким образом, представление «знает» о контроллере за счёт атрибутов ng-controller или ng-view и может получать значение атрибутов объекта scope.

Роутер в AngularJS

Роутер является не обязательным компонентом. Он позволяет переключать контроллеры в зависимости от текущего значения URL, точнее той его части, которая идёт после символа #.

В нашем приложении центральная часть страницы разделена на две области. В левой находится карта, она должна отображаться постоянно. А в правой – либо список объектов, либо форма создания нового объекта.

main_page_structure

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

Нам нужно, чтобы:

  • для #/list отображался список объектов;
  • для #/add отображалась форма создания нового объекта;
  • а для #/edit/id отображалась форма редактирования объекта, который имеет указанный id.

Настроим роутер (файл app.js)

var app = angular.module('personalmaps', ['ui.bootstrap', 'pascalprecht.translate'])
    .value('lang', lang);

app.config(['$routeProvider', function($routeProvider) {
    $routeProvider.when('/list', {
        templateUrl: 'partials/list.html',
        controller: 'PlacesListController'
    });
    $routeProvider.when('/add', {
        templateUrl: 'partials/form.html',
        controller: 'PlacesFormController'
    });
    $routeProvider.when('/edit/:placeId', {
        templateUrl: 'partials/form.html',
        controller: 'PlacesFormController'
    });
    $routeProvider.otherwise({
        redirectTo: '/list'
    });
}]);

Как видите, после создания приложения (строка 1) мы вызываем метод config, которому передаём функцию, настраивающую роутер.

В первом параметре эта функция получает $routeProvider – объект, который используется для настройки сервиса $route (стандартный сервис AngularJS).

Для каждого варианта URL, который нам нужно обрабатывать, вызываем метод when, в котором передаём два параметра:

  1. Шаблон URL. Если URL содержит параметры, то перед ними нужно поставить двоеточие. Для каждого параметра создаётся свойство в объекте $routeParams. Например, $routeParams.placeId.
  2. Хеш с настройками. templateUrl содежрит URL шаблона, а controller – имя контроллера.

Метод $routeProvider.otherwise позволяет установить параметры для всех остальных вариантов URL.

Для того чтобы роутер заработал нам нужно выполнить ещё один шаг – указать область на странице, для которой будут устанавливаться контроллеры. Делается это с помощью директивы ng-view. В нашем случае разметка выглядит так:

<div class="row-fluid">
    <div class="span9 map" pm-google-map></div>

    <div class="span3" ng-view></div>
</div>

Роутер заменит ng-view на соответствующий контроллер и вставит шаблон внутрь данного тега. Например, для URL #/list мы получим что-то вроде:

<div class="span3" ng-controller="PlacesListController">… содержимое шаблона partials/list.html …</div>

Теперь нам нужно написать контроллеры для списка и формы.

Контроллер списка

Файл js/controllers/list.js

app.controller('PlacesListController'
    , ['$scope', '$rootScope', 'Places', '$dialog', 'lang'
    , function($scope, $rootScope, Places, $dialog, lang) {

    $scope.curLang = lang;

    $scope.places = Places.getAll();

    $rootScope.$on('places:updated', function() {
        $scope.places = Places.getAll();
    });

    $scope.isEmpty = function() {
        if ($scope.places.length === 0) {
            return true;
        }
        return false;
    }

    $scope.confirm = function(place) {
        var title = 'Confirm';
        var msg = 'Do you really want to delete this place?';
        var btns = [{result:'no', label: 'No'}, {result:'yes', label: 'Yes', cssClass: 'btn-danger'}];

        $dialog.messageBox(title, msg, btns)
            .open()
            .then(function(result){
                if (result === 'yes') {
                    $scope.delete(place);
                }
            });
    }

    $scope.delete = function(place) {
        Places.delete(place);
    }

    $scope.show = function(place) {
        $rootScope.$broadcast('place:show', place);
    }
}]);

Здесь для создания контроллера используется метод controller, который получает название контроллера, список зависимостей и функцию, которая и создаёт контроллер. Т.е. контроллер в любом случае создаётся с помощью JS функции, но в отличие от первого варианта, показанного в начале статьи, данный контроллер находится внутри модуля приложения и не «засоряет» глобальное пространство имён.

В списке зависимостей мы указали:

  • Сервис Places, который создали в 4-ой части. Напомню, этот сервис выполняет всю работу по взаимодействию с серверной частью приложения. И с помощью его методом мы можем прочитать, изменить или создать объект.
  • Объект $scope – его автоматически создаёт AngularJS и через него осуществляется передача данных в представление.
  • $rootScope – глобальный scope, который мы используем для отправки и получения событий.
  • $dialogсторонний компонент, который мы используем для создания модального окна. Используется для подтверждения удаления объекта.
  • lang – содержит название текущего языка.

Т.к. контроллер отвечает за отображение списка объектов, то мы читаем их с помощью метода Places.getAll() и присваиваем свойству $scope.places (чтобы к ним могло получить доступ представление).

Когда список объектов изменяется, сервис Places инициирует событие places:updated. Поэтому мы добавляем соответствующий обработчик – строки 9-11.

И нам нужно несколько методов, которые будет вызывать представление.

  • $scope.isEmpty – проверяет, является ли список объектов пустым.
  • $scope.confirm – открывает диалог с просьбой подтвердить удаление объекта. И если подтверждение получено, вызывает метод $scope.delete.
  • $scope.delete – вызывает Places.delete для удаления объекта.
  • $scope.show – отправляет событие place:show, уведомляющее другие компоненты о том, что пользователь кликнул на объекте.

Теперь рассмотрим шаблон partials/list.html

<div id="placesList">

    <ul>
        <li ng-repeat="place in places">
            <a href="" ng-click="show(place)">{{ place.p_title }}</a>
            <a href="" ng-click="confirm(place)" class="pull-right" rel="tooltip" title="{{ 'DELETE' | translate }}"><i class="icon-trash"></i></a>
            <a href="places/index#/edit/{{place.id}}" class="pull-right" rel="tooltip" title="{{ 'UPDATE' | translate }}"><i class="icon-pencil"></i></a>
        </li>
    </ul>

    <span ng-show="isEmpty()">{{ 'NO_PLACES' | translate }}</span>

    <div>
        <a href="places/index#/add" class="btn btn-success">{{ 'ADD_PLACE' | translate }}</a>
    </div>
</div>

Для вывода списка объектов используем директиву ng-repeat (аналог foreach цикла). В качестве её значения указываем place in places. Значение places – это массив, который мы сохранили в $scope при инициализации контроллера.

Сразу после старта приложения $scope.places будет пуст, т.к. сервис Places вряд ли успеет получить от сервера ответ. Поэтому список будет пустым, и мы увидим соответствующее сообщение (строка 11). Обратите внимание на директиву ng-show. Она устанавливает CSS правило display в зависимости от результата, который возвращает функция isEmpty.

Как только сервис Places получит ответ сервера, он инициирует событие places:updated. Наш контроллер «слушает» это событие и как только оно появляется, ещё раз вызывает метод getAll сервиса. Т.е. в $scope.places будет сохранён массив с объектами. Напомню, $scope.places является моделью и AngularJS отслеживает изменения в её состоянии. Когда $scope.places изменяется, автоматически запускается рендеринг представления, связанного с данным контроллером. В результате будет сформирован список объектов с кнопками «Изменить» и «Удалить».

Обратите внимание на то, как формируется ссылка «Изменить» (строка 7). В атрибуте href мы указываем places/index#/edit/{{place.id}}, где place.id – id текущего объекта. В результате, клик по этой ссылке приведёт к срабатыванию роутера, который загрузит PlacesFormController с соответствующим шаблоном. Аналогично работает и клик по кнопке «Создать объект».

Контроллер формы

app.controller('PlacesFormController'
    , ['$scope', '$rootScope', 'Places', '$routeParams', '$location'
    , function($scope, $rootScope, Places, $routeParams, $location) {

    $scope.place = {};
    $scope.isNew = true;
    $scope.saving = false;

    $scope.showErrors = false;
    $scope.errors = [];

    if ($routeParams.placeId !== undefined) {
        $scope.place = Places.get($routeParams.placeId);
        if (undefined === $scope.place || null === $scope.place) {
            //place with this id not found
            $location.path('/add').replace();
        }
        $scope.isNew = false;
    }

    $scope.save = function() {
        Places.save($scope.place);
        $scope.saving = true;
        $scope.showErrors = false;
    }

    $rootScope.$on('place:updated', function() {
        $scope.saving = false;
    });

    $rootScope.$on('place:added', function(event, data) {
        $scope.saving = false;
        $location.path('/list').replace();
    });

    $rootScope.$on('place:error', function(event, data) {
        $scope.showErrors = true;
        $scope.errors = [];
        angular.forEach(data.errors, function(error) {
            if (typeof error == 'object') {
                angular.forEach(error, function(err) {
                    $scope.errors.push(err);
                });
            }
            else {
                $scope.errors.push(error);
            }
        });
        $scope.saving = false;
    });

    $rootScope.$on('map:pointSelected', function(event, data) {
        $scope.place.p_lat = data.p_lat;
        $scope.place.p_lng = data.p_lng;
    });
}]);

Форма может использоваться как для создания нового объекта, так и для редактирования существующего. Модель объекта храниться в $scope.place. Во время инициализации контроллер проверяет, указан ли id объекта и пытается его получить (строки 12-19). Если объект найден, $scope.isNew будет равен false.

Процесс сохранения объекта. Когда пользователь нажимает кнопку «Создать», приложение (сервис Places) отправляет асинхронный запрос с данными объекта. Ответ на этот запрос придёт через некоторое время, поэтом нам важно исключить возможность повторного нажатия на кнопку «Создать». Кроме того, пользователь должен видеть, что процесс сохранения запущен.

Для этого в методе $scope.save мы устанавливаем $scope.saving = true; (строка 23). Также устанавили обработчики событий place:updated и place:added (строки 27-34). Эти события инициирует сервис Places, когда получает сообщения об успешном обновлении или создании объекта. Обработчики устанавливают $scope.saving = false. Теперь в шаблоне формы мы можем использовать $scope.saving для того, чтобы показать пользователю, например, картинку с загрузчиком.

<span ng-show="saving"><img ng-src="images/ajax-loader.gif" alt="loader"></span>

Во время сохранения может произойти какая-нибудь ошибка. Информацию о ней мы должны показать пользователю. Для этого устанавливаем обработчик place:error. Это также событие сервиса Places, вместе с которым передаются описания ошибок. Описания мы сохраняем в массиве $scope.errors. Таким образом, мы сможем отобразить их в шаблоне.

Обработчик события map:pointSelected (строки 52-55) предназначен для установки координат. Это событие инициирует директива (её мы рассмотрим в следующей части) когда пользователь кликает по карте. Обработчик просто сохраняет соответствующие свойства объекта $scope.place.

Шаблон формы

Файл partials/form.html

<form name="placeForm" novalidate>

    <h4 ng-show="isNew">{{ 'CREATE_PLACE' | translate }}</h4>
    <h4 ng-hide="isNew">{{ 'UPDATE_PLACE' | translate }}</h4>

    <label>{{ 'TITLE' | translate }} *:<br><input type="text" ng-model="place.p_title" name="pTitle" required class="span12">
    <span class="text-error" ng-show="placeForm.$dirty && placeForm.pTitle.$error.required">{{ 'FIELD_NOT_EMPTY' | translate }}.</span></label>

    <label>{{ 'DESCRIPTION' | translate }} (<a href="http://daringfireball.net/projects/markdown/" target="_blank">Markdown</a> {{ 'ENABLED' | translate }}):<br>
        <textarea ng-model="place.p_description" class="span12"></textarea>
    </label>

    <label for="placeLat">{{ 'LATITUDE' | translate }}:</label>
    <div class="input-append">
        <input type="text" id="placeLat" ng-model="place.p_lat" placeholder="{{ 'CLICK_ON_THE_MAP' | translate }}" disabled>
        <span class="add-on">&deg;</span>
    </div>

    <label for="placeLng">{{ 'LONGITUDE' | translate }}:</label>
    <div class="input-append" class="span12">
        <input type="text" id="placeLng" ng-model="place.p_lng" placeholder="{{ 'CLICK_ON_THE_MAP' | translate }}" disabled>
        <span class="add-on">&deg;</span>
    </div>

    <div class="alert alert-error" ng-show="showErrors">
        <button type="button" class="close" data-dismiss="alert">&times;</button>
        <h4>{{ 'ERRORS' | translate }}:</h4>
        <ul>
            <li ng-repeat="error in errors">{{ error }}</li>
        </ul>
    </div>

    <button type="submit" ng-disabled="placeForm.$invalid || saving" ng-click="save()" class="btn btn-primary">
        <span ng-show="isNew">{{ 'CREATE' | translate }}</span>
        <span ng-hide="isNew">{{ 'UPDATE' | translate }}</span>
        <span ng-show="saving"><img ng-src="images/ajax-loader.gif" alt="loader"></span>
    </button>

    <a href="places/index#/list" class="btn">{{ 'CANCEL' | translate }}</a>

</form>

Заголовков формы у нас два (Создать или Изменить форму). Мы выбираем нужный с помощью директив ng-show и ng-hide (строки 3, 4). Эти директивы можно рассматривать как аналог условий.

Разметка формы достаточно стандартная. Используются классы Twitter Bootstrap. Значения поля формы получают напрямую из моделей с помощью директив ng-model. В значении директивы нужно указать модель или её свойство. Например, модель place ($scope.place) является объектом, поэтому для поля с заголовком объекта мы указываем ng-model="place.p_title".

Использование ng-model автоматически подключает двунаправленное связывание данных. Т.е. если пользователь изменит значение в поле, оно будет сразу же записано в $scope.place. И наоборот, любое изменение $scope.place или его атрибутов будет отображаться в соответствующем поле. Таким образом, в явном виде задавать атрибут value для тегов input не нужно.

Отображение ошибок формы

Мы уже рассмотрели контроллер формы и знаем, что он обрабатывает сообщение place:error. Но это сообщение передаёт список ошибок сервера, т.е. пользователь увидит его только после того, как попытается сохранить форму. Но мы можем выполнить проверку с помощью JavaScript, и AngularJS предоставляет довольно удобный механизм для этих целей.

Для каждой формы автоматически создаётся объект с названием, совпадающим со значением атрибута name формы. Этот объект хранит информацию о состоянии формы и наличии ошибок в её полях.

Состояний всего пять:

  • $pristine – пользователь не работал с формой;
  • $dirty – пользователь вводил какую-то информацию;
  • $valid – принимает значение true, если валидация прошла без ошибок для всех полей;
  • $invalid – true если хотя бы одно из полей не валидно;
  • $error – хеш, содержащий ссылки на все не валидные поля.

В стандартном варианте AngularJS поддерживает проверку большинства распространённых полей (text, number, url, email, radio, checkbox), а для установки правил проверки используются атрибуты – required, pattern, minlength, maxlength, min, max.

Рассмотрим в качестве примера поле с названием объекта.

<input type="text" ng-model="place.p_title" name="pTitle" required>
<span ng-show="placeForm.$dirty && placeForm.pTitle.$error.required">{{ 'FIELD_NOT_EMPTY' | translate }}.</span>

Для поля мы установили атрибут required, т.е. поле является обязательным. А тег span будет отображаться, если пользователь работал с формой placeForm.$dirty == true и оставил название пустым placeForm.pTitle.$error.required == true. Без placeForm.$dirty сообщение об ошибке отображалось бы ещё до того, как пользователь начал ввод данных.

Тестирование контроллеров

В прошлой части мы рассматривали тестирование сервисов с помощью фреймворка Jasmine. Принцип тестирования контроллеров очень похож, но есть нюансы. Рассмотрим их на примере тестов для списка объектов (файл test/unit/js/controllers/list.spec.js).

describe('Places List controller', function() {
    var scope, rootScope, dialog, routeParams, controller, places;

    beforeEach(function() {
        module('personalmaps', function($provide) {
            $provide.value('lang', '');
        });

        inject(function($rootScope, $controller, $routeParams, $dialog, lang) {
            rootScope = $rootScope;
            scope = $rootScope.$new();
            dialog = $dialog;
            routeParams = $routeParams;
            controller = $controller;

            dialog = {
                messageBox: function() {}
            }
            spyOn(dialog, 'messageBox').andReturn({
                open: function() {
                    return {
                        then: function() {}
                    }
                }
            });

            places = jasmine.createSpyObj('Places', ['getAll', 'get', 'add', 'update', 'delete', 'save']);
        });
    });

    it('should call getAll', function() {
        controller('PlacesListController', {$scope: scope, $rootScope: rootScope, 'Places': places, $routeParams: routeParams, $dialog: dialog});
        expect(places.getAll).toHaveBeenCalled();
    });

    it('should call getAll on places:updated event', function() {
        controller('PlacesListController', {$scope: scope, $rootScope: rootScope, 'Places': places, $routeParams: routeParams, $dialog: dialog});
        rootScope.$emit('places:updated');
        expect(places.getAll.calls.length).toEqual(2);
    });

    it('should open dialog on confirm call', function() {
        controller('PlacesListController', {$scope: scope, $rootScope: rootScope, 'Places': places, $routeParams: routeParams, $dialog: dialog});
        scope.confirm();
        expect(dialog.messageBox).toHaveBeenCalled();
    });
});

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

Для этого мы используем функцию beforeEach.

В первую очередь создаём модуль приложения (строки 5-7). Затем с помощью inject (строки 9-29) подключаем зависимости. Здесь есть нюансы. Подключение стандартных компонентов AngularJS (вроде $rootScope) мы рассматривали в прошлой части. Но сейчас мы тестируем контроллер и для него нам нужно создать scope. В самом приложении это происходит автоматически, когда Angular обрабатывает директиву ng-controller или ng-view, но при тестировании это нужно сделать вручную:

scope = $rootScope.$new();

Кроме того, в нашем контроллере используется $dialog (создаёт окно с просьбой подтвердить удаление). Наш контроллер вызывает методы этого компонента и нам нужно протестировать, что эти методы вызываются правильно. Но использовать настоящий $dialog не желательно, т.к. нам нужно зафиксировать только факт вызова метода, работа компонента нас сейчас не интересует. Поэтому мы создаём mock объект, который содержит функцию messageBox

dialog = {
    messageBox: function() {}
}

и с помощью функции spyOn указываем Jasmine, что нужно отслеживать вызовы messageBox

spyOn(dialog, 'messageBox').andReturn({
	open: function() {
		return {
			then: function() {}
		}
	}
});

Тут же указываем, какой объект должен вернуть вызов messageBox. Это тоже заглушка, содержащая функции open и then. Она необходима, потому что в контроллере (list.js, строки 25-31) мы эти функции вызываем.

Также нам нужно отследить, как контроллер вызывает методы сервиса Places. Реальный сервис мы не используем (для него у нас тесты написаны), а создаём mock объект.

places = jasmine.createSpyObj('Places', ['getAll', 'get', 'add', 'update', 'delete', 'save']);

В первом параметре функции createSpyObj указываем имя сервиса, во втором – массив с именами методов.

Теперь можно написать тесты (строки 31-46).

В каждом тесте мы сначала создаём контроллер

controller('PlacesListController', {$scope: scope, $rootScope: rootScope, 'Places': places, $routeParams: routeParams, $dialog: dialog});

и затем имитируем поведение пользователя. Например, вызываем метод confirm и проверяем, что в ответ контроллер вызвал метод dialog.messageBox.

scope.confirm();
expect(dialog.messageBox).toHaveBeenCalled();

Точно также проверяем реакцию на события.

rootScope.$emit('places:updated');
expect(places.getAll.calls.length).toEqual(2);

Метод getAll вызывался дважды, т.к. в первый раз он был вызван при создании контроллера, а во второй – при получении события places:updated.

Тесты для формы я описывать не буду, т.к. они аналогичны и вы можете посмотреть их на GitHub.

В следующей части мы рассмотрим создание директивы для подключения Google Map.

Если есть вопросы или замечания, пишите.

Содержание