Skip to content

3. Клієнт Angular JS

3.1. Посилання на фреймворк Angular JS

На початку цього документа було наведено два посилання на фреймворк Angular JS. Наводимо їх ще раз:

  • [ref1]: книга «Pro AngularJS», написана Адамом Фріменом у видавництві Apress. Це чудова книга. Вихідні коди прикладів із цієї книги доступні безкоштовно на сайті URL [http://www.apress.com/downloadable/download/sample/sample_id/1527/];
  • [ref2]: офіційна документація Angular JS [https://docs.angularjs.org/guide];

Angular JS заслуговує на окрему книгу. Книга Адама Фрімена налічує понад 600 сторінок, і жодна з них не є зайвою. Ми опишемо додаток на Angular, і в ході цього опису нам доведеться торкнутися основ цього фреймворку. Проте ми обмежимося лише тими поясненнями, які необхідні для розуміння запропонованого рішення. Angular — надзвичайно багатофункціональний фреймворк, і існує безліч способів досягти одного й того самого результату. Це створює певні труднощі, оскільки на початку шляху важко зрозуміти, чи використовуєш ти гірше чи краще рішення, ніж інші. Це стосується й запропонованого тут рішення. Його можна було б реалізувати інакше, можливо, з використанням кращих практик.

3.2. Архітектура клієнта Angular

Архітектура клієнта Angular схожа на архітектуру класичного веб-додатка MVC, але має деякі відмінності. Наприклад, веб-додаток Spring MVC має таку архітектуру:

Обробка запиту від клієнта відбувається наступним чином:

  1. запит — запитувані URL мають вигляд http://machine:port/contexte/Action/param1/param2/....?p1=v1&p2=v2&... [Dispatcher Servlet] — це клас Spring, який обробляє вхідні URL. Вона «направляє» URL до дії, яка має її обробити. Ці дії — це методи певних класів, що називаються [Contrôleurs]. Літера «C» у MVC тут є рядком [Dispatcher Servlet, Contrôleur, Action]. Якщо для обробки вхідного запиту URL не було налаштовано жодної дії, сервлет [Dispatcher Servlet] відповість, що запитуваний URL не знайдено (помилка 404 NOT FOUND);
  1. обробка
  • обрана дія може використовувати параметри parami, які їй передала сервлета [Dispatcher Servlet]. Вони можуть походити з різних джерел:
    • шляху [/param1/param2/...] від URL,
    • параметрів [p1=v1&p2=v2] з URL,
    • параметрів, надісланих браузером разом із запитом;
  • під час обробки запиту користувача дії може знадобитися рівень [metier] [2b]. Після обробки запиту клієнта це може викликати різні відповіді. Класичним прикладом є:
    • сторінка з повідомленням про помилку, якщо запит не вдалося обробити належним чином
    • сторінка підтвердження в іншому випадку
  • дія вимагає відображення певного виду [3]. Цей вид відображатиме дані, які називаються моделлю виду. Це «M» у MVC. Дія створить цю модель M [2c] і вимагатиме відображення виду V [3];
  1. відповідь — обраний вигляд V використовує модель M, створену дією, для ініціалізації динамічних частин відповіді HTML, яку він повинен надіслати клієнту, а потім надсилає цю відповідь.

Архітектура нашого клієнта Angular буде аналогічною, але з дещо іншою термінологією. По-перше, додатки Angular зазвичай є односторінковими веб-додатками (APU) або Single Page Application (SPA):

Image

  • користувач запитує початкову сторінку URL додатка у вигляді: http://machine:port/contexte. Браузер надсилає запит до веб-сервера, щоб отримати запитуваний документ. Це сторінка HTML, оформлена за допомогою CSS та динамізована за допомогою JavaScript;
  • після чого користувач взаємодіє з представленими йому елементами інтерфейсу. Можна виділити різні види взаємодій:
    • ті, що не потребують жодної взаємодії із зовнішнім середовищем, наприклад, приховування/відображення елементів вікна. Вони обробляються вбудованим Javascript;
    • ті, що вимагають даних із віддаленого веб-сервісу. Вони отримуються за допомогою виклику AJAX (Asynchronous JavaScript And XML), після чого будується модель і відображається вікно;
    • ті, що вимагають іншого виду, ніж початковий. Він буде запитаний за допомогою виклику Ajax до сервера, який надав початкову сторінку. Потім попередній процес повториться. Отримана сторінка буде збережена в кеші браузера. Під час наступного виклику її не буде запитуватися у віддаленого сервера HTML;

У підсумку браузер здійснює лише один запит HTTP — той, що отримує початкову сторінку. Наступні запити HTTP до сервера сторінок HTML або віддалених веб-сервісів виконуються за допомогою вбудованого в сторінки JavaScript.

Тепер розглянемо архітектуру додатка в браузері. Ми не враховуємо сервер HTML, який надає сторінки HTML додатка. Для пояснення можна вважати, що всі вони вже знаходяться у кеші браузера.

Насамперед слід визначити місце розташування цієї архітектури:

  • у [1] ми перебуваємо в браузері;
  • у [2] користувач взаємодіє з елементами інтерфейсу, що відображаються браузером;
  • у [3] дані шукаються в мережі, часто у веб-сервісах;

Користувач взаємодіє з екранами: він заповнює форми та підтверджує їх. Розглянемо цей процес на прикладі екрана V1, наведеного вище. Припустимо, що це початковий екран додатка. Він був отриманий таким чином:

  • користувач запитує початковий вигляд URL додатка у формі: http://machine:port/contexte;
  • браузер запросив документ, пов’язаний із цим URL. Він отримав сторінку HTML / CSS / JS із екрану V1;
  • після чого вбудований у сторінку JavaScript взяв управління на себе та передав контроль контролеру C1 [5];
  • останній побудував модель M1 [8] [9] для подання V1. Для побудови цієї моделі, можливо, знадобилося використання внутрішніх служб [6] та звернення до зовнішніх служб [7];

Зараз перед користувачем відкрито вікно V1. Уявімо, що це форма. Він заповнює її, а потім підтверджує:

  • у [4] користувач підтверджує форму;
  • у [5] ця подія буде оброблена одним із методів контролера C1;

якщо подія призводить лише до простої зміни подання V1 (приховати/показати поля), контролер C1 змінить модель M1 подання V1, а потім знову відобразить подання V1. Для цього йому можуть знадобитися одні з сервісів шару [services] [6].

Якщо подія вимагає зовнішніх даних:

  • у [6] контролер C1 звернеться до шару [DAO] із запитом на їх отримання;
  • у [7] цей рівень здійснить один або кілька викликів AJAX для їх отримання;
  • у [8] та [9] модель M1 буде змінена, а представлення V1 — відображене;

Якщо подія призводить до зміни подання, у двох попередніх випадках замість відображення подання V1 контролер C1 запросить нове подання URL [10]. Це внутрішня для браузера URL. Вона не призводить одразу до виклику HTTP до сервера сторінок HTML. Ця зміна URL обробляється маршрутизатором, налаштованим таким чином, що кожному внутрішньому URL відповідає представлення V та його контролер C. Після цього маршрутизатор забезпечує відображення нового представлення Vn. Перед відображенням його контролер Cn бере управління на себе, будує модель Mn, а потім забезпечує відображення подання Vn [11]. Якщо сторінка HTML подання Vn не зберігалася в кеші браузера, її буде запитано у сервера сторінок HTML.

Рівень [Présentation] цієї архітектури близький до архітектури JSF (Java Server Faces):

  • представлення V відповідає представленню типу Facelet у JSF;
  • контролер C відповідає bean-у JSF — класу Java, який містить як модель M подання V, так і обробники його подій;

Рівень [Services] відрізняється від рівнів [Services], до яких ми звикли. У серверній частині веб-розробки найчастіше використовується така багаторівнева архітектура:

У наведеному вище прикладі шар [web] взаємодіє з шаром [DAO] лише через шар [métier]. Ніщо не заважає нам ввести в шар [web] посилання на шар [DAO], що дозволило б здійснити цей обмін даними. Але ми цього не робимо.

З Angular ми цього не забороняємо. Тоді архітектура виглядає так:

  • у [1] шар [présentation] може безпосередньо взаємодіяти з будь-яким сервісом;
  • у [2] сервіси взаємодіють між собою. Один сервіс може використовувати один або кілька інших.

3.3. Види клієнта Angular

Види клієнта Angular вже були представлені в розділі 1.3.3. Щоб полегшити читання цього нового розділу, ми наводимо їх тут ще раз. Перший вид має такий вигляд:

  • у [6] — це сторінка входу в додаток. Це додаток для запису на прийом до лікарів;
  • [7] — прапорець, що дозволяє ввімкнути або вимкнути режим [debug]. Останній характеризується наявністю рамки [8], яка відображає шаблон поточного виду;
  • у [9] — штучна тривалість очікування в мілісекундах. За замовчуванням вона дорівнює 0 (очікування відсутнє). Якщо N — значення цього часу очікування, будь-яка дія користувача буде виконана після очікування тривалістю N мілісекунд. Це дозволяє побачити, як додаток реалізує управління очікуванням;
  • у [10] — URL сервера Spring 4. Якщо слідувати попередньому, це [http://localhost:8080];
  • у [11] та [12] — ідентифікатор та пароль користувача, який бажає скористатися додатком. Є два користувачі: admin/admin (логін/пароль) з роллю (ADMIN) та user/user з роллю (USER). Лише роль ADMIN має право користуватися додатком. Роль USER наведено лише для того, щоб показати, що відповідає сервер у цьому випадку використання;
  • у [13] — кнопка, що дозволяє підключитися до сервера;
  • у [14] — мова додатка. Їх дві: французька (за замовчуванням) та англійська.
  • у [1] — підключення;
  • після входу можна вибрати лікаря, до якого хочете записатися на прийом [2], та день прийому [3];
  • за допомогою [4] можна переглянути розклад обраного лікаря на обраний день;
  • отримавши розклад лікаря, можна забронювати час прийому [5];
  • у [6] вибирають пацієнта для прийому та підтверджують цей вибір у [7];

Після підтвердження прийому система автоматично повертає до календаря, де новий прийом тепер занесено. Цей прийом можна буде згодом видалити за допомогою [7].

Основні функції описано. Вони прості. Ті, що не були описані, — це функції навігації для повернення до попереднього екрана. На завершення розглянемо управління мовою:

  • у [1] можна перейти з французької на англійську;

2

Image

  • на [2] — перехід на англійську мову, включаючи календар;

3.4. Налаштування проекту Angular

Ми будемо поступово створювати наш клієнт Angular. Ми використовуємо IDE Webstorm.

Створимо порожню папку [rdvmedecins-angular-v1], а потім відкриємо її в WebStorm:

  • у [1] відкриваємо папку;
  • у [2] вказуємо папку, яку ми створили;
  • у [3] ми отримуємо порожній проєкт WebStorm;
  • у [4] налаштування проєкту здійснюється за допомогою опції [File / Settings];
  • У файлах [5] та [6] налаштовується властивість [Spelling], яка відповідає за перевірку орфографії. За замовчуванням ця функція увімкнена. Оскільки завантажене програмне забезпечення англійською мовою, наші коментарі до програм французькою мовою будуть підкреслені як можливі орфографічні помилки. Тому ми вимикаємо цю перевірку орфографії [7];
  • у [8] створюємо новий файл;
  • у [9] вибираємо створення файлу [package.json], який описує програму за допомогою синтаксису JSON;
  • у [10] — згенерований файл, який ми редагуємо, як показано у [11];
  • у [12] цей файл зберігається одночасно в [package.json] та [bower.json];
  • у [13] знову налаштовують проєкт;
  • у [14] налаштовуємо властивість [Javascript / Bower], яка дозволить нам вказати необхідні нам бібліотеки JavaScript;
  • у [15] вказуємо файл [bower.json], який ми щойно створили;
  • у [16] додамо бібліотеку JavaScript;
  • у [17] відображаються всі бібліотеки JavaScript, які можна завантажити;
  • у [18] ми можемо вказати критерій для фільтрації списку [17]. Тут ми вказуємо, що нам потрібна бібліотека [Angular JS];
  • у [19] відображаються характеристики бібліотеки. Тут ми бачимо, що буде завантажено версію Angular 1.2.18;
  • у [20] відбувається завантаження;
  • у [21] видно, що вона завантажена;
  • у [22] бачимо завантажену версію. Тож насправді це версія 1.2.19;
  • у [23] бачимо останню доступну версію;
  • у [24], дотримуючись того самого підходу, що й раніше, завантажуються такі бібліотеки:
angular-base64
для кодування рядка «user:password» у Base64;
angular-i18n
для інтернаціоналізації календаря
angular-route
для перенаправлення внутрішніх URL додатка до відповідного контролера та відповідного виду;
angular-translate
дозволяє інтернаціоналізувати подання. Це проект, незалежний від Angular. Тут будуть використовуватися дві мови: французька та англійська;
angular-ui-bootstrap-bower
надає візуальні компоненти, сумісні з Bootstrap. Тут ми будемо використовувати його календар;
bootstrap
фреймворк CSS Bootstrap. Використовуватиметься для побудови представлень;
footable
надає візуальний компонент типу «таблиця». Він є «адаптивним» у тому сенсі, що може пристосовуватися до розміру екрану;
bootstrap-select
надає компонент типу «випадаючий список»;
  • у [25] завантажені бібліотеки було встановлено у папку [bower_components];
  • у [26] видно, що бібліотека JQuery була завантажена. Це тому, що її використовує Bootstrap. Система встановлення JavaScript-залежностей проекту аналогічна системі Maven у світі Java: якщо завантажена бібліотека сама має залежності, то вони завантажуються автоматично;

Файл [bower.json] зазнав змін:

{
  "name": "rdvmedecins-angular",
  "version": "0.0.1",
  "dependencies": {
    "angular": "~1.2.18",
    "angular-base64": "~2.0.2",
    "angular-route": "~1.2.18",
    "angular-translate": "~2.2.0",
    "bootstrap": "~3.1.1",
    "footable": "~2.0.1",
    "angular-ui-bootstrap-bower": "~0.11.0",
    "bootstrap-select": "~1.5.2"
  }
}

Усі завантажені залежності були внесені до файлу.

3.5. Початкова сторінка клієнта Angular

Ми створюємо першу версію початкової сторінки клієнта Angular:

  • у файлах [1] та [2], створюємо файл HTML з іменами [app-01], [3] та [4];

Файл [app-01.html] деякий час буде нашою головною сторінкою. У ньому ми налаштуємо імпорт файлів CSS та JS, які потрібні додатку:


<!DOCTYPE html>
<html>
<head>
  <title>RdvMedecins</title>
  <!-- META -->
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta name="description" content="Angular client for RdvMedecins">
  <meta name="author" content="Serge Tahé">
  <!-- CSS -->
  <link href="bower_components/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet" />
  <link href="bower_components/bootstrap/dist/css/bootstrap-theme.min.css" rel="stylesheet"/>
  <link href="bower_components/bootstrap-select/bootstrap-select.min.css" rel="stylesheet"/>
  <link href="bower_components/footable/css/footable.core.min.css" rel="stylesheet"/>
</head>
<body>
<div class="container">
  <h1>Rdvmedecins - v1</h1>
</div>
<!-- Ядро Bootstrap JavaScript ================================================== -->
<script type="text/javascript" src="bower_components/jquery/dist/jquery.min.js"></script>
<script type="text/javascript" src="bower_components/bootstrap/dist/js/bootstrap.min.js"></script>
<script type="text/javascript" src="bower_components/bootstrap-select/bootstrap-select.min.js"></script>
<script type="text/javascript" src="bower_components/footable/dist/footable.min.js"></script>
<!-- AngularJS -->
<script type="text/javascript" src="bower_components/angular/angular.min.js"></script>
<script type="text/javascript" src="bower_components/angular-ui-bootstrap-bower/ui-bootstrap-tpls.min.js"></script>
<script type="text/javascript" src="bower_components/angular-route/angular-route.min.js"></script>
<script type="text/javascript" src="bower_components/angular-translate/angular-translate.min.js"></script>
<script type="text/javascript" src="bower_components/angular-base64/angular-base64.min.js"></script>
</body>
</html>
  • рядки 11–12: файли CSS для Bootstrap;
  • рядок 13: файл CSS для компонента [boostrap-select];
  • рядок 14: файл CSS для компонента [footable];
  • рядки 21–24: файли JS для компонентів Bootstrap;
  • рядок 21: компоненти Bootstrap працюють на базі JQuery;
  • рядок 22: файл JS для Bootstrap;
  • рядок 23: файл JS для компонента [boostrap-select];
  • рядок 24: файл JS для компонента [footable];
  • рядки 26–30: файли JS для Angular та пов’язаних з ним проєктів;
  • рядок 26: файл JS для Angular. Він повинен завантажуватися після JQuery, якщо використовується ця бібліотека;
  • рядок 27: файл JS проекту [angular-ui-bootstrap];
  • рядок 28: файл JS маршрутизатора [angular-route];
  • рядок 29: файл JS з модуля інтернаціоналізації додатків Angular;
  • рядок 30: файл JS з модуля [angular-base64];

Валідність файлу [app-01.html] можна перевірити:

  • у [1] вимагається перевірка коду;
  • у файлі [2] наведено результат, коли все гаразд;

Рекомендується проводити таку систематичну перевірку коду перед його виконанням. У цьому випадку така перевірка дозволяє виявити будь-які помилки посилань у файлах CSS та JS. Якщо шлях вказано неправильно, інспектор коду про це повідомить.

  • У [3] сторінку можна завантажити в браузері за допомогою відладчика. У браузері ми отримуємо такий результат:
  • у [4] сторінка [app-01.html] була надана внутрішнім сервером WebStorm, що працює тут на порту 63342;
  • у [5] — консоль відладчика. Якби виникли помилки, вони б з’явилися саме тут. Саме сюди також надходять екранні виводи, що генеруються інструкцією [console.log(expression)] у JavaScript. Ми активно використовуватимемо цю можливість;

Режим налагодження дозволяє вносити зміни на сторінку в WebStorm і бачити результати цих змін у браузері без необхідності перезавантаження сторінки. Отже, якщо ми додамо рядок 3, наведений нижче:


<div class="container">
  <h1>Rdvmedecins - v1</h1>
  <h2>Version 1</h2>
</div>

і повернемося до браузера, то побачимо, що сторінка змінилася:

 

3.6. Ознайомлення з Bootstrap

Тепер ми проілюструємо деякі особливості Bootstrap, що використовуються в додатку. Я маю лише обмежені знання про цей фреймворк, отримані шляхом копіювання та вставлення коду, знайденого в Інтернеті. Я поясню роль класів CSS, які, як я вважаю, розумію. Щодо інших я утримаюся від коментарів.

3.6.1. Приклад 1

У Angular операції, що отримують інформацію ззовні, є асинхронними. Це означає, що операція запускається, а контроль негайно повертається до вікна, з яким користувач може продовжувати взаємодіяти. Про завершення операції додаток повідомляється за допомогою події. Ця подія обробляється функцією JS, яка може доповнити поточний вигляд або змінити його. Якщо операція може тривати довго, доцільно надати користувачеві можливість її скасувати. Ми будемо пропонувати цю можливість завжди. Для цього ми використаємо банер Bootstrap:

Image

Щоб отримати цей результат, ми дублюємо [app-01.html] у [app-02.html] і змінюємо такі рядки:


<div class="container">
  <h1>Rdvmedecins - v1</h1>
  <div class="alert alert-warning">
    <h1>Opération en cours. Veuillez patienter...
      <button class="btn btn-primary pull-right">Annuler</button>
      <img src="assets/images/waiting.gif" alt=""/>
    </h1>
  </div>
</div>
  • рядок 1: клас CSS [container] визначає область відображення всередині браузера;
  • рядок 3: клас CSS [alert] відображає кольорову область. Клас [alert-warning] використовує попередньо визначений колір;
  • рядок 5: клас [btn] оформлює кнопку. Клас [btn-primary] надає їй певний колір. Клас [pull-right] розміщує її праворуч від панелі сповіщень;
  • рядок 6: анімоване зображення очікування;

3.6.2. Приклад 2

Різні вікна додатка матимуть спільний заголовок:

Image

Щоб отримати цей результат, ми дублюємо [app-01.html] у [app-03.html] і змінюємо такі рядки:


<div class="container">
  <h1>Rdvmedecins - v1</h1>
  <!-- Bootstrap Jumbotron -->
  <div class="jumbotron">
    <div class="row">
      <div class="col-md-2">
        <img src="assets/images/caduceus.jpg" alt="RvMedecins"/>
      </div>
      <div class="col-md-10">
        <h1>Les Médecins associés</h1>
      </div>
    </div>
  </div>
</div>
  • зафарбована область створюється за допомогою класу [jumbotron] у рядку 4;
  • рядок 5: клас [row] визначає рядок із 12 стовпцями;
  • рядок 6: клас [col-md-2] визначає зону з двома стовпцями в рядку;
  • рядок 7: у ці два стовпці розміщується зображення;
  • рядки 9–11: у решту 10 стовпців розміщується текст;

3.6.3. Приклад 3

Вікна матимуть верхню панель керування. На ній будуть розміщені опції керування, посилання або кнопки. Також там будуть елементи форми. Наприклад:

Щоб отримати цей результат, ми дублюємо [app-01.html] у [app-04.html] і змінюємо такі рядки:


<div class="container">
  <h1>Rdvmedecins - v1</h1>

  <div class="navbar navbar-inverse navbar-fixed-top" role="navigation">
    <div class="container">
      <div class="navbar-header">
        <button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
          <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="navbar-brand" href="#">RdvMedecins</a>
      </div>
       <div class="navbar-collapse collapse">
        <form class="navbar-form navbar-right">
          <!-- режим налагодження -->
          <label style="width: 100px">
            <input type="checkbox">
            <span style="color: white">Debug</span>
          </label>
          <!-- форма входу -->
          <div class="form-group">
            <input type="text" class="form-control" placeholder="Temps d'attente"
                   style="width: 150px"/>
            <input type="text" class="form-control" placeholder="URL du service web"
                   style="width: 200px"/>
            <input type="text" class="form-control" placeholder="Login"
                   style="width: 100px"/>
            <input type="password" class="form-control" placeholder="Mot de passe"
                   style="width: 100px"/>
          </div>
          <button class="btn btn-success">
            Connexion
          </button>
        </form>
      </div>
          <button class="btn btn-success">
            Connexion
          </button>
        </form>
      </div>
    </div>
  </div>
</div>
  • рядок 4: клас [navbar] визначає стиль навігаційної панелі. Клас [navbar-inverse] надає їй чорний фон. Клас [navbar-fixed-top] забезпечить, щоб під час прокрутки сторінки, що відображається браузером, панель навігації залишалася у верхній частині екрана;
  • рядки 6–14: визначають зону [1]. Це типова серія класів, яку я не розумію. Я використовую компонент таким, яким він є;
  • рядок 15: визначає «адаптивну» зону панелі керування. На смартфоні ця зона зникає в меню;
  • рядок 16: клас [navbar-form] оформлює форму панелі керування. Клас [navbar-right] розміщує її праворуч від панелі;
  • рядки 23–32: чотири поля введення форми з рядка 17 — [3]. Вони знаходяться всередині класу [form-group], який оформлює елементи форми, і кожне з них має клас [form-control];
  • рядок 33: клас [btn], з яким ми вже зустрічалися, доповнений класом [btn-success], що надає йому зеленого кольору;

3.6.4. Приклад 4

Панель керування дозволить змінювати мову за допомогою випадаючого списку:

Image

Щоб отримати цей результат, ми дублюємо [app-01.html] у [app-05.html] і додаємо до панелі керування такі рядки:


          <button class="btn btn-success">
            Connexion
          </button>
          <!-- мови -->
          <div class="btn-group">
            <button type="button" class="btn btn-danger">
              Langues
            </button>
            <button type="button" class="btn btn-danger dropdown-toggle" data-toggle="dropdown">
              <span class="caret"></span>
              <span class="sr-only">Toggle Dropdown</span>
            </button>
            <ul class="dropdown-menu" role="menu">
              <li>
                <a href="">Français</a>
              </li>
              <li>
                <a href="">English</a>
              </li>
            </ul>
          </div>
</form>

Додані рядки — це рядки 4–21.

  • рядок 5: клас [btn-group] оформлює групу кнопок. Їх дві — у рядках 6 та 9;
  • рядки 6–8: перша кнопка визначає напис у випадаючому списку. Клас [btn-danger] надає їй червоного кольору;
  • рядки 9–12: друга кнопка — це кнопка випадаючого списку. Вона розташована поруч із першою, що створює враження єдиного компонента;
  • рядок 10: відображає стрілку вниз, що вказує на те, що кнопка є випадаючим списком;
  • рядок 11: для «програм-зчитувачів екрану»;
  • рядки 13–20: елементи випадаючого списку є елементами неупорядкованого списку;

3.6.5. Приклад 5

Для підтвердження форми або навігації користувач матиме у панелі керування такі опції або кнопки, як показано нижче:

Пункти меню були додані в [1]. Щоб отримати цей результат, ми дублюємо [app-01.html] у [app-06.html] і додаємо такі рядки:


<div class="navbar navbar-inverse navbar-fixed-top" role="navigation">
    <div class="container">
      <div class="navbar-header">
...
      </div>
      <!-- параметри меню -->
      <div class="collapse navbar-collapse">
        <ul class="nav navbar-nav">
          <li class="active">
            <a href="">
              <span>Home</span>
            </a>
          </li>
          <li class="active">
            <a href="">
              <span>Agenda</span>
            </a>
          </li>
          <li class="active">
            <a href="">
              <span>Valider</span>
            </a>
          </li>
          <li class="active">
            <a href="">
              <span>Annuler</span>
            </a>
          </li>
        </ul>
        <!-- праві кнопки -->
        <form class="navbar-form navbar-right" role="form">
...
        </form>
      </div>
    </div>
  </div>
</div>
  • Пункти меню формуються рядками 8–29. Це знову елементи списку <ul>. Клас [active] надає тексту підсвічування, вказуючи тим самим, що на цей пункт можна натиснути.

3.6.6. Приклад 6

Ми представимо лікарів та клієнтів у випадаючих списках, як показано нижче:

 

Випадаючий список, що використовується, не є вбудованим компонентом Bootstrap. Це компонент [bootstrap-select] (http://silviomoreto.github.io/bootstrap-select/). Щоб отримати цей результат, ми дублюємо [app-01.html] у [app-07.html] і додаємо такі рядки:


<!DOCTYPE html>
<html>
<head>
...
<link href="bower_components/bootstrap-select/bootstrap-select.min.css" rel="stylesheet"/>

</head>
<body>
<div class="container">
  <h1>Rdvmedecins - v1</h1>

  <h2><label for="medecins">Médecins</label></h2>
  <select id="medecins" data-style="btn btn-primary" class="selectpicker">
    <option value="1">Mme Marie PELISSIER</option>
    <option value="1">Mr Jacques BROMARD</option>
    <option value="1">Mr Philippe JANDOT</option>
    <option value="1">Mme Justine JACQUEMOT</option>
  </select>
</div>
<!-- ядро Bootstrap JavaScript ================================================== -->
...
<script type="text/javascript" src="bower_components/bootstrap-select/bootstrap-select.min.js"></script>
<!-- локальний скрипт -->
<script>
  $('.selectpicker').selectpicker();
</script>
</body>
</html>
  • рядок 5: потрібно імпортувати таблицю стилів з [bootstrap-select];
  • рядок 13: атрибут [data-style] використовується файлом [bootstrap-select]. Він слугує для стилізації випадаючого списку. Тут йому надається вигляд синьої кнопки [btn-primary];
  • рядок 13: атрибут [class] використовується в рядку 23. Може бути будь-яким;
  • рядки 14–17: елементи випадаючого списку. Тут використовуються стандартні теги HTML;
  • рядок 22: потрібно імпортувати JS з [bootstrap-select];
  • рядки 24–26: скрипт JS, що виконується після завершення завантаження сторінки;
  • рядок 25: інструкція JQuery. Метод [selectpicker] (selectpicker()) застосовується до всіх елементів із класом [selectpicker] ($('.selectpicker')). Такий елемент є лише один — тег <select> у рядку 13. Метод [selectpicker] походить із файлу JS, на який є посилання в рядку 22;

3.6.7. Приклад 7

Щоб відобразити розклад прийому лікаря, ми використаємо «адаптивну» таблицю, надану бібліотекою JS [footable]:

  • у [1]: таблиця зі стандартним відображенням;
  • у [2]: таблиця при зменшенні розміру вікна браузера. Стовпець [Action] автоматично переноситься на наступний рядок. Це називається «адаптивним» компонентом або просто компонентом, що пристосовується.

Ми дублюємо [app-01.html] у [app-08.html] і додаємо такі рядки:


...
<link href="bower_components/footable/css/footable.core.min.css" rel="stylesheet"/>
<link href="assets/css/rdvmedecins.css" rel="stylesheet"/>
...
<div class="container">
  <h1>Rdvmedecins - v1</h1>

  <div class="row alert alert-warning">
    <div class="col-md-6">
      <table id="creneaux" class="table">
        <thead>
        <tr>
          <th data-toggle="true">
            <span>Créneau horaire</span>
          </th>
          <th>
            <span>Client</span>
          </th>
          <th data-hide="phone">
            <span>Action</span>
          </th>
        </thead>
        <tbody>
        <tr>
          <td>
            <span class='status-metro status-active'>
              9h00-9h20
            </span>
          </td>
          <td>
            <span></span>
          </td>
          <td>
            <a href="" class="status-metro status-active">
              Réserver
            </a>
          </td>
        </tr>
        <tr>
          <td>
            <span class='status-metro status-suspended'>
              9h20-9h40
            </span>
          </td>
          <td>
            <span>Mme Paule MARTIN</span>
          </td>
          <td>
            <a href="" class="status-metro status-suspended">
              Supprimer
            </a>
          </td>
        </tr>
        </tbody>
      </table>
    </div>
  </div>
</div>
...
<script src="bower_components/footable/dist/footable.min.js" type="text/javascript"></script>
  • рядки 2 та 60 вже присутні у файлі [app-01.html]. Це файли CSS та JS, надані бібліотекою [footable];
  • рядок 3 посилається на наступний файл CSS:

@CHARSET "UTF-8";

#проміжки th {
    text-align: center;
}

#проміжки td {
    text-align: center;
    font-weight: bold;
}

.status-metro {
  display: inline-block;
  padding: 2px 5px;
  color:#fff;
}

.status-metro.status-active {
  background: #43c83c;
}

.status-metro.status-suspended {
  background: #fa3031;
}

Стилі [status-*] взяті з прикладу використання таблиці [footable], знайденого на сайті бібліотеки.

  • рядок 8: розміщує таблицю в одному рядку [row] та кольоровій рамці [alert alert-warning];
  • рядок 9: таблиця займатиме 6 стовпців [col-md-6];
  • рядок 10: таблиця HTML відформатована за допомогою Bootstrap [class='table'];
  • рядок 13: атрибут [data-toggle] вказує на стовпець, у якому розміщений символ [+/-], що розгортає/складає рядок;
  • рядок 19: атрибут [data-hide='phone'] вказує, що стовпець має бути прихований, якщо розмір екрану відповідає розміру екрану мобільного телефону. Також можна використовувати значення «tablet»;

3.6.8. Приклад 8

Щоб допомогти користувачеві, ми створимо підказки (tooltip) навколо основних компонентів візуалізацій:

Щоб отримати цей результат, ми дублюємо [app-01.html] у [app-09.html] і додаємо такі рядки:


<!DOCTYPE html>
<html ng-app="rdvmedecins">
<head>
...
</head>
<body>
<div class="container">
  <h1>Rdvmedecins - v1</h1>
  <div class="navbar navbar-inverse navbar-fixed-top" role="navigation">
    <div class="container">
      <div class="navbar-header">
        <button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
          <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="navbar-brand" href="#">RdvMedecins</a>
      </div>
      <!-- опції меню -->
      <div class="collapse navbar-collapse">
        <ul class="nav navbar-nav">
          <li class="active">
            <a href="">
              <span tooltip="Retourne à la page d'accueil" tooltip-placement="bottom">Home</span>
            </a>
          </li>
          <li class="active">
            <a href="">
              <span tooltip="Affiche l'agenda" tooltip-placement="top">Agenda</span>
            </a>
          </li>
          <li class="active">
            <a href="">
              <span tooltip="Valide le rendez-vous" tooltip-placement="right">Valider</span>
            </a>
          </li>
          <li class="active">
            <a href="">
              <span tooltip="Annule l'opération en cours" tooltip-placement="left">Annuler</span>
            </a>
          </li>
        </ul>
      </div>
    </div>
  </div>
</div>
<!-- Ядро Bootstrap JavaScript ================================================== -->
<...
<script type="text/javascript" src="bower_components/angular-ui-bootstrap-bower/ui-bootstrap-tpls.min.js"></script>
<!-- локальний скрипт -->
<script>
  // --------------------- модуль Angular
  angular.module("rdvmedecins", ['ui.bootstrap']);
</script>
</body>
</html>

Підказки надаються бібліотекою [angular-ui-bootstrap], яка, у свою чергу, спирається на бібліотеку [angular]. У рядку 50 імпортується бібліотека [angular-ui-bootstrap]. Щоб реалізувати компоненти бібліотеки [angular-ui-bootstrap], нам потрібно створити модуль Angular. Це робиться у рядках 52–55. Ці рядки визначають модуль Angular із назвою [rdvmedecins] (1-й параметр). Модуль Angular може використовувати інші модулі Angular. Це називається залежностями модуля. Вони передаються у вигляді масиву як другий параметр функції [angular.module]. Тут модуль із назвою [ui.bootstrap] надається бібліотекою [angular-ui-bootstrap]. Саме цей модуль буде надавати нам підказки.

У рядку 54 визначається модуль Angular. За замовчуванням це ніяк не впливає на сторінку. Вказується, що сторінка має управлятися Angular, шляхом прив’язування її до модуля Angular. Саме це і робиться у рядку 2. Атрибут [ng-app='rdvmedecins'] прив’язує сторінку до модуля, створеного у рядку 54. Після цього сторінка буде проаналізована Angular. Атрибути [tooltip] будуть виявлені та оброблені модулем [ui.bootstrap].

Синтаксис підказки такий:


 <span tooltip="Retourne à la page d'accueil" tooltip-placement="bottom">Home</span>

У наведеному вище прикладі до тексту [Home] додається підказка:

  • [tooltip]: визначає текст підказки;
  • [tooltip-placement]: визначає його положення (bottom, top, left, right);

Angular JS дозволяє додавати нові теги або атрибути до тих, що вже існують у мові HTML. Це розширення мови HTML здійснюється за допомогою директив Angular. Тут атрибути [tooltip] та [tooltip-placement] є атрибутами, створеними за допомогою [angular-ui-bootstrap].

3.6.9. Приклад 9

Щоб допомогти користувачеві вибрати день зустрічі, ми запропонуємо йому календар:

Image

Як і у випадку з підказками, цей календар надається бібліотекою [angular-ui-bootstrap]. Щоб отримати цей результат, ми дублюємо [app-01.html] у [app-10.html] і додаємо такі рядки:


<!DOCTYPE html>
<html ng-app="rdvmedecins">
<head>
  ...
<body>
<div class="container">
  <h1>Rdvmedecins - v1</h1>

  <div>
    <pre>Date <em>{{jour | date:'fullDate'}}</em></pre>
    <div class="row">
      <div class="col-md-2">
        <h4>Calendrier</h4>

        <div style="display:inline-block; min-height:290px;">
          <datepicker ng-model="jour" show-weeks="true" class="well"></datepicker>
        </div>

        </div>
      </div>
    </div>
  </div>
</div>
...
<!-- локальний скрипт -->
<script>
  // --------------------- модуль Angular
  angular.module("rdvmedecins", ['ui.bootstrap'])
</script>

</body>
</html>

Як і раніше, сторінка пов’язана з модулем Angular (рядки 2 та 28). Календар визначається тегом <datepicker> у рядку 16, який реалізовано бібліотекою [angular-ui-bootstrap]:

  • [show-weeks='true']: для відображення номерів тижнів;
  • [class='well']: для оточення календаря сірою областю із заокругленими кутами;
  • [ng-model='jour']: атрибути [ng-*] є атрибутами Angular. Атрибут [ng-model] позначає дані, які будуть розміщені в моделі подання. Коли користувач натисне на дату, вона буде розміщена у змінній [jour] моделі. Ця змінна використовується в рядку 10. Синтаксис {{expression}} дозволяє обчислити вираз, що складається з елементів моделі. Тут {{jour}} відобразить значення змінної [jour] з моделі. Важливою особливістю Angular є те, що представлення автоматично відстежує зміни змінної [jour]. Таким чином, коли користувач змінить дати, ці зміни негайно відображатимуться у рядку 10. Загалом принцип роботи такий:
    • представлення V пов’язане з моделлю M;
    • Angular відстежує модель M і автоматично оновлює представлення V, коли відбуваються зміни в моделі M;

Синтаксис {{день|дата}} називається фільтром. Відображається не значення [jour], а значення [jour], відфільтроване за допомогою фільтра з назвою [date]. Цей фільтр є вбудованим у Angular. Він слугує для форматування дат. Він приймає параметри, що визначають бажаний формат. Так, вираз {{день | дата:'fullDate'}} вказує, що потрібно повний формат дати, у даному випадку [Friday, June 20, 2014], оскільки календар за замовчуванням налаштований на англійську мову. Незабаром ми розглянемо питання його інтернаціоналізації.

3.6.10. Висновок

Ми ознайомилися з елементами фреймворку CSS Bootstrap, які нам доведеться використовувати. Це були пасивні компоненти: їхні події не оброблялися. Отже, натискання на кнопки чи посилання не давало жодного ефекту. Ці події оброблятимуться за допомогою JavaScript. Цю мову можна використовувати без фреймворків, але, як це було на стороні сервера, на стороні клієнта певні фреймворки стають незамінними. Це стосується фреймворку Angular JS, який пропонує новий підхід до розробки JavaScript-додатків, що виконуються у браузері. Зараз ми його представимо.

3.7. Ознайомлення з Angular JS

Зараз ми проілюструємо деякі особливості фреймворку Angular JS, що використовуються в додатку. З деякими з них ми вже ознайомилися:

  • сторінка HTML працює на базі Angular JS, якщо до неї підключити модуль:

<html ng-app="rdvmedecins">
  • Angular дозволяє створювати нові теги та атрибути HTML за допомогою директив:
attributs : ng-app, ng-model, tooltip-placement, tooltip
balises : datepicker
  • Angular дозволяє створювати фільтри:
{{jour|date:'fullDate'}}
  • Вигляд V відображає модель M. Angular відстежує модель M і автоматично оновлює вигляд V, коли відбувається зміна в моделі M. Значення змінної моделі M відображається у вигляді V за допомогою:
{{variable}}

Спочатку ми детальніше розглянемо реалізацію шаблону проектування «Модель — Вид — Контролер» в Angular. Нагадаємо, які зв’язки існують між ними з архітектурної точки зору:

  • представлення V1 відображає модель M1, побудовану контролером C1. Останній містить не лише модель M1, а й обробники подій для подання V1. Ми перебуваємо в циклі 5, 8, 9:
    • [5]: у подання V1 відбувається подія. Її обробляє контролер C1;
    • той виконує свою роботу [6-7], а потім будує модель M1 [8];
    • [9]: представлення V1 відображає нову модель M1. Як ми вже зазначали, цей останній крок відбувається автоматично. На відміну від інших фреймворків, тут немає явного push (C1 завантажує модель M1 у V1) або явного pull (представлення V1 отримує модель M1 з C1). Існує неявний push, якого розробник не бачить;
    • потім цикл 5, 8, 9 повторюється;

3.7.1. Приклад 1: модель MVC в Angular

Повернемося до прикладу з календарем. Ми вже розглядали директиву, яка його генерує:


          <datepicker ng-model="jour" show-weeks="true" class="well"></datepicker>

Ця директива підтримує й інші атрибути, крім наведених вище, зокрема атрибут [min-date], який встановлює мінімальну дату, яку можна вибрати в календарі. Це нам знадобиться. Коли користувач вибирає дату зустрічі, вона має бути не раніше, ніж поточний день. Тож ми напишемо:


<datepicker ng-model="jour" ... min-date="dateMin"></datepicker>

де [dateMin] буде змінною шаблону сторінки, значенням якої буде поточна дата. У результаті отримаємо таку сторінку:

  • у [1] — сьогодні 19 червня 2014 року. Курсор вказує, що можна вибрати 19 червня;
  • у [2] курсор вказує, що 18 червня вибрати не можна;

Ми дублюємо [app-10.html] у [app-11.html] і вносимо такі зміни:


<!DOCTYPE html>
<html ng-app="rdvmedecins">
<head>
...
</head>
<body ng-controller="rdvMedecinsCtrl">
<div class="container">
  <h1>Rdvmedecins - v1</h1>

  <div>
    <pre>Date <em>{{jour | date:'fullDate' }}</em></pre>
    <div class="row">
      <div class="col-md-2">
        <h4>Calendrier</h4>

        <div style="display:inline-block; min-height:290px;">
          <datepicker ng-model="jour" show-weeks="true" class="well" min-date="minDate"></datepicker>
        </div>
      </div>
    </div>
  </div>
</div>
<!-- ядро Bootstrap JavaScript ================================================== -->
...
<!-- локальний скрипт -->
<script>
  // --------------------- модуль Angular
  angular.module("rdvmedecins", ['ui.bootstrap']);
  // контролер
  angular.module("rdvmedecins")
    .controller('rdvMedecinsCtrl', ['$scope',
      function ($scope) {
        // мінімальна дата
        $scope.minDate = new Date();
      }]);

</script>

</body>
</html>

Спочатку розглянемо локальний скрипт у рядках 26–37:

  • рядок 28: створення модуля [rdvmedecins] із залежністю від модуля [ui.bootstrap], який надає календар;
  • рядки 30–35: створення контролера. Саме він буде містити шаблон нашої сторінки. Тут не буде обробника подій;
  • рядки 30–31: контролер [rdvMedecinsCtrl] належить до модуля [rdvmedecins]. До модуля можна додавати скільки завгодно контролерів. У нашому додатку ми матимемо:
    • модуль управління додатком;
    • один контролер на кожен вигляд;
  • другий параметр функції [controller] — це масив у формі ['O1', 'O2', ..., 'On', function(O1, O2, ..., On)]. Останній параметр — це функція, яка реалізує контролер. Її параметри — це об’єкти, які Angular JS надасть цій функції.

Повернемося до архітектури додатка Angular:

Як показано вище, контролер C1 містить усі обробники подій подання V1, а також його модель M1. Для роботи обробникам подій може знадобитися один або кілька сервісів [6]. Усі вони передаються як параметри функції створення контролера:

['S1', 'S2', ..., 'Sn', function(S1, S2, ..., Sn)]

Сервіси Si є синглтонами. Angular створює їх у єдиному екземплярі. Вони ідентифікуються за іменем Si. Чому вони двічі присутні у таблиці вище? Під час роботи скрипти JS мінімізуються. У процесі мінімізації таблиця вище набуває такого вигляду:

['S1', 'S2', ..., 'Sn', function(a1, a2, ..., an)]

Параметри втрачають свої імена. А це саме імена сервісів. Тому важливо зберегти ці імена. Саме тому вони передаються як символьні рядки в якості параметрів, що передують функції. Символьні рядки не змінюються в процесі мініфікації. Коли Angular буде створювати контролер із новим масивом, він замінить a1 на S1, a2 на S2, ... Тому порядок параметрів є важливим. Він повинен відповідати порядку сервісів, що передують визначенню функції.

Повернемося до визначення контролера [rdvMedecinsCtrl]:


  // контролер
  angular.module("rdvmedecins")
    .controller('rdvMedecinsCtrl', ['$scope',
      function ($scope) {
        // мінімальна дата
        $scope.minDate = new Date();
}]);
  • рядки 3–4: єдиним об’єктом, що вводиться в контролер, є об’єкт $scope. Це попередньо визначений об’єкт, який представляє модель M для переглядів, пов’язаних із контролером. Щоб розширити модель перегляду, достатньо додати поля до об’єкта $scope;
  • саме це робиться у рядку 6. Створюється поле [minDate] із значенням поточної дати;

Вигляд V використовує цю модель M наступним чином:


<body ng-controller="rdvMedecinsCtrl">
<div class="container">
 ...
        <div style="display:inline-block; min-height:290px;">
          <datepicker ng-model="jour" show-weeks="true" class="well" min-date="minDate"></datepicker>
        </div>
...
</div>
...
  • рядок 1: тіло сторінки пов’язане з контролером [rdvMedecinsCtrl] за допомогою атрибута [ng-controller]. Це означає, що все, що міститься в тезі <body>, використовуватиме контролер [rdvMedecinsCtrl] для управління своїми подіями та отримання своєї моделі M. Сторінка HTML може залежати від декількох контролерів, вкладених один в одного або ні:
<div id='div1' ng-controller='c1'>
    ...
    <div id='div11' ng-controller='c11'>
    ...
    </div>
    ...
    <div id='div12' ng-controller='c12'>
    ...
    </div>
</div>

Вище:

  • вміст [div1] (рядки 1–10) відображає шаблон M1, який керується контролером c1. Теги в цій області можуть посилатися на обробники подій контролера c1;
  • вміст [div11] (рядки 3–4) відображає шаблон M11, що керується контролером c11, а також шаблон M1. Існує успадкування шаблонів. Теги цієї зони можуть посилатися як на обробники подій контролера c11, так і на обробники подій контролера c1. Вони не можуть посилатися ні на шаблон M12 контролера c12, ні на його обробники подій. Контролер c12, дійсно, не згадується у рядках 3–5;
  • рядки 7–9: можна провести міркування, аналогічні до тих, що були наведені раніше;

Повернемося до коду календаря:


<datepicker ng-model="jour" show-weeks="true" class="well" min-date="minDate"></datepicker>

Атрибут [min-date] ініціалізується значенням [minDate] з моделі. Неявно — [$scope.minDate]. Поле завжди шукається в об’єкті $scope.

3.7.2. Приклад 2: локалізація дат

Наразі календар нам майже не придатний, оскільки це англійський календар. Його можна локалізувати:

  • у [1] ми маємо календар французькою мовою;
  • у [2] — переводимо його на англійську;
  • за адресою [3] — англійський календар;

Ми дублюємо сторінку [app-11.html] у [app-12.html], а потім змінюємо останню таким чином:


<!DOCTYPE html>
<html ng-app="rdvmedecins">
<head>
  ...
</head>
<body ng-controller="rdvMedecinsCtrl">
<div class="container">
  <h1>Rdvmedecins - v1</h1>

  <pre>Date <em>{{jour | date:'fullDate' }}</em></pre>
  <div class="row">
    <!-- календар-->
    <div class="col-md-4">
      <h4>Calendrier</h4>

      <div style="display:inline-block; min-height:290px;">
        <datepicker ng-model="jour" show-weeks="true" class="well" min-date="minDate"></datepicker>
      </div>
    </div>
    <!-- мови -->
    <div class="col-md-2">
      <div class="btn-group" dropdown is-open="isopen">
        <button type="button" class="btn btn-primary dropdown-toggle" style="margin-top: 30px">
          Langues<span class="caret"></span>
        </button>
        <ul class="dropdown-menu" role="menu">
          <li><a href="" ng-click="setLang('fr')">Français</a></li>
          <li><a href="" ng-click="setLang('en')">English</a></li>
        </ul>
      </div>
    </div>
  </div>
</div>
...
<script type="text/javascript" src="rdvmedecins.js"></script>
</body>
</html>

Зміни незначні. Просто додано рядки 21–31 зі списку мов. Вперше ми зустрічаємо обробник події в рядках 27–28:

  • рядок 27: атрибут [ng-click] — це атрибут Angular, який вказує на обробник події, що має виконатися при натисканні на елемент із цим атрибутом. У цьому випадку буде виконана функція [$scope.setLang('fr')]. Вона переведе календар на французьку мову;
  • рядок 28: тут календар переводиться на англійську мову;
  • рядок 35: оскільки JavaScript-код контролера є досить об’ємним, ми розміщуємо його у файлі [rdvmedecins.js];

Angular керує локалізацією представлень за допомогою модуля під назвою [ngLocale]. Отже, визначення нашого модуля [rdvmedecins] буде таким:


  // --------------------- модуль Angular
angular.module("rdvmedecins", ['ui.bootstrap', 'ngLocale']);

У рядку 2 не слід забувати про залежності, оскільки Angular іноді не дуже точний у своїх повідомленнях про помилки. Тому пропущення залежності виявити особливо складно. Тут ми маємо нову залежність від модуля [ngLocale].

За замовчуванням Angular підтримує лише локалізацію дат, чисел тощо, які мають локальні варіанти. Він не підтримує інтернаціоналізацію текстів. Для цього ми використаємо бібліотеку [angular-translate]. Управління локалізацією здійснюється бібліотекою [angular-i18n]. Ця бібліотека містить стільки файлів, скільки існує варіантів для дат, чисел тощо.

  

Для французького календаря ми будемо використовувати файл [angular-locale_fr-fr.js], а для англійського — файл [angular-locale_en-us.js]. Давайте подивимося, що, наприклад, міститься у файлі [angular-locale_fr-fr.js]:


'use strict';
angular.module("ngLocale", [], ["$provide", function($provide) {
var PLURAL_CATEGORY = {ZERO: "zero", ONE: "one", TWO: "two", FEW: "few", MANY: "many", OTHER: "other"};
$provide.value("$locale", {
  "DATETIME_FORMATS": {
    "AMPMS": [
      "AM",
      "PM"
    ],
    "DAY": [
      "dimanche",
      "lundi",
      "mardi",
      "mercredi",
      "jeudi",
      "vendredi",
      "samedi"
    ],
    "MONTH": [
      "janvier",
      "f\u00e9vrier",
      "mars",
      "avril",
      "mai",
      "juin",
      "juillet",
      "ao\u00fbt",
      "septembre",
      "octobre",
      "novembre",
      "d\u00e9cembre"
    ],
    "SHORTDAY": [
      "dim.",
      "lun.",
      "mar.",
      "mer.",
      "jeu.",
      "ven.",
      "sam."
    ],
    "SHORTMONTH": [
      "janv.",
      "f\u00e9vr.",
      "mars",
      "avr.",
      "mai",
      "juin",
      "juil.",
      "ao\u00fbt",
      "sept.",
      "oct.",
      "nov.",
      "d\u00e9c."
    ],
    "fullDate": "EEEE d MMMM y",
    "longDate": "d MMMM y",
    "medium": "d MMM y HH:mm:ss",
    "mediumDate": "d MMM y",
    "mediumTime": "HH:mm:ss",
    "short": "dd/MM/yy HH:mm",
    "shortDate": "dd/MM/yy",
    "shortTime": "HH:mm"
  },
  "NUMBER_FORMATS": {
    "CURRENCY_SYM": "\u20ac",
    "DECIMAL_SEP": ",",
    "GROUP_SEP": "\u00a0",
    "PATTERNS": [
      {
        "gSize": 3,
        "lgSize": 3,
        "macFrac": 0,
        "maxFrac": 3,
        "minFrac": 0,
        "minInt": 1,
        "negPre": "-",
        "negSuf": "",
        "posPre": "",
        "posSuf": ""
      },
      {
        "gSize": 3,
        "lgSize": 3,
        "macFrac": 0,
        "maxFrac": 2,
        "minFrac": 2,
        "minInt": 1,
        "negPre": "(",
        "negSuf": "\u00a0\u00a4)",
        "posPre": "",
        "posSuf": "\u00a0\u00a4"
      }
    ]
  },
  "id": "fr-fr",
  "pluralCat": function (n) {  if (n >= 0 && n <= 2 && n != 2) {   return PLURAL_CATEGORY.ONE;  }  return PLURAL_CATEGORY.OTHER;}
});
}]);

Тут ми бачимо елементи, які дозволяють створити французький календар:

  • рядки 10–18: таблиця днів тижня;
  • рядки 19–32: таблиця місяців року;
  • рядки 33–41: таблиця днів тижня у скороченому вигляді;
  • рядки 42–55: таблиця місяців року у скороченому вигляді;
  • рядки 56–63: формати дати та часу. У рядку 62 можна впізнати формат «дд/мм/рр» французьких дат;
  • рядки 65–95: інформація щодо форматування чисел. Це нас тут не цікавить;
  • рядок 96: ідентифікатор «fr-fr» локалі файлу (fr-fr: французька мова Франції, fr-ca: французька мова Канади, ...)

У файлі [angular-locale_en-us.js] міститься точно те саме, але цього разу для англійської мови з файлу USA (en-us).

Наведений вище код не дуже простий для розуміння. При уважному прочитанні можна побачити, що весь цей код визначає змінну [$locale] у рядку 4. Саме змінюючи значення цієї змінної, ми отримуємо інтернаціоналізацію дат, чисел, валюти тощо. Цікаво, що Angular не передбачив можливості змінити змінну [$locale] під час виконання. Її визначають раз і назавжди, імпортуючи файл потрібної локалі:


<script type="text/javascript" src="bower_components/angular-i18n/angular-locale_fr-fr.js"></script>

Немає сенсу імпортувати всі файли потрібних локалей, оскільки, як ми бачили, кожен файл виконує лише одну дію: визначає змінну [$locale]. Перемагає останній імпортований файл, і після цього змінити локаль вже неможливо.

Шукаючи в Інтернеті рішення цієї проблеми, я його не знайшов. Тому пропоную своє рішення тут: [https://github.com/stahe/angular-ui-bootstrap-datepicker-with-locale-updated-on-the-fly]. Ідея полягає в тому, щоб помістити різні локалі, які нам потрібні, у словник. Саме звідти ми будемо їх брати, коли знадобиться змінити локаль. Код JavaScript для [rdvmedecins.js] має таку структуру:

 

Якщо вилучити визначення локалей, яке займає 200 рядків (рядки 15–215 вище), код виявляється простим:

  • рядок 6: визначає модуль [rdvmedecins] та його залежності;
  • рядки 8–10: визначають контролер сторінки [rdvMedecinsCtrl];
  • рядок 9: функція створення контролера приймає два параметри:
    • $scope: для створення шаблону подання;
    • $locale: це змінна, яка керує локалізацією календаря. Саме її потрібно змінювати при зміні мови;
  • рядок 13: змінна [minDate] шаблону ініціалізується поточною датою;
  • рядок 15: визначає словник [locales]. Зверніть увагу, що ми не вказали [$scope.locales]. Змінна [locales], дійсно, не входить до шаблону, що відображається у вікні;
  • рядки 15–215: визначають словник {'fr':locale-fr-fr, 'en':locale-en-us}. Значення [locale-fr-fr] та [locale-en-us] беруться відповідно з файлів JS, [angular-locale_fr-fr.js] та [angular-locale_en-us.js]. Найскладніше — не помилитися у численних дужках цього словника...
  • рядок 217: ініціалізуємо змінну $locale значенням locales['fr'], тобто французькою версією локалі. Не можна просто написати [$locale=locales['fr']], що присвоює $locale адресу locales['fr']. Необхідно виконати копіювання значення. Це можна зробити за допомогою попередньо визначеної функції [angular.copy];
  • рядок 219: змінна [jour] шаблону ініціалізується поточною датою. Це призводить до того, що календар відображатиметься з позиціонуванням на цю дату;
  • рядки 223–230: визначають обробник події, який викликається під час зміни мови. Зверніть увагу на синтаксис:
$scope.nom_fonction=function(param1, param2, ...){...}

для визначення обробника події, який називатиметься [nom_fonction] і прийматиме параметри [param1, param2, ...];

Нагадаємо код HTML зі списку, що розгортається:


    <!-- мови -->
    <div class="col-md-2">
      <div class="btn-group" dropdown is-open="isopen">
        <button type="button" class="btn btn-primary dropdown-toggle" style="margin-top: 30px">
          Langues<span class="caret"></span>
        </button>
        <ul class="dropdown-menu" role="menu">
          <li><a href="" ng-click="setLang('fr')">Français</a></li>
          <li><a href="" ng-click="setLang('en')">English</a></li>
        </ul>
      </div>
</div>
  • рядок 8: вибір французької мови призводить до виклику [setLang('fr')];
  • рядок 9: вибір англійської мови призводить до виклику [setLang('en')];
  • рядок 3: атрибут [is-open] — це логічне значення, яке керує відкриттям (true) або закриттям (false) випадаючого списку. Він ініціалізується змінною [isopen] з моделі подання;

Повернемося до коду [rdvmedecins.js]:

  • рядок 225: змінюємо значення змінної [$locale] на відповідне значення зі словника [locales];
  • рядок 227: ми зазначили, що коли модель M подання V змінюється, подання V автоматично оновлюється з новою моделлю. У рядку 225 було змінено значення змінної [$locale], яка не входить до моделі M, що відображається поданням V. Потрібно знайти спосіб змінити цю модель M, щоб календар оновився та використовував нову локаль. Тут ми змінюємо змінну [jour] у моделі календаря. Її ініціалізують за допомогою нового покажчика (new), який вказує на дату, ідентичну тій, що відображається. [$scope.jour.getTime()] — це кількість мілісекунд, що минула з 1 січня 1970 року до дати, яку відображає календар. За допомогою цього числа відтворюють нову дату. Звісно, ми отримаємо ту саму дату, і календар залишиться на тій даті, яку він відображав. Але значення [$scope.jour], яке насправді є покажчиком, зміниться, і календар оновиться;
  • рядок 229: присвоюємо false значення змінної [isopen] з шаблону. Ця змінна керує одним з атрибутів випадаючого списку:

<div class="btn-group" dropdown is-open="isopen">
    <button type="button" class="btn btn-primary dropdown-toggle" style="margin-top: 30px">
          Langues<span class="caret"></span>
    </button>
...
</div>

У рядку 1 вище атрибут [is-open] зміниться на false, що призведе до закриття випадаючого списку.

3.7.3. Приклад 3: інтернаціоналізація текстів

Повернемося до локалізації календаря:

У [3] ми бачимо, що календар англійською мовою, але тексти [Calendrier, Langues] — ні. За замовчуванням Angular не надає інструментів для інтернаціоналізації повідомлень. Тут ми скористаємося бібліотекою [angular-translate] (https://github.com/angular-translate/angular-translate).

Ми розробимо такий приклад:

  • у [1] — вигляд французькою мовою;
  • у [2] — версію англійською мовою;

Розглянемо конфігурацію, необхідну для інтернаціоналізації. Скрипт [rdvmedecins.js] змінюється наступним чином:


  // --------------------- модуль Angular
angular.module("rdvmedecins", ['ui.bootstrap', 'ngLocale', 'pascalprecht.translate']);
// конфігурація i18n
angular.module("rdvmedecins")
  .config(['$translateProvider', function ($translateProvider) {
    // повідомлення французькою
    $translateProvider.translations("fr", {
      'msg_header': 'Медичний кабінет <br/> Les Médecins Associés',
      'msg_langues': 'Мови',
      'msg_agenda': 'Розклад {{заголовок}} {{ім'я}} {{прізвище}}<br/>{{день}}',
      'msg_calendrier': 'Календар',
      'msg_jour': 'Вибраний день: ',
      'msg_meteo': "Сьогодні буде дощ..."
    });
    // повідомлення англійською
    $translateProvider.translations("en", {
      'msg_header': 'The Associated Doctors',
      'msg_langues': «Мови»,
      'msg_agenda': "{{заголовок}} Щоденник {{ім'я}} {{прізвище}}<br/> на {{день}}",
      'msg_calendrier': 'Календар',
      'msg_jour': 'Вибраний день: ',
      'msg_meteo': 'Сьогодні буде дощ...'
    });
    // мова за замовчуванням
    $translateProvider.preferredLanguage("fr");
}]);
  • рядок 2: перша зміна — додавання нової залежності. Для інтернаціоналізації додатка потрібен модуль Angular [pascalprecht.translate];
  • рядки 5–26: визначають функцію [config] модуля [rdvmedecins]. Під час запуску додатка Angular фреймворк створює екземпляри всіх необхідних для додатка сервісів — як попередньо визначених у Angular, так і визначених користувачем. Наразі ми ще не визначили жодних сервісів. Функція [config] модуля додатка виконується перед будь-яким створенням екземпляра сервісу. Її можна використовувати для визначення інформації про конфігурацію сервісів, екземпляри яких будуть створені згодом. У цьому випадку функція [config] буде використана для визначення інтернаціоналізованих повідомлень додатка;
  • рядок 5: параметр функції [config] — це масив ['O1', 'O2', ..., 'On', function(O1, O2, ..., On)], де Oi — відомий об’єкт, що надається Angular. Тут об’єкт [$translateProvider] надається модулем [pascalprecht.translate]. [function] — це функція, що виконується для налаштування додатка;
  • рядки 7–14: функція [$translateProvider.translations] приймає два параметри:
    • перший параметр — це ключ мови. Його можна вказати довільно. Тут для французьких перекладів вказано «fr» (рядок 7), а для англійських — «en» (рядок 16),
    • другий — це список перекладів у вигляді словника {'cle1':'msg1', 'cle2':'msg2', ...};
  • рядки 7–14: французькі повідомлення;
  • рядки 16–23: англійські повідомлення;
  • рядок 25: метод [preferredLanguage] встановлює мову за замовчуванням. Його параметром є один із аргументів, що використовується як перший параметр функції [$translateProvider.translations], тобто тут або «fr» (рядок 7), або «en» (рядок 16);
  • зауважимо, що існує три типи повідомлень:
    • повідомлення без параметрів та елементів HTML (рядки 9, 11, 12, ...),
    • повідомлення з елементами HTML (рядки 8, 10, ...),
    • повідомлення з параметрами (рядки 10, 19);

Тепер ми дублюємо [app-11.html] у [app-12.html] і вносимо такі зміни:


<div class="container">
  <!-- перший текст із елементами HTML -->
  <h3 class="alert alert-info" translate="{{'msg_header'}}"></h3>
  <!-- другий текст із параметрами -->
  <h3 class="alert alert-warning" translate="{{msg.text}}" translate-values="{{msg.model}}"></h3>
  <!-- третій текст, перекладений контролером -->
  <h3 class="alert alert-danger">{{msg2}}</h3>

  <pre>{{'msg_jour'|translate}}<em>{{jour | date:'fullDate' }}</em></pre>
  <div class="row">
    <!-- календар-->
    <div class="col-md-4">
      <h4>{{'msg_calendrier'|translate}}</h4>

      <div style="display:inline-block; min-height:290px;">
        <datepicker ng-model="jour" show-weeks="true" class="well" min-date="minDate"></datepicker>
      </div>
    </div>
    <!-- мови -->
    <div class="col-md-2">
      <div class="btn-group" dropdown is-open="isopen">
        <button type="button" class="btn btn-primary dropdown-toggle" style="margin-top: 30px">
          {{'msg_langues'|translate}}<span class="caret"></span>
        </button>
        <ul class="dropdown-menu" role="menu">
          <li><a href="" ng-click="setLang('fr')">Français</a></li>
          <li><a href="" ng-click="setLang('en')">English</a></li>
        </ul>
      </div>
    </div>
  </div>
</div>
  • переклади відбуваються у рядках 3, 5, 9, 13, 23;
  • можна виділити три синтаксиси:
    • синтаксис [translate={{'msg_key'}}] (рядок 3), де [msg_key] є одним із ключів словника перекладу. Цей синтаксис підходить для повідомлень з елементами HTML або без них, але не для тих, що містять параметри;
    • синтаксис [translate={{'msg_key'}} translate-values={{dictionnaire]}}] (рядок 5) підходить для повідомлень з елементами HTML або без них, а також з параметрами;
    • синтаксис [{{'msg_key'|translate}}] (рядки 9, 13, 23) підходить для повідомлень без параметрів і без елементів HTML;

Розглянемо різні повідомлення цього виду:

ligne
français
anglais
3
Медичний кабінет<br/>Les Médecins Associés
«The Associated Doctors»
13
Календар
Календар
23
Мови
Languages
9
Вибраний день:
Вибраний день:

Тепер розглянемо рядок 5:


<h3 class="alert alert-warning" translate="{{msg.text}}" translate-values="{{msg.model}}"></h3>

Зверніть увагу, що [msg.text] та [msg.model] не взяті в апострофи. Це не символьні рядки, а елементи шаблону:

  • msg.text: визначає ключ налаштованого повідомлення, яке слід використовувати;
  • msg.model — це словник, що надає значення параметрів;

Імена полів [text, model] можуть бути будь-якими. У контролері [rdvMedecinsCtrl] подання об’єкт [msg] визначено таким чином:

Image

  • рядок 245: визначення об’єкта [msg];
  • рядок 245: поле [text] має значення ключа [msg_agenda], який пов’язаний із двома значеннями:
    • Щоденник {{заголовок}} {{ім'я}} {{прізвище}}<br/>{{день}} у французькому словнику;
    • Щоденник {{titre}} {{prenom}} {{nom}}<br/> на {{jour}} в англійському словнику;

Отже, повідомлення, яке потрібно відобразити, має чотири параметри [titre, prenom, nom, jour];

  • рядок 245: поле [model] — це словник, що присвоює значення цим чотирьом параметрам. Виникає складність із параметром [jour]. Ми хочемо відобразити повну назву дня. Вона відрізняється залежно від того, чи це французька, чи англійська мова. Тому використовуємо фільтр [date], який вже застосовувався у поданні у формі {{ день | date:'fullDate'}}. У коді JavaScript можна використовувати будь-який фільтр у формі $filter('filter')(значення, додаткові параметри), де $filter — це попередньо визначений об’єкт Angular, а 'filter' — назва фільтра;
  • рядки 33–34: попередньо визначений об’єкт $filter передається як параметр контролеру, що дозволяє використовувати його в рядку 245;

Повернемося до іншого рядка відображеного виду:


  <!-- третій текст, перекладений контролером -->
<h3 class="alert alert-danger">{{msg2}}</h3>

Усі попередні переклади виконувалися у поданнях за допомогою атрибутів модуля [pascalprecht.translate]. Можна також вирішити виконувати цей переклад на стороні сервера. Саме це й зроблено тут. У контролері (рядок 247 на знімку екрана вище) міститься такий код:


$scope.msg2 = $filter('translate')('msg_meteo');

Використовується той самий синтаксис, що й для фільтра «date», оскільки «translate» також є фільтром. Тут запитується повідомлення з ключем «msg_meteo».

Розглянемо механізм зміни мов. Ми бачили, що функція [config] для налаштування модуля [rdvmedecins] призначила французьку мову мовою за замовчуванням (рядок 9 нижче):


// конфігурація i18n
angular.module("rdvmedecins")
  .config(['$translateProvider', function ($translateProvider) {
    // повідомлення французькою
    $translateProvider.translations("fr", {...});
    // повідомлення англійською
    $translateProvider.translations("en", {...});
    // мова за замовчуванням
    $translateProvider.preferredLanguage("fr");
}]);

Нагадаємо також, що локал за замовчуванням також був французьким. У коді ініціалізації контролера [rdvmedecins] було написано:


// встановлюємо локаль на французьку
angular.copy(locales['fr'], $locale);
  • рядок 2: [locales] — це словник, який ми створили;

Немає жодного зв’язку між інтернаціоналізацією повідомлень, яку забезпечує модуль [pascalprecht.translate], та локалізацією дат, яку ми реалізували. Остання використовує змінну $locale, яка не використовується модулем [pascalprecht.translate]. Це два процеси, які не взаємодіють між собою.

Тепер настав час подивитися, що відбувається, коли користувач змінює мову:

Image

  • рядок 251: під час зміни мови викликається функція [setLang] з одним із двох параметрів ['fr','en'];
  • рядки 252–257: вже пояснювалися — вони змінюють змінну [$locale] календаря. Це ніяк не впливає на мову перекладів;
  • рядок 259: змінюється мова перекладів. Використовується об’єкт [$translate], наданий модулем [pascalprecht.translate]. Для цього його потрібно ввести в контролер:

// контролер
angular.module("rdvmedecins")
  .controller('rdvMedecinsCtrl', ['$scope', '$locale', '$translate', '$filter',
function ($scope, $locale, $translate, $filter) {

У рядках 3 і 4 вище вставляється об’єкт $translate;

  • параметр lang функції [$translate.use(lang)] повинен мати значення одного з ключів, що використовуються в конфігурації як перший параметр функції [$translateProvider.translations], тобто або 'fr', або 'en'. Це саме так;
  • рядок 261: перераховується значення msg2. Чому? У поданні після зміни мови, здійсненої в рядку 259, усі наявні атрибути [translate] будуть перераховані. Це не стосується виразу {{msg2}}, який не має цього атрибута. Тому його нове значення обчислюється в контролері. Це має бути зроблено після зміни мови в рядку 259, щоб для обчислення [msg2] використовувалася нова мова;

Якщо зупинитися на цьому, можна помітити дві аномалії:

  1. у [1] день залишився французькою мовою, тоді як решта подання — англійською;
  2. у [2] та [3] вибраним днем є 24 червня, тоді як у [1] день залишається встановленим на 20 червня;

Спробуємо знайти пояснення, перш ніж шукати рішення. Повідомлення [1] формується в контролері за допомогою такого коду:


      $scope.msg = {'text': 'msg_agenda', 'model': {'titre': 'Mme', 'prenom': 'Laure', 'nom': 'PELISSIER', 'jour': $filter('date')($scope.jour, 'fullDate')}};

і відображається у вікні з таким кодом:


  <h3 class="alert alert-warning" translate="{{msg.text}}" translate-values="{{msg.model}}"></h3>

Аномалія [1] (день залишився французькою, тоді як решта вікна — англійською) здається, свідчить про те, що якщо атрибут [translate] переоцінюється під час зміни мови, то атрибут [translate-values] не переоцінювався. Тоді можна примусово виконати цю переоцінку в контролері:


      // ------------------- менеджер подій
      // зміна мови
      $scope.setLang = function (lang) {
...
        // оновлення msg2
        $scope.msg2 = $filter('translate')('msg_meteo');
        // та день повідомлення
        $scope.msg.model.jour = $filter('date')($scope.jour, 'fullDate');
};

При кожній зміні мови рядок 8 вище перераховує день, що відображається. Це ефективно вирішує першу проблему, але не другу (день, що відображається у повідомленні, не змінюється при виборі іншого дня в календарі). Причина такої поведінки полягає в наступному. Повідомлення відображається у вікні за допомогою такого коду:


<h3 class="alert alert-warning" translate="{{msg.text}}" translate-values="{{msg.model}}"></h3>

Відображуваний вигляд V змінюється лише тоді, коли змінюється його шаблон M. Однак у цьому випадку вибір нового дня в календарі запускає подію, яка не обробляється, через що шаблон [msg] не змінюється, а отже, не змінюється й вигляд. Ми змінюємо у вигляді визначення календаря:


<datepicker ng-model="jour" show-weeks="true" class="well" min-date="minDate"
ng-click="calendarClick()"></datepicker>

Вище ми вказуємо, що клік на календарі має оброблятися функцією [$scope.calendarClick]. Вона виглядає так:

Image

  • рядок 267: обробник кліка на календарі;
  • рядок 269: примусове оновлення дня, що відображається, за допомогою повідомлення [msg];

3.7.4. Приклад 4: служба конфігурації

Повернемося до архітектури додатка Angular JS:

Тут ми зосередимося на понятті «сервіс». Це досить широке поняття. Якщо вищезгаданий шар [DAO] є явно сервісом, то будь-який об’єкт Angular може стати сервісом:

  • сервіс дотримується певної синтаксичної структури. Він має ім’я, і Angular розпізнає його за цим ім’ям;
  • сервіс може бути ін’єктований Angular у контролери та інші сервіси;

Деякі сервіси, які ми будемо налаштовувати в модулі [rdvmedecins], потребуватимуть конфігурації. Оскільки сервіс може бути введений в інший сервіс, виникає спокуса виконати налаштування в сервісі, який ми назвемо [config], і ввести його в сервіси та контролери, що підлягають налаштуванню. Тепер ми опишемо цей процес.

Ми дублюємо [app-13.html] у [app-14.html] і вносимо такі зміни:


<div class="container">
  <!-- перевірка повідомлення в черзі -->
  <label>
    <input type="checkbox" ng-model="waiting.visible">
    <span>Voir le message d'attente</span>
  </label>

  <!-- повідомлення в черзі -->
  <div class="alert alert-warning" ng-show="waiting.visible">
    <h1>{{ waiting.text | translate}}
      <button class="btn btn-primary pull-right" ng-click="waiting.cancel()">
            {{'msg_cancel'|translate}}</button>
      <img src="assets/images/waiting.gif" alt=""/>
    </h1>
  </div>
...
</div>
...
<script type="text/javascript" src="rdvmedecins-02.js"></script>
  • рядки 3–6: прапорець, який визначає, чи відображатиметься повідомлення про очікування з рядків 9–15. Значення прапорця зберігається у змінній [waiting.visible] моделі M подання V. Це значення дорівнює true, якщо прапорець встановлено, і false — у протилежному випадку. Це працює в обох напрямках. Якщо ми присвоїмо змінній [waiting.visible] значення true, прапорець буде встановлено. Між поданням V та його моделлю M існує двонаправлений зв’язок;
  • рядки 9–15: повідомлення про очікування з кнопкою скасування очікування (рядок 11);
  • рядок 9: повідомлення видно лише тоді, коли змінна [waiting.visible] має значення true. Отже, коли ми встановимо прапорець у рядку 4:
    • значення true присвоюється змінній [waiting.visible] (ng-model, рядок 4);
    • оскільки відбулася зміна моделі M, вигляд V автоматично переоцінюється. Повідомлення про очікування стане видимим (ng-show, рядок 9);
    • аналогічний принцип діє, коли знімається галочка у рядку 4: повідомлення про очікування приховується;
  • рядок 10: повідомлення про очікування підлягає перекладу (фільтр translate);
  • рядок 11: при натисканні на кнопку виконується метод [waiting.cancel()] (атрибут ng-click);
  • рядок 12: текст кнопки підлягає перекладу;
  • рядок 19: код JavaScript додатка переміщується в новий файл JS [rdvmedecins-02], щоб не втратити вже написаний код, який тепер потрібно реорганізувати;

У результаті отримуємо такий вигляд:

  • у [1], поле не відмічено;
  • у [2], поле відмічено;

Скрипт [rdvmedecins-02] є реорганізацією скрипта [rdvmedecins]:

Image

  • рядок 6: модуль [rdvmedecins] додатка;
  • рядки 9–10: функція конфігурації додатка;
  • рядки 38–39: служба [config];
  • рядки 283–284: контролер [rdvMedecinsCtrl];

Раніше ми визначили в контролері словник locales={'fr':..., 'en': ...}, який налічував 200 рядків. Цей словник, безперечно, є елементом конфігурації, тому його переносимо до сервісу [config] у рядках 38–39. Цей сервіс визначено таким чином:

Image

  • рядки 38–39: створюється сервіс за допомогою функції [factory] об’єкта [angular.module]. Синтаксис цієї функції такий самий, як і в попередніх: factory('nom_service', ['O1','O2', ...., 'On', function (O1, O2, ..., On){...}]), де Oi — це імена об’єктів, відомих Angular (заздалегідь визначених або створених розробником), які Angular вводить як параметри функції factory. Оскільки в даному випадку функція не має параметрів, було використано коротший синтаксис, який також є допустимим: factory('nom_service', function (){...}]);
  • рядок 40: функція [factory] повинна реалізувати сервіс за допомогою об’єкта, який вона повертає. Саме цей об’єкт і є сервісом. Саме тому функція називається factory (фабрика створення об’єктів);

Зазвичай код сервісу має такий вигляд:


Angular.module('nom_module')
  .factory('nom_service',['O1','O2', ...., 'On', function (O1, O2, ..., On){
     // підготовка сервісу
    ...
     // повертається об’єкт, що реалізує службу
    return {
         // поля
        ...
         // методи
        ...
        }
});
  • рядок 6: повертається об’єкт JS, який може містити як поля, так і методи. Саме останні забезпечують роботу сервісу;

Тут сервіс [config] визначає лише поля і жодного методу. Сюди буде поміщено все, що можна налаштувати в додатку:

  • рядки 42–47: ключі повідомлень, що підлягають перекладу;
  • рядки 59–62: URL додатка;
  • рядки 64–69: URL віддаленого веб-сервісу;
  • рядок 71: виклик HTTP до веб-сервісу, який не відповідає, може тривати довго. Тут встановлюється максимальний час очікування відповіді веб-сервісу — 1 секунда. Після закінчення цього часу виклик HTTP завершується з помилкою, і генерується виняток JS;
  • рядок 73: перед кожним викликом сервера будемо імітувати очікування, тривалість якого встановлюється тут у мілісекундах. Значення 0 означає відсутність очікування. Додаток буде побудовано таким чином, щоб користувач міг скасувати операцію, яку він запустив. Щоб операцію можна було скасувати, вона має тривати принаймні кілька секунд. Це штучне очікування використовуватиметься для імітації тривалих операцій;
  • рядок 75: у режимі [debug=true] у поточному вікні відображається додаткова інформація. За замовчуванням цей режим увімкнено. У виробничому середовищі це поле слід встановити на false;
  • рядки 77–278: словник для двох локалей «fr» та «en». Раніше він містився у контролері [rdvMedecinsCtrl];

З цією службою контролер [rdvMedecinsCtrl] змінюється наступним чином:

Image

  • рядки 284–285: служба [config] вбудовується в контролер;
  • рядок 290: словник [locales] тепер знаходиться у службі [config], а не в контролері;
  • рядок 294: об’єкт [waiting], який керує відображенням повідомлення про очікування. Ключ повідомлення про очікування знаходиться у службі [config] (поле text). За замовчуванням повідомлення про очікування приховане (поле visible). Поле cancel має значення — ім’я функції у рядку 316. Отже, це поле є методом або функцією;
  • рядок 316: функція [cancel] є приватною (не було написано $scope.cancel=function(){}). Повернемося до коду кнопки скасування:

<button class="btn btn-primary pull-right" ng-click="waiting.cancel()">

Коли користувач натискає кнопку скасування, викликається метод [$scope.waiting.cancel()]. У підсумку виконується приватна функція cancel у рядку 316. Вона просто приховує повідомлення про очікування, встановлюючи значення false для змінної моделі [waiting.visible] (рядок 318);

3.7.5. Приклад 5: асинхронне програмування

Тепер ми розглянемо новий сервіс із новим поняттям — асинхронним програмуванням.

Наш додаток матиме три сервіси:

  • [config]: сервіс конфігурації, який ми щойно розглянули;
  • [utils]: служба утилітарних методів. Ми розглянемо два з них;
  • [dao]: сервіс доступу до веб-сервісу для запису на прийом. Ми розглянемо його найближчим часом;

Ми напишемо таку програму:

  • завдання полягає в тому, щоб відобразити банер [2] протягом часу, встановленого [1]. Очікування можна скасувати за допомогою [3].

Ми дублюємо [app-01.html] у [app-15.html] і змінюємо код наступним чином:


<!DOCTYPE html>
<html ng-app="rdvmedecins">
<head>
  <title>RdvMedecins</title>
  ...
</head>
<body ng-controller="rdvMedecinsCtrl">
<div class="container">

  <!-- повідомлення очікування -->
  <div class="alert alert-warning" ng-show="waiting.visible" ng-cloak="">
    <h1>{{ waiting.text | translate}}
      <button class="btn btn-primary pull-right" ng-click="waiting.cancel()">{{'msg_cancel'|translate}}</button>
      <img src="assets/images/waiting.gif" alt=""/>
    </h1>
  </div>

  <!-- форма -->
  <div class="alert alert-info" ng-hide="waiting.visible">
    <div class="form-group">
      <label for="waitingTime">{{waitingTimeText | translate}}</label>
      <input type="text" id="waitingTime" ng-model="waiting.time"/>
    </div>
    <button class="btn btn-primary" ng-click="execute()">Exécuter</button>
  </div>
</div>
..
<script type="text/javascript" src="rdvmedecins-03.js"></script>
</body>
</html>
  • рядок 11: атрибут [ng-cloak] запобігає відображенню поля до того, як будуть обчислені його вирази Angular. Це дозволяє уникнути короткочасного відображення поля до обчислення атрибута [ng-show], який фактично призведе до його приховування;
  • рядок 22: введені користувачем дані (час очікування) будуть збережені в моделі [waiting.time] (атрибут ng-model);
  • рядок 28: сторінка використовує новий скрипт [rdvmedecins-03];

Скрипт [rdvmedecins-03] має такий вигляд:

Image

  • рядок 6: модуль Angular, який керує додатком;
  • рядок 10: функція [config], що використовується для інтернаціоналізації повідомлень;
  • рядок 41: сервіс [config], який ми описали;
  • рядок 286: сервіс [utils], який ми будемо створювати;
  • рядок 315: контролер [rdvmedecinsCtrl], який ми збираємося створити;

До функції [config] додаємо новий ключ повідомлення (рядки 6, 11):


angular.module("rdvmedecins")
  .config(['$translateProvider', function ($translateProvider) {
    // повідомлення французькою
    $translateProvider.translations("fr", {
...
      'msg_waiting_time_text': «Час очікування: »
    });
    // повідомлення англійською
    $translateProvider.translations("en", {
...
      'msg_waiting_time_text': "Час очікування:"
    });
    // мова за замовчуванням
    $translateProvider.preferredLanguage("fr");
}]);

Додаємо до сервісу [config] новий рядок (рядок 6) для цього ключа повідомлення:


angular.module("rdvmedecins")
  .factory('config', function () {
    return {
      // повідомлення для інтернаціоналізації
      ...
waitingTimeText: 'msg_waiting_time_text',

Сервіс [utils] містить два методи (рядки 4, 12):


angular.module("rdvmedecins")
  .factory('utils', ['config', '$timeout', '$q', function (config, $timeout, $q) {
    // відображення JSON-представлення об’єкта
    function debug(message, data) {
      if (config.debug) {
        var text = data ? message + " : " + angular.toJson(data) : message;
        console.log(text);
      }
    }

    // очікування
    function waitForSomeTime(milliseconds) {
      // асинхронне очікування, тривалість у мілісекундах
      var task = $q.defer();
      $timeout(function () {
        task.resolve();
      }, milliseconds);
      // повертається завдання
      return task;
    };

    // екземпляр служби
    return {
      debug: debug,
      waitForSomeTime: waitForSomeTime
    }
}]);
  • рядок 2: служба називається [utils] (1-й параметр). Вона має залежності від трьох служб: двох попередньо визначених служб Angular — $timeout, $q — та служби config. Сервіс [$timeout] дозволяє виконати функцію після закінчення певного часу. Сервіс [$q] дозволяє створювати асинхронні завдання;
  • рядок 4: локальна функція [debug];
  • рядок 12: локальна функція [waitForSomeTime];
  • рядки 23–26: екземпляр сервісу [utils]. Це об’єкт, який надає доступ до двох методів — тих, що наведені в рядках 4 та 12. Зверніть увагу, що поля об’єкта можуть мати будь-які імена. Для узгодженості їм надано імена функцій, на які вони посилаються;
  • рядки 4–9: метод [debug] виводить на консоль повідомлення [message] і, за потреби, представлення JSON об’єкта [data]. Це дозволяє відображати об’єкти будь-якої складності;
  • рядки 12–20: метод [waitForSomeTime] створює асинхронне завдання, яке триває [milliseconds] мілісекунд;
  • рядок 14: створення завдання за допомогою попередньо визначеного об’єкта [$q] (https://docs.angularjs.org/api/ng/service/$q). Нижче наведено код API для завдання, яке в документації Angular називається [deferred]:

Image

  • асинхронне завдання [task] створюється інструкцією [$q.defer()];
  • воно завершується за допомогою одного з двох методів:
    • [task.resolve(value)]: який успішно завершує завдання та повертає значення [value] тим, хто очікує на завершення завдання;
    • [task.reject(value)]: завершує завдання з помилкою та повертає значення [value] тим, хто очікує на завершення завдання;

Завдання [task] може регулярно надавати інформацію тим, хто очікує на його завершення:

    • [task.notify(value)]: надсилає значення [value] тим, хто очікує на завершення завдання. Завдання продовжує виконуватися;

Ті, хто хоче дочекатися завершення завдання, використовують поле [promise] цього завдання:

var promise=[task].promise ;

Об’єкт [promise] має таке значення API (http://www.frangular.com/2012/12/api-promise-angularjs.html):

Image

Щоб обробляти як успішне виконання, так і збій завдання, потрібно написати:

1
2
3
var promise=[task].promise;
promise.then(successCallback, errorCallBack);
promise['finally'](finallyCallback);
  • рядок 1: отримуємо обіцянку завдання;
  • рядок 2: визначаємо функції, які мають виконуватися у разі успіху або невдачі. Функцію для невдачі можна не вказувати. Функція [successCallback] буде виконана лише після успішного завершення завдання [task] з результатом [task.resolve()]. Функція [errorCallBack] буде виконана лише після завершення завдання [task] у разі невдачі [task.reject()].
  • рядок 3: тут визначається функція, яка має виконатися після того, як виконається одна з двох попередніх функцій. Сюди вноситься код, спільний для обох функцій [successCallback, errorCallBack].

Повернемося до коду функції [waitForSomeTime]:


    // очікування
    function waitForSomeTime(milliseconds) {
      // асинхронне очікування в мілісекундах
      var task = $q.defer();
      $timeout(function () {
        task.resolve();
      }, milliseconds);
      // повертається завдання
      return task;
};
  • рядок 4: створюється завдання;
  • рядки 5–7: об’єкт [$timeout] дозволяє визначити функцію (1-й параметр), яка виконується через певний проміжок часу, виражений у мілісекундах (2-й параметр). Тут другим параметром функції [$timeout] є параметр методу (рядок 1);
  • рядок 6: після закінчення затримки [milliseconds] завдання успішно завершується;
  • рядок 9: повертається завдання [task]. Слід розуміти, що рядок 9 виконується одразу після визначення об’єкта [$timeout]. Не чекають, поки закінчиться час очікування [milliseconds]. Отже, код рядків 2–10 виконується у два різні моменти:
    • вперше — під час визначення об’єкта [$timeout];
    • другий раз — після закінчення затримки [milliseconds];

Тут ми маємо справу з асинхронною функцією: її результат отримується пізніше, ніж відбулося її виконання.

Код контролера, який використовує службу [config], виглядає так:


// контролер
angular.module("rdvmedecins")
  .controller('rdvMedecinsCtrl', ['$scope', 'utils', 'config', '$filter',
    function ($scope, utils, config, $filter) {
      // ------------------- ініціалізація моделі
      // повідомлення про очікування
      $scope.waiting = {text: config.msgWaiting, visible: false, cancel: cancel, time: undefined};
      $scope.waitingTimeText = config.waitingTimeText;
      // завдання очікування
      var task;
      // журнали
      utils.debug("libellé temps d'attente", $filter('translate')($scope.waitingTimeText));
      utils.debug("locales['fr']=", config.locales['fr']);

      // виконання дії
      $scope.execute = function () {
        // журнал
        utils.debug('début', new Date());
        // відображення повідомлення про очікування
        $scope.waiting.visible = true;
        // імітований режим очікування
        task = utils.waitForSomeTime($scope.waiting.time);
        // завершення очікування
        task.promise.then(function () {
          // успіх
          utils.debug('fin', new Date());
        }, function () {
          // невдача
          utils.debug('Opération annulée')
        });
        task.promise['finally'](function () {
          // завершення очікування у всіх випадках
          $scope.waiting.visible = false;
        });

      };

      // скасування очікування
      function cancel() {
        // завершення завдання
        task.reject();
      }
    }]);
  • рядок 3: контролер використовує службу [config];
  • рядок 7: до об’єкта [time] додано поле [time]. Об’єкт [$scope.waiting.time] отримує значення часу очікування, встановлене користувачем;
  • рядок 8: ключ повідомлення про очікування, що відображається поданням, розміщується в шаблоні [$scope.waitingTimeText]. Загалом усе, що відображається поданням V, має бути розміщене в об’єкті [$scope];
  • рядок 10: локальна змінна. Вона не доступна для подання V;
  • рядки 12–13: використання методу [debug] сервісу [config]. На консолі виводиться такий результат:
libellé temps d'attente : "Temps d'attente : "
locales['fr']= : {"DATETIME_FORMATS":{"AMPMS":["AM","PM"],"DAY":["dimanche","lundi","mardi","mercredi","jeudi","vendredi","samedi"],"MONTH":["janvier","février","mars","avril","mai","juin","juillet","août","septembre","octobre","novembre","décembre"],"SHORTDAY":["dim.","lun.","mar.","mer.","jeu.","ven.","sam."],"SHORTMONTH":["janv.","févr.","mars","avr.","mai","juin","juil.","août","sept.","oct.","nov.","déc."],"fullDate":"EEEE d MMMM y","longDate":"d MMMM y","medium":"d MMM y HH:mm:ss","mediumDate":"d MMM y","mediumTime":"HH:mm:ss","short":"dd/MM/yy HH:mm","shortDate":"dd/MM/yy","shortTime":"HH:mm"},"NUMBER_FORMATS":{"CURRENCY_SYM":"","DECIMAL_SEP":",","GROUP_SEP":" ","PATTERNS":[{"gSize":3,"lgSize":3,"macFrac":0,"maxFrac":3,"minFrac":0,"minInt":1,"negPre":"-","negSuf":"","posPre":"","posSuf":""},{"gSize":3,"lgSize":3,"macFrac":0,"maxFrac":2,"minFrac":2,"minInt":1,"negPre":"(","negSuf":" ¤)","posPre":"","posSuf":" ¤"}]},"id":"fr-fr"}

У рядку 2 отримуємо позначення JSON об’єкта locales['fr'].

  • рядок 16: метод, що виконується, коли користувач натискає кнопку [Executer];
  • рядок 18: відображає час початку виконання методу;
  • рядок 22: запускається завдання [waitForSomeTime]. Його завершення не очікується. Виконання продовжується з наступного рядка 24;
  • рядки 24–30: визначаються функції, які мають виконуватися у разі успішного завершення завдання (рядок 26) та у разі помилки (рядок 29);
  • рядок 26: виводить час завершення виконання методу;
  • рядок 29: виводиться повідомлення про те, що операція була скасована. Це відбувається лише тоді, коли користувач натискає кнопку [Annuler]. Інструкція в рядку 41 зупиняє асинхронне завдання з кодом помилки;
  • рядки 31–34: визначається функція, яку слід виконати після виконання однієї з двох попередніх функцій;

Важливо розуміти послідовність виконання цього коду. У разі, якщо користувач встановлює затримку на 3 секунди і не скасовує очікування:

  • коли він натискає кнопку [Exécuter], виконується функція [$scope.execute]. Рядки 16–34 виконуються без очікування 3 секунд. Після завершення цього виконання представлення V синхронізується з моделлю M. Відображається повідомлення про очікування (ng-show=$scope.waiting.visible=true, рядок 20), а форма приховується (ng-hide=$scope.waiting.visible=true, рядок 20);
  • з цього моменту користувач може знову взаємодіяти з видом. Зокрема, він може натиснути на кнопку [Annuler];
  • якщо він цього не зробить, через 3 секунди виконується функція [$timeout] (див. рядки 5–7 нижче):

    // очікування
    function waitForSomeTime(milliseconds) {
      // асинхронне очікування тривалістю мілісекунд мілісекунд
      var task = $q.defer();
      $timeout(function () {
        task.resolve();
      }, milliseconds);
      // повернення завдання
      return task;
};
  • Отже, через 3 секунди виконується код. Цей код завершує завдання [task] з кодом успіху (resolve). Це запустить виконання всіх кодів, які очікували на це завершення (рядок 4 нижче):

        // імітований очікування
        task = utils.waitForSomeTime($scope.waiting.time);
        // завершення очікування
        task.promise.then(function () {
          // успіх
          utils.debug('fin', new Date());
        }, function () {
          // невдача
          utils.debug('Opération annulée')
        });
        task.promise['finally'](function () {
          // завершення очікування у будь-якому випадку
          $scope.waiting.visible = false;
        });

  • отже, буде виконано рядок 6 вище (успішне завершення). Потім настане черга рядків 11–14. Після виконання цього коду ми повертаємося до подання V, яке потім буде синхронізовано з його моделлю M. Повідомлення про очікування приховано (ng-show=$scope.waiting.visible=false, рядок 13), а форма відображається (ng-hide=$scope.waiting.visible=false, рядок 13);

Екранні зображення виглядають наступним чином:

début : "2014-06-23T15:05:58.480Z"
fin : "2014-06-23T15:06:01.481Z"

Як бачимо вище, між початком і кінцем очікування є затримка в 3 секунди (06:01–05:58). Якщо ж користувач скасує очікування до закінчення цих 3 секунд, з’явиться таке повідомлення:

début : "2014-06-23T15:08:09.564Z"
Opération annulée

Наостанок важливо зрозуміти, що в будь-який момент часу існує лише один потік виконання, який називається потоком UI (User Interface). Завершення асинхронного завдання сигналізується подією, точно так само, як і натискання кнопки. Ця подія не обробляється негайно. Воно потрапляє в чергу подій, що очікують на виконання. Коли настає його черга, воно обробляється. Ця обробка використовує потік UI, і тому протягом цього часу інтерфейс «зависає». Він не реагує на дії користувача. Тому важливо, щоб обробка події відбувалася швидко. Оскільки кожна подія обробляється потоком UI, ніколи не доводиться вирішувати проблеми синхронізації між потоками, що виконуються одночасно. У будь-який момент часу виконується лише потік UI.

3.7.6. Приклад 6: служби HTTP

Тепер розглянемо службу [dao], яка взаємодіє з веб-сервером:

3.7.6.1. Вигляд V

Ми напишемо форму для запиту списку лікарів:

Image

Ми дублюємо [app-01.html] у [app-16.html], який потім змінюємо наступним чином:


<div class="container" ng-cloak="">
  <h1>Rdvmedecins - v1</h1>

  <!-- повідомлення про очікування -->
  <div class="alert alert-warning" ng-show="waiting.visible" ng-cloak="">
    <h1>{{ waiting.text | translate}}
      <button class="btn btn-primary pull-right" ng-click="waiting.cancel()">{{'msg_cancel'|translate}}</button>
      <img src="assets/images/waiting.gif" alt=""/>
    </h1>
  </div>

  <!-- запит -->
  <div class="alert alert-info" ng-hide="waiting.visible">
    <div class="form-group">
      <label for="waitingTime">{{waitingTimeText | translate}}</label>
      <input type="text" id="waitingTime" ng-model="waiting.time"/>
    </div>
    <div class="form-group">
      <label for="urlServer">{{urlServerLabel | translate}}</label>
      <input type="text" id="urlServer" ng-model="server.url"/>
    </div>
    <div class="form-group">
      <label for="login">{{loginLabel | translate}}</label>
      <input type="text" id="login" ng-model="server.login"/>
    </div>
    <div class="form-group">
      <label for="password">{{passwordLabel | translate}}</label>
      <input type="password" id="password" ng-model="server.password"/>
    </div>
    <button class="btn btn-primary" ng-click="execute()">{{medecins.title|translate:medecins.model}}</button>
  </div>

  <!-- список лікарів -->
  <div class="alert alert-success" ng-show="medecins.show">
    {{medecins.title|translate:medecins.model}}
    <ul>
      <li ng-repeat="medecin in medecins.data">{{medecin.titre}}{{medecin.prenom}} {{medecin.nom}}</li>
    </ul>
  </div>

  <!-- список помилок -->
  <div class="alert alert-danger" ng-show="errors.show">
    {{errors.title|translate:errors.model}}
    <ul>
      <li ng-repeat="message in errors.messages">{{message|translate}}</li>
    </ul>
  </div>

</div>
...
<script type="text/javascript" src="rdvmedecins-04.js"></script>
  • рядки 13–31: реалізують форму. Вона не відображається, коли показується повідомлення про очікування (ng-hide="waiting.visible"). Слід зауважити, що чотири поля введення даних зберігаються в (атрибути ng-model) [waiting.time (ligne 16), server.url (ligne 20), server.login (ligne 24), server.password (ligne 28)];
  • рядки 34–39: відображають список лікарів. Цей список не завжди видимий (ng-show="medecins.show").
  • рядок 35: альтернатива синтаксису <div ... translate="{{medecins.title}}" translate-values="{{medecins.model}}">, з яким ми вже зустрічалися;
  • рядок 36: неупорядкований список;
  • рядок 37: список лікарів міститься у шаблоні [medecins.data]. Директива Angular [ng-repeat] дозволяє перебирати список. Синтаксис ng-repeat="medecin in medecins.data" вимагає, щоб тег <li> повторювався для кожного елемента списку [medecins.data]. Поточний елемент списку називається [medecin];
  • рядок 37: для кожного <li> записуються посада, ім’я та прізвище поточного лікаря, позначеного змінною [medecin];
  • рядки 42–47: відображають список помилок. Цей список не завжди видно (ng-show="errors.show"). Його відображення відбувається за тим самим принципом, що й відображення списку лікарів. Як правило, для відображення списку об’єктів використовується директива Angular [ng-repeat];
  • рядок 51: код JavaScript тепер знаходиться у файлі [rdvmedecins-04]

3.7.6.2. Контролер C та модель M

Код JavaScript змінюється наступним чином:

Image

  • рядки 6–9: модуль [rdvmedecins] заявляє про залежність від модуля [base64], що надається бібліотекою [angular-base64], яка є однією із залежностей проєкту. Цей модуль слугує для кодування в Base64 рядка [login:password], що надсилається до веб-сервісу для автентифікації;
  • рядки 12–13: функція ініціалізації, яка містить наші інтернаціоналізовані повідомлення. З’являються нові повідомлення. Ми більше не будемо їх наводити;
  • рядки 69–70: сервіс [config], який налаштовує наш додаток. До нього додано нові ключі повідомлень. Ми більше не будемо їх описувати;
  • рядки 318–319: сервіс [utils], що містить допоміжні методи. До нього додано нові методи. Ми їх розглянемо;
  • рядки 385–386: сервіс [dao], відповідальний за обмін даними з веб-сервісом. Саме на ньому ми зосередимося;
  • рядки 467–468: контролер C для подання V, яке ми щойно розглянули. Ми розглянемо його зараз, оскільки саме він є «диригентом», який реагує на запити користувача;

3.7.6.3. Контролер C

Код контролера такий:


angular.module("rdvmedecins")
  .controller('rdvMedecinsCtrl', ['$scope', 'utils', 'config', 'dao', '$translate',
    function ($scope, utils, config, dao, $translate) {
      // ------------------- ініціалізація шаблону
      // модель
      $scope.waiting = {text: config.msgWaiting, visible: false, cancel: cancel, time: undefined};
      $scope.waitingTimeText = config.waitingTimeText;
      $scope.server = {url: undefined, login: undefined, password: undefined};
      $scope.medecins = {title: config.listMedecins, show: false, model: {}};
      $scope.errors = {show: false, model: {}};
      $scope.urlServerLabel = config.urlServerLabel;
      $scope.loginLabel = config.loginLabel;
      $scope.passwordLabel = config.passwordLabel;

      // асинхронне завдання
      var task;

      // виконання дії
      $scope.execute = function () {
        // оновлюємо UI
        $scope.waiting.visible = true;
        $scope.medecins.show = false;
        $scope.errors.show = false;
        // імітація очікування
        task = utils.waitForSomeTime($scope.waiting.time);
        var promise = task.promise;
        // очікування
        promise = promise.then(function () {
          // запит списку лікарів;
          task = dao.getData($scope.server.url, $scope.server.login, $scope.server.password, config.urlSvrMedecins);
          return task.promise;
        });
        // аналізується результат попереднього виклику
        promise.then(function (result) {
          // result={err: 0, data: [med1, med2, ...]}
          // result={err: n, messages: [msg1, msg2, ...]}
          if (result.err == 0) {
            // вносимо отримані дані в модель
            $scope.medecins.data = result.data;
            // оновлюємо UI
            $scope.medecins.show = true;
            $scope.waiting.visible = false;
          } else {
            // виникли помилки під час отримання списку лікарів
            $scope.errors = { title: config.getMedecinsErrors, messages: utils.getErrors(result), show: true, model: {}};
            // оновлюється файл UI
            $scope.waiting.visible = false;
          }
        });
      };

      // очікується скасування
      function cancel() {
        // завершується виконання завдання
        task.reject();
        // оновлюється файл UI
        $scope.waiting.visible = false;
        $scope.medecins.show = false;
        $scope.errors.show = false;
      }

    }
  ])
;
  • рядок 2: контролер має нову залежність — від сервісу [dao];
  • рядки 6–13: модель M подання V ініціалізується для першого відображення цього подання;
  • рядок 8: [$scope.server] буде використано для отримання трьох із чотирьох даних з форми V, четвертий з яких зберігається в [$scope.waiting.time] (рядок 6);
  • рядок 9: [$scope.medecins] збиратиме інформацію, необхідну для відображення списку лікарів:

  <!-- список лікарів -->
  <div class="alert alert-success"  ng-show="medecins.show">
    {{medecins.title|translate:medecins.model}}
    <ul>
      <li ng-repeat="medecin in medecins.data">{{medecin.titre}}{{medecin.prenom}} {{medecin.nom}}</li>
    </ul>
</div>

Атрибут [medecins.title] буде заголовком банера. Він визначений у службі [config]. Атрибут [medecins.show] контролюватиме, чи відображати банер (атрибут ng-show="medecins.show"). Атрибут [medecins.model] є порожнім словником і таким і залишиться. Він слугує лише для ілюстрації використання варіанту перекладу, застосованого в рядку 3. Атрибут [medecins.data], який міститиме список лікарів (рядок 5), ще не визначено.

  • рядок 10: [$scope.errors] збиратиме інформацію, необхідну для відображення списку помилок:

  <!-- список помилок -->
  <div class="alert alert-danger"  ng-show="errors.show">
    {{errors.title|translate:errors.model}}
    <ul>
      <li ng-repeat="message in errors.messages">{{message|translate}}</li>
    </ul>
</div>

Атрибут [errors.title] буде заголовком банера. Він визначений у службі [config]. Атрибут [errors.show] контролюватиме, чи відображатиметься банер (атрибут ng-show="errors.show"). Атрибут [errors.model] є порожнім словником і таким і залишиться. Він слугує лише для ілюстрації використання варіанту перекладу, застосованого в рядку 3. Атрибут [errors.messages], який міститиме список повідомлень про помилки для відображення (рядок 5), поки що не визначений.

  • рядок 16: асинхронне завдання. Контролер послідовно запустить два асинхронні завдання. Посилання на ці послідовні завдання будуть розміщені у змінній [task]. Це дозволить їх скасувати (рядок 55);
  • рядок 19: метод, що виконується, коли користувач натискає кнопку [Liste des médecins]:

    <button class="btn btn-primary" ng-click="execute()">Liste des médecins</button>
  • рядки 21–23: візуальний інтерфейс оновлюється: відображається повідомлення про очікування, все інше приховується;
  • рядок 25: створюється асинхронне завдання очікування. Сигнал (завдання виконано) буде отримано після закінчення часу, введеного користувачем у форму;
  • рядок 26: отримуємо обіцянку асинхронного завдання. Саме з нею працює програма, що запускає завдання. Однак потрібно мати посилання на саме завдання, щоб мати змогу його скасувати (рядок 55);
  • рядки 28–32: визначаємо дії, які потрібно виконати після завершення очікування;
  • рядок 30: використовується метод [dao.getData] для запуску нового асинхронного завдання. Йому передаються необхідні дані:
    • кореневий URL веб-сервісу [$scope.server.url], наприклад [http://localhost:8080];
    • логін [$scope.server.login] для авторизації, наприклад [admin];
    • пароль [$scope.server.password] для входу, наприклад [admin];
    • URL, який виконує запитувану послугу [config.urlSvrMedecins], у даному випадку [/getAllMedecins]. У підсумку повний URL буде [http://localhost:8080/getAllMedecins];

Метод [dao.getData] повертає результат, який може мати дві форми:

  • (продовження)
    • {err: 0, data: [med1, med2, ...]}, де [medi] — об’єкт, що представляє лікаря (звання, ім’я, прізвище),
    • {err: n, messages: [msg1, msg2, ...]}, де [msgi] — повідомлення про помилку, а n не дорівнює 0;
  • рядок 31: повертається обіцянка завдання. Тут є дещо, що слід зрозуміти. Ми маємо дві обіцянки:
    • promise.then() : повертає першу обіцянку [promise1];
    • return task.promise: повертає другу обіцянку [promise2];
    • у підсумку promise=promise.then(...; return task.promise) є ланцюжком із двох обіцянок [promise2.promise1]. [promise1] буде обчислено лише тоді, коли буде отримано обіцянку [promise2], тобто коли завдання [dao.getData] буде завершено. Обіцянка [promise1] не залежить від жодного асинхронного завдання. Тому вона буде виконана негайно;
  • рядки 34–50: з попереднього пояснення випливає, що ці рядки будуть виконані лише після завершення завдання [dao.getData]. Параметр [result], переданий у функцію в рядку 34, формується методом [dao.getData] і передається коду, що викликає, операцією [task.resolve(result)], де [result] має такий вигляд:
    • {err: 0, data: [med1, med2, ...]}, де [medi] — об’єкт, що представляє лікаря (звання, ім’я, прізвище),
    • {err: n, messages: [msg1, msg2, ...]}, де [msgi] — це повідомлення про помилку, а n не дорівнює 0;
  • рядок 37: перевіряємо код помилки [result.err];
  • рядки 38–42: якщо помилки немає (result.err == 0), то отримуємо список лікарів і виводимо його на екран;
  • рядки 44–47: якщо, навпаки, є помилка (result.err ≠ 0), то отримуємо список повідомлень про помилки та виводимо його;
  • рядки 53–56: повідомлення про очікування з кнопкою скасування залишається на екрані, доки обидві асинхронні операції не завершаться. Давайте розглянемо, що відбувається залежно від моменту скасування:
    • спочатку слід зрозуміти, що рядки 19–50 виконуються одним махом. Тоді запускається лише одне асинхронне завдання — те, що в рядку 25,
    • після цього першого виконання оновлюється вікно V, і, отже, стає видимим банер очікування та кнопка скасування. Якщо користувач скасовує очікування до завершення завдання з рядка 25, виконується метод з рядка 53, і завдання скасовується з помилкою (рядок 55);
    • рядки 56–59: інтерфейс оновлюється: знову відображається форма, а все інше приховується,
    • після чого відбувається повернення до виду V, і браузер переходить до обробки наступної події. Оскільки завдання завершилося, отримується обіцянка цього завдання, що створює подію. Потім вона обробляється;
    • потім виконуються рядки 28–32. Функція для випадку невдачі не визначена, тому код не виконується. Отримується нова обіцянка, яка, як і раніше, повертається [promise.then] і, як і раніше, отримується,
    • після обробки події відбувається повернення до вікна V, і браузер переходить до обробки наступної події. Оскільки [promise] із рядка 28 було оброблено, буде вирішено [promise] із рядка 34, що спричинить нову подію. Потім вона обробляється;
    • потім по черзі виконуються рядки 34–49, оскільки обіцянка, використана в рядку 34, була виконана. Знову ж таки, оскільки для випадку невдачі не визначено жодної функції, код не виконується,
    • і таким чином ми доходимо до рядка 50. Очікування завдання більше немає, і відображається новий вигляд V;
    • тепер припустимо, що скасування відбувається під час виконання другого асинхронного завдання [dao.getData]. Попередній виклад можна застосувати знову. Завершення завдання спричинить виконання рядків 34–50 із завершенням завдання з помилкою. Незабаром ми побачимо, що метод [dao.getData] здійснює асинхронний виклик HTTP до веб-сервісу. Цей виклик не буде скасовано, але його результат не буде використано.

Важливо розуміти це постійне перемикання між відображенням подання V та обробкою подій браузера. Події викликаються користувачем (клацанням) або системними операціями, такими як завершення асинхронної операції. Стан спокою браузера — це відображення подання V. З цього стану його виводить подія, яка відбувається, і яку він потім обробляє. Як тільки подія оброблена, браузер повертається до стану спокою. Подання V оновлюється, якщо оброблена подія змінила його модель M. Наступна подія виводить браузер із стану спокою.

Усе відбувається в одному потоці. Дві події ніколи не обробляються одночасно. Їх виконання є послідовним. Браузер переходить до наступної події лише тоді, коли попередня звільняє йому дорогу, як правило, тому що її обробка завершилася повністю.

Залишилося пояснити ще один момент. Щоб відобразити повідомлення про помилки, ми пишемо:


$scope.errors = { title: config.getMedecinsErrors, messages: utils.getErrors(result), show: true, model: {}};

Список повідомлень надається методом [utils.getErrors], визначеним у службі [utils]. Цей метод виглядає так:


// аналіз помилок у відповіді сервера JSON
    function getErrors(data) {
      // дані {err:n, messages:[]}, err!=0
      // помилки
      var errors = [];
      // код помилки
      var err = data.err;
      switch (err) {
        case 2 :
          // не авторизовано
          errors.push('not_authorized');
          break;
        case 3 :
          // заборонено
          errors.push('forbidden');
          break;
        case 4 :
          // локальна помилка
          errors.push('not_http_error');
          break;
        case 6 :
          // документ не знайдено
          errors.push('not_found');
          break;
        default :
          // інші випадки
          errors = data.messages;
          break;

      }
      // якщо повідомлення відсутнє, додаємо його
      if (! errors || errors.length == 0) {
        errors=['error_unknown'];
      }
      // виводимо список помилок
      return errors;
    }
  • рядки 2–3: отриманий параметр [data] є об’єктом із двома атрибутами:
    • [err]: код помилки;
    • [messages] — список повідомлень;
  • рядок 5: ми створимо масив повідомлень про помилки. Ці повідомлення є інтернаціоналізованими. З цієї причини в масив заносяться не самі повідомлення, а їхні ключі інтернаціоналізації, за винятком рядка 27. У цьому випадку використовується атрибут [messages] параметра [data]. Ці повідомлення є справжніми повідомленнями, а не ключами повідомлень. Однак подання V буде обробляти їх як ключі повідомлень, які, відповідно, не будуть знайдені. У цьому випадку модуль [translate] відображає ключ повідомлення, який він не знайшов, тобто в даному випадку — справжнє повідомлення. Це є бажаним результатом;
  • рядки 32–34: обробляють випадок, коли значення [data.messages] у рядку 27 дорівнює null. Це трапляється у написаному веб-сервісі. Цього випадку слід було уникнути.

3.7.6.4. Сервіс [dao]

Сервіс [dao] забезпечує обмін даними з веб-сервісом HTTP / JSON. Його код такий:


angular.module("rdvmedecins")
  .factory('dao', ['$http', '$q', 'config', '$base64', 'utils',
    function ($http, $q, config, $base64, utils) {

      // журнали
      utils.debug("[dao] init");

      // ----------------------------------приватні методи
      // отримати дані з веб-сервісу
      function getData(serverUrl, username, password, urlAction, info) {
        // асинхронна операція
        var task = $q.defer();
        // запит за URL-адресою HTTP
        var url = serverUrl + urlAction;
        // базова автентифікація
        var basic = "Basic " + $base64.encode(username + ":" + password);
        // відповідь
        var réponse;
        // усі HTTP-запити мають бути автентифіковані
        var headers = $http.defaults.headers.common;
        headers.Authorization = basic;
        // надсилаємо запит HTTP
        var promise;
        if (info) {
          promise = $http.post(url, info, {timeout: config.timeout});
        } else {
          promise = $http.get(url, {timeout: config.timeout});
        }
        promise.then(success, failure);
        // повертаємо саме завдання, щоб його можна було скасувати
        return task;

        // успішно
        function success(response) {
          // response.data={status:0, data:[med1, med2, ...]} або {status:x, data:[msg1, msg2, ...]
          utils.debug("[dao] getData[" + urlAction + "] success réponse", response);
          // відповідь
          var payLoad = response.data;
          réponse = payLoad.status == 0 ? {err: 0, data: payLoad.data} : {err: 1, messages: payLoad.data};
          // віддаємо відповідь
          task.resolve(réponse);
        }

        // помилка
        function failure(response) {
          utils.debug("[dao] getData[" + urlAction + "] error réponse", response);
          // аналізується статус
          var status = response.status;
          var error;
          switch (status) {
            case 401 :
              // неавторизовано
              error = 2;
              break;
            case 403:
              // заборонено
              error = 3;
              break;
            case 404:
              // не знайдено
              error = 6;
              break;
            case 0:
              // локальна помилка
              error = 4;
              break;
            default:
              // щось інше
              error = 5;
          }
          // відповідаємо
          task.resolve({err: error, messages: [response.statusText]});
        }
      }

      // --------------------- екземпляр служби [dao]
      return {
        getData: getData
      }
}]);
  • рядки 77–79: сервіс має лише одне поле — метод [getData], який дозволяє отримувати інформацію від веб-сервісу / JSON;
  • рядок 2: з’являється залежність [$http], з якою ми ще не стикалися. Це попередньо визначений сервіс Angular, який забезпечує взаємодію HTTP з віддаленим об’єктом;
  • рядок 6: запис у журналі, що дозволяє побачити, на якому етапі роботи додатка виконується код;
  • рядок 10: метод [getData] приймає п’ять параметрів:
    • [serverUrl]: кореневий URL веб-сервісу (http://localhost:8080);
    • [urlAction]: URL конкретного запитуваного сервісу (/getAllMedecins);
    • [username]: логін користувача;
    • [password]: його пароль;
    • [info]: об’єкт, що містить додаткову інформацію, коли URL конкретної запитуваної послуги запитується за допомогою операції POST. У випадку з URL (/getAllMedecins) цей параметр не був переданий. Отже, він дорівнює [undefined];
  • рядок 12: створюється асинхронне завдання;
  • рядок 14: URL завершує запитуваний сервіс (http://localhost:8080/getAllMedecins);
  • рядок 16: автентифікація здійснюється шляхом надсилання такого заголовка HTTP:
Authorization:Basic code

де [code] — це код Base64 рядка [username:password];

У рядку 16 формується частина [Basic code] заголовка HTTP;

  • рядок 18: відповідь веб-сервісу;
  • рядок 20: заголовки HTTP, які Angular надсилає за замовчуванням у запиті HTTP, визначені в об’єкті [$http.defaults.headers.common]. Заголовок [Authorization:Basic code] до них не входить;
  • рядок 21: його додають до заголовків HTTP, які мають надсилатися завжди. Ліворуч від присвоєння знаходиться заголовок [Authorization], який потрібно ініціалізувати, а праворуч — значення заголовка, у даному випадку значення, визначене в рядку 16. Отже, якщо написати:
headers.Authorization = 'x';

Angular надішле заголовок HTTP:

Authorization : x
  • рядок 23: методи сервісу [$http] повертають обіцянки. Вони будуть збережені у змінній [promise];
  • рядок 27: оскільки тут параметр [info] має значення [undefined], виконується саме рядок 27. URL (http://localhost:8080/getAllMedecins) запитується разом із GET. Щоб не чекати занадто довго, встановлюємо максимальний час очікування (тайм-аут) на отримання відповіді від сервера. За замовчуванням цей час становить одну секунду;
  • рядок 29: визначаємо два методи, які потрібно виконати після отримання обіцянки:
    • [success]: визначено в рядку 34, це метод, який слід виконати, коли обіцянка отримана після успішного виконання завдання;
    • [failure]: визначений у рядку 45, це метод, який слід виконати, коли обіцянка отримується після невдачі завдання;
    • обидва методи (точніше, функції) визначені всередині функції [getData]. Це можливо в JavaScript. Змінні, визначені в [getData], доступні в обох внутрішніх функціях [success, failure];
  • рядок 31: повертається завдання, створене в рядку 12. Тут слід згадати код, що викликає:

        promise = promise.then(function () {
          // запитується список лікарів;
          task = dao.getData($scope.server.url, $scope.server.login, $scope.server.password, config.urlSvrMedecins);
          return task.promise;
});

У рядку 3 вище дійсно отримується завдання.

  • рядок 34: функція [success] виконується пізніше, коли виклик HTTP успішно завершується. Це поняття «успіху» пов’язане з першим рядком відповіді HTTP. Вона має такий вигляд:
HTTP/1.1 code texte

Код — це трицифровий текст, який вказує, чи виклик завершився успішно чи ні. Узагальнено можна сказати, що коди 2xx та 3xx є кодами успіху, а решта — кодами невдачі. Текст — це коротке пояснення. Ось два можливі варіанти відповідей: один у разі успіху, інший — у разі невдачі:

HTTP/1.1 200 OK
HTTP/1.1 404 Not Found
  • рядок 36: на консолі виводиться відповідь сервера. У разі помилки [404 Not Found] ми отримуємо щось на зразок:

[dao] getData[/getAllMedecins] error réponse : {"data":"...","status":404,"config":{...},"statusText":"Not Found"}

У цій відповіді ми будемо використовувати лише поля [data], [status] та [statusText].

  • рядок 38: витягуємо поле [data] з відповіді. Воно матиме один із таких виглядів:
    • {status: 0, data: [med1, med2, ...]}, де [medi] — об’єкт, що представляє лікаря (посада, ім’я, прізвище),
    • {status: n, data: [msg1, msg2, ...]}, де [msgi] — повідомлення про помилку, а n не дорівнює 0;

Image

  • рядок 39: формується відповідь {0,data} або {n,messages}. Перша відповідь містить лікарів у полі [data]. Друга відповідь сигналізує про помилку, що сталася на стороні сервера. Сервер обробив цю помилку, згенерував код помилки в полі [err] та список повідомлень про помилки в полі [data]. В обох випадках він повертає код HTTP 200, що вказує на те, що замовлення HTTP було повністю оброблено. Саме тому обидва випадки обробляються в одній і тій самій функції [success];
  • рядок 41: завдання завершено [task.resolve], і повертається одна з двох відповідей:
    • {err: 0, data: [med1, med2, ...]}, де [medi] — об’єкт, що представляє лікаря (посада, ім’я, прізвище),
    • {err: n, messages: [msg1, msg2, ...]}, де [msgi] — повідомлення про помилку, а n не дорівнює 0;

Цей код потрібно пов’язати зі способом отримання цієї відповіді у викликуючому коді контролера:


        // аналізуємо результат попереднього виклику
        promise.then(function (result) {
          // result={err: 0, data: [med1, med2, ...]}
          // result={err: n, messages: [msg1, msg2, ...]}
          ...
          }

Відповідь [task.resolve(réponse)] міститься вище у змінній [result].

  • рядок 45: функція [failure], яка викликається, коли асинхронне завдання завершується з помилкою. Можливі два випадки:
    • сервер повідомляє про цю помилку, повертаючи код, який не є ні 2xx, ні 3xx,
    • Angular скасовує виклик HTTP. У цьому випадку виклик не відбувається. виникає виняток Angular, але сервер не повертає код помилки HTTP. Це, наприклад, трапляється, якщо вказано недійсний URL, який не можна викликати;
  • рядок 46: відповідь виводиться в консоль;
  • рядок 48: нагадуємо, що відповідь сервера має такий вигляд:

{"data":"...","status":404,"config":{...},"statusText":"Not Found"}

У рядку 48 витягується вищезазначений атрибут [status];

  • рядки 50–70: на основі коду помилки HTTP генерується новий код помилки, щоб приховати від викликів характер методу HTTP, який насправді є [dao.getData]. Можна переконатися, що в контролері, який використовує цей метод, ніщо не вказує на те, що в методі є виклик HTTP;
    • рядок 51: помилка [401] відповідає невдалій аутентифікації (наприклад, неправильний пароль),
    • рядок 55: помилка [403] відповідає несанкціонованому виклику. Користувач успішно пройшов автентифікацію, але не має достатніх прав для виконання запиту URL, який він надіслав. Це трапиться з користувачем [user / user]. Цей користувач дійсно існує в базі даних, але не має права користуватися додатком. Лише користувач [admin / admin] має це право;
    • рядок 59: помилка [404] пов’язана з тим, що URL не знайдено. Помилка може мати кілька причин:
      • користувач допустив помилку при введенні URL сервісу;
      • веб-сервіс не був запущений;
      • веб-сервіс не відповів достатньо швидко (за замовчуванням — затримка в одну секунду);
    • рядок 63: код помилки HTTP 0 не існує. Ми маємо ситуацію, коли Angular не виконав запит HTTP, оскільки URL, введений користувачем, є недійсним і не може бути викликаний. Далі ми зіткнемося з іншими випадками, коли Angular не виконує запитуваний виклик HTTP;
  • рядок 72: завдання успішно завершено (task.resolve) з поверненням відповіді типу {err, messages}, де масив [messages] складається лише з одного повідомлення [response.statusText]. У разі, якщо Angular не виконав запит HTTP, ми отримаємо порожній рядок;

Тепер, коли ми маємо як загальний, так і детальний огляд додатка, можемо розпочати тестування.

3.7.6.5. Тестування додатка — 1

Почнемо з правильних значень:

Image

  • у полі [1] вводимо 0, щоб уникнути очікування;
  • у випадку [2] з’являється повідомлення про помилку, хоча введені дані є правильними. Ми не наводили різні повідомлення про помилки. Повідомлення, що відображається у випадку [2], є загальним повідомленням, пов’язаним з помилкою 0, яка відповідає винятковій ситуації в Angular. Angular зіткнувся з проблемою, яка завадила йому виконати виклик HTTP. У таких випадках потрібно переглянути журнали консолі JavaScript. Це можна зробити двома способами:
    • виконати [F12] у браузері Chrome;
    • скористатися консоллю WebStorm;

У консолі WebStorm ми знаходимо різні повідомлення, серед яких таке:

XMLHttpRequest cannot load http://localhost:8080/getAllMedecins. У запитуваному ресурсі відсутній заголовок «Access-Control-Allow-Origin». Тому доступу з джерела «http://localhost:63342» не дозволено.
[dao] getData[/getAllMedecins] error réponse : {"data":"","status":0,"config":{"method":"GET","transformRequest":[null],"transformResponse":[null],"timeout":1000,"url":"http://localhost:8080/getAllMedecins","headers":{"Accept":"application/json, text/plain, */*","Authorization":"Basic YWRtaW46YWRtaW4="}},"statusText":""}
  • рядок 1: Angular повідомляє про помилку, до якої ми ще повернемося;
  • рядок 2: журнал методу [dao.getData]. У ньому можна знайти цікаві речі:
    • [status] дорівнює 0, що вказує на те, що виклик HTTP не відбувся. Відповідно, [statusText] є порожнім,
    • [url] дорівнює [http://localhost:8080/getAllMedecins], що є правильним;
    • заголовок HTTP для автентифікації [Authorization":"Basic YWRtaW46YWRtaW4=] також є правильним;

Тоді чому ж це не спрацювало? Ключовим рядком у логах є [No 'Access-Control-Allow-Origin' header is present]. Щоб його зрозуміти, потрібно дати довге пояснення. Почнемо з огляду загальної архітектури клієнт-серверного додатка:

Image

  • сторінки HTML / CSS / JS додатка Angular надходять із сервера [1];
  • на сторінці [2] служба [dao] надсилає запит на інший сервер — [2]. Однак це заборонено браузером, який виконує додаток Angular, оскільки це є уразливістю безпеки. Додаток може звертатися лише до того сервера, з якого він походить, тобто до сервера [1];

Насправді не зовсім правильно стверджувати, що браузер забороняє додатку Angular звертатися до сервера [2]. Насправді додаток звертається до нього, щоб запитати, чи дозволяє він клієнту, який не походить з його домену, звертатися до нього. Цю техніку обміну називають CORS (Cross-Origin Resource Sharing). Сервер [2] дає свою згоду, надсилаючи певні заголовки HTTP. Саме тому, що в даному випадку наш сервер [2] не надіслав їх, браузер відмовився виконати виклик HTTP, який запитувала програма.

Тепер розглянемо це детальніше. Проаналізуємо мережевий обмін даними, що відбувся під час виклику HTTP. Для цього в браузері Chrome натискаємо [F12], щоб відкрити інструменти розробника, і вибираємо вкладку [Network], щоб переглянути мережевий обмін даними:

  • у [1] вибираємо вкладку [network];
  • у [2] запитуємо список лікарів;

На вкладці [network] ми отримуємо таку інформацію:

  • у [1] — інформація, надіслана на сервер;
  • у [2] — відповідь сервера;

У [1] можна побачити, що браузер надіслав запит HTTP [OPTIONS] щодо запитуваного URL. [OPTIONS] — це одна з можливих команд HTTP, серед яких найвідомішими є [GET] та [POST]. Вона дозволяє запитувати у сервера інформацію, зокрема про опції HTTP, які він підтримує, звідки й походить назва команди. Сервер надає відповідь у форматі [2]. Щоб вказати, що він приймає запити від клієнтів, які не належать до його домену, він повинен надіслати спеціальний заголовок під назвою [Access-Control-Allow-Origin]. І саме через те, що він не надіслав цей заголовок, Angular не виконав запит HTTP і повернув помилку:

XMLHttpRequest cannot load http://localhost:8080/getAllMedecins. У запитуваному ресурсі відсутній заголовок «Access-Control-Allow-Origin». Тому доступу з джерела «http://localhost:63342» не дозволено.

Отже, нам потрібно змінити налаштування нашого сервера, щоб він надсилав очікуваний заголовок HTTP.

3.7.6.6. Модифікація веб-сервера / JSON

Повертаємося до Eclipse. Щоб зберегти досягнуте, дублюємо поточну версію веб-сервера / JSON [rdvmedecins-webapi-v2] у [rdvmedecins-webapi-v3] [1]:

Ми вносимо першу зміну в [ApplicationModel], який є одним з елементів конфігурації веб-сервісу:


package rdvmedecins.web.models;

...

@Component
public class ApplicationModel implements IMetier {

    // шар [métier]
    @Autowired
    private IMetier métier;

    // дані з шару [métier]
    private List<Medecin> médecins;
    private List<Client> clients;
    private List<String> messages;
    // дані конфігурації
    private boolean CORSneeded = true;

...

    public boolean isCORSneeded() {
        return CORSneeded;
    }

}
  • рядок 17: створюємо логічну змінну, яка вказує, чи приймаються клієнти, що не належать до домену сервера;
  • рядки 21–23: метод доступу до цієї інформації;

Потім ми створюємо новий контролер Spring MVC [3]:

Клас [RdvMedecinsCorsController] має такий вигляд:


package rdvmedecins.web.controllers;

import javax.servlet.http.HttpServletResponse;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;

import rdvmedecins.web.models.ApplicationModel;

@Controller
public class RdvMedecinsCorsController {

    @Autowired
    private ApplicationModel application;

    // надсилання параметрів клієнту
    private void sendOptions(HttpServletResponse response) {
        if (application.isCORSneeded()) {
            // встановлення заголовка CORS
            response.addHeader("Access-Control-Allow-Origin", "*");
        }

    }

    // список лікарів
    @RequestMapping(value = "/getAllMedecins", method = RequestMethod.OPTIONS)
    public void getAllMedecins(HttpServletResponse response) {
        sendOptions(response);
    }
}
  • рядки 28–31: визначають контролер для URL [/getAllMedecins], коли його викликають за допомогою команди HTTP [OPTIONS];
  • рядок 29: метод [getAllMedecins] приймає як параметр об’єкт [HttpServletResponse], який буде надіслано клієнту, що зробив запит. Цей об’єкт вводиться Spring;
  • рядок 30: обробку запиту делегують приватному методу, описаному в рядках 19–25;
  • рядки 15–16: об’єкт [ApplicationModel] вводиться;
  • рядки 20–23: якщо сервер налаштований на прийом клієнтів, що не належать до його домену, то надсилається заголовок HTTP:

Access-Control-Allow-Origin: *

що означає, що сервер приймає клієнтів з будь-якого домену (*).

Тепер ми готові до нових тестів. Запускаємо нову версію веб-сервісу і виявляємо, що проблема залишається невирішеною. Нічого не змінилося. Якщо у рядку 30 вище встановити виведення на консоль, воно ніколи не відображається, що свідчить про те, що метод [getAllMedecins] у рядку 29 ніколи не викликається.

Після деяких досліджень ми з’ясували, що Spring MVC самостійно обробляє команди HTTP та [OPTIONS], застосовуючи обробку за замовчуванням. Тому відповідь завжди надає Spring, а не метод [getAllMedecins] у рядку 29. Цю поведінку Spring MVC за замовчуванням можна змінити. Ми вводимо новий клас конфігурації, щоб налаштувати нову поведінку:

  

Новий клас конфігурації [WebConfig] має такий вигляд:


package rdvmedecins.web.config;

import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.web.servlet.DispatcherServlet;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;

@Configuration
public class WebConfig extends WebMvcConfigurerAdapter {

    // конфігурація dispatcherservlet для заголовків CORS
    @Bean
    public DispatcherServlet dispatcherServlet() {
        DispatcherServlet servlet = new DispatcherServlet();
        servlet.setDispatchOptionsRequest(true);
        return servlet;
    }
}
  • рядок 8: клас є класом конфігурації Spring. Він оголошує біни, які будуть розміщені в контексті Spring;
  • рядок 12: bean [dispatcherServlet] слугує для визначення сервлету, який обробляє запити клієнтів. Він має тип [DispatcherServlet]. Цей сервлет зазвичай створюється за замовчуванням. Якщо ми створюємо його самостійно, то можемо його налаштувати;
  • рядок 14: створюється екземпляр типу [DispatcherServlet];
  • рядок 15: задаємо, щоб сервлет передавав до додатка команди HTTP та [OPTIONS];
  • рядок 16: налаштовуємо сервлет таким чином;

Залишається внести зміни до класу [AppConfig]:


package rdvmedecins.web.config;

import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Import;

import rdvmedecins.config.DomainAndPersistenceConfig;

@EnableAutoConfiguration
@ComponentScan(basePackages = { "rdvmedecins.web" })
@Import({ DomainAndPersistenceConfig.class, SecurityConfig.class, WebConfig.class })
public class AppConfig {

}
  • рядок 11: імпортується новий клас конфігурації [WebConfig];

3.7.6.7. Тестування додатка — 2

Запускаємо нову версію веб-сервісу / JSON і намагаємося отримати список лікарів за допомогою нашого клієнта Angular. Перевіряємо мережевий обмін даними у вкладці [Network]:

  • у [1] можна помітити, що заголовок HTTP [Access-Control-Allow-Origin: *] тепер присутній у відповіді сервера. І все ж це все ще не працює. Переглядаємо в [2] журнали консолі. Там ми знаходимо такий запис:
XMLHttpRequest cannot load http://localhost:8080/getAllMedecins. Поле заголовка запиту Authorization заборонено налаштуванням Access-Control-Allow-Headers

Видно, що браузер очікує на новий заголовок HTTP [Access-Control-Allow-Headers], який би вказував, що ми маємо право надіслати йому заголовок автентифікації:

Authorization:Basic code

Це може бути хорошим знаком. Можливо, Angular хотів надіслати запит HTTP GET. Але оскільки цей запит супроводжується заголовком автентифікації, він запитує, чи приймає сервер цей заголовок.

Ми змінюємо наш веб-сервер / JSON, щоб надіслати цей заголовок. Клас [RdvMedecinsCorsController] змінюється наступним чином:


    // надсилання опцій клієнту
    private void sendOptions(HttpServletResponse response) {
        if (application.isCORSneeded()) {
            // встановлюємо заголовок CORS
            response.addHeader("Access-Control-Allow-Origin", "*");
            // дозвіл на заголовок [Authorization]
            response.addHeader("Access-Control-Allow-Headers", "Authorization");            
}
  • у рядках 6–7 додано відсутній заголовок.

Ми перезапускаємо сервер і знову запитуємо список лікарів за допомогою клієнта Angular:

 

Цього разу все працює. У журналах консолі відображається відповідь, отримана методом [dao.getData]:


[dao] getData[/getAllMedecins] success réponse : {"data":{"status":0,"data":[{"id":1,"version":1,"titre":"Mme","nom":"PELISSIER","prenom":"Marie"},{"id":2,"version":1,"titre":"Mr","nom":"BROMARD","prenom":"Jacques"},{"id":3,"version":1,"titre":"Mr","nom":"JANDOT","prenom":"Philippe"},{"id":4,"version":1,"titre":"Melle","nom":"JACQUEMOT","prenom":"Justine"}]},"status":200,"config":{"method":"GET","transformRequest":[null],"transformResponse":[null],"timeout":1000,"url":"http://localhost:8080/getAllMedecins","headers":{"Accept":"application/json, text/plain, */*","Authorization":"Basic YWRtaW46YWRtaW4="}},"statusText":"OK"}

Бачимо, що:

  • сервер повернув код помилки [status=200] із повідомленням [statusText=OK]. Саме тому ми перебуваємо у функції [success];
  • сервер повернув об’єкт [data] із двома полями:
    • [status]: (не плутати з кодом помилки HTTP [status]). Тут [status=0] вказує, що URL та [/getAllMedecins] були оброблені без помилок;
    • [data]: містить список JSON лікарів;

Тепер розглянемо інші цікаві випадки:

Помилка в ідентифікаторах [login, password]:

Вхід здійснюється під ідентифікатором [user / user], який не має доступу до додатка (доступ має лише [admin]):

Цього разу помилка вже не стосується [Erreur d'authentification], а [Accès refusé].

3.7.7. Приклад 7: список клієнтів

Ми повернемося до попереднього додатка, щоб цього разу відобразити список клієнтів у випадаючому списку типу [Bootstrap select] (див. розділ 3.6.6).

3.7.7.1. Вигляд V

Початковий вигляд буде таким:

 

Щоб отримати вигляд V, ми дублюємо код [app-16.html] у [app-17.html] і змінюємо його наступним чином:


<div class="container" >
  <h1>Rdvmedecins - v1</h1>

  <!-- повідомлення про очікування -->
  <div class="alert alert-warning" ng-show="waiting.visible" >
...
  </div>

  <!-- запит -->
  <div class="alert alert-info" ng-hide="waiting.visible" >
...
    <button class="btn btn-primary" ng-click="execute()">{{clients.title|translate}}</button>
  </div>

  <!-- список клієнтів -->
  <div class="row" style="margin-top: 20px" ng-show="clients.show">
    <div class="col-md-3">
      <h2 translate="{{clients.title}}"></h2>
      <select data-style="btn-primary" class="selectpicker">
        <option ng-repeat="client in clients.data" value="{{client.id}}">
          {{client.titre}} {{client.prenom}} {{client.nom}}
        </option>
      </select>
    </div>
  </div>

  <!-- список помилок -->
  <div class="alert alert-danger"  ng-show="errors.show">
   ...
  </div>

</div>
....
<script type="text/javascript" src="rdvmedecins-05.js"></script>
  • рядки 5–7: смуга очікування не змінюється;
  • рядки 10–13: форма не змінюється, за винятком тексту кнопки (рядок 12);
  • рядки 28–30: панель помилок не змінюється;
  • рядки 16–25: клієнти відображаються у випадаючому списку, стиль якого задається компонентом [Bootstrap-selectpicker] (атрибути data-style, class, рядок 19);
  • рядок 20: для генерації різних опцій випадаючого списку використовується директива [ng-repeat]. Слід зауважити, що назва опції має тип [Mme Julienne Tatou], а значення опції — тип [100], де 100 — це ідентифікатор (id) клієнта, що відображається;
  • рядок 34: код JavaScript переміщується в новий файл [rdvmedecins-05];

3.7.7.2. Контролер C та модель M

Код JavaScript у файлі [rdvmedecins-05] отримано шляхом копіювання з файлу [rdvmedecins-04]:

Image

Практично нічого не змінилося, за винятком контролера, який тепер пристосований для надання списку клієнтів:


angular.module("rdvmedecins")
  .controller('rdvMedecinsCtrl', ['$scope', 'utils', 'config', 'dao', '$translate',
    function ($scope, utils, config, dao, $translate) {
      // ------------------- ініціалізація шаблону
      // шаблон
      $scope.waiting = {text: config.msgWaiting, visible: false, cancel: cancel, time: undefined};
      $scope.waitingTimeText = config.waitingTimeText;
      $scope.server = {url: undefined, login: undefined, password: undefined};
      $scope.clients = {title: config.listClients, show: false, model: {}};
      $scope.errors = {show: false, model: {}};
      $scope.urlServerLabel = config.urlServerLabel;
      $scope.loginLabel = config.loginLabel;
      $scope.passwordLabel = config.passwordLabel;

      // асинхронне завдання
      var task;

      // виконання дії
      $scope.execute = function () {
        // оновлюємо UI
        $scope.waiting.visible = true;
        $scope.clients.show = false;
        $scope.errors.show = false;
        // імітований стан очікування
        task = utils.waitForSomeTime($scope.waiting.time);
        var promise = task.promise;
        // очікування
        promise = promise.then(function () {
          // запит списку клієнтів;
          task = dao.getData($scope.server.url, $scope.server.login, $scope.server.password, config.urlSvrClients);
          return task.promise;
        });
        // аналізується результат попереднього виклику
        promise.then(function (result) {
          // result={err: 0, data: [client1, client2, ...]}
          // result={err: n, messages: [msg1, msg2, ...]}
          if (result.err == 0) {
            // отримані дані заносяться в модель
            $scope.clients.data = result.data;
            // оновлюємо UI
            $scope.clients.show = true;
            $scope.waiting.visible = false;
            // оформлюємо список, що розгортається
            $('.selectpicker').selectpicker();
          } else {
            // виникли помилки під час отримання списку клієнтів
            $scope.errors = { title: config.getClientsErrors, messages: utils.getErrors(result), show: true, model: {}};
            // оновлюємо UI
            $scope.waiting.visible = false;
          }
        });
      };

      // очікується скасування
      function cancel() {
        // завершується завдання
        task.reject();
        // оновлюється файл UI
        $scope.waiting.visible = false;
        $scope.clients.show = false;
        $scope.errors.show = false;
      }
    }
  ])
;
  • у контролері зміни мінімальні. Раніше він надавав список лікарів. Тепер він надає список клієнтів;
  • рядок 9: [$scope.clients] буде шаблоном панелі клієнтів у поданнях V;
  • рядок 30: тепер використовується URL [/getAllClients];
  • рядки 35–36: обидві форми відповіді, що повертаються методом [dao.getData]. Тепер замість лікарів ми маємо клієнтів;
  • рядок 44: досить рідкісна інструкція в коді Angular. Ми безпосередньо оперуємо DOM (Document Object Model). Тут ми хочемо застосувати метод [selectpicker] (що є частиною [bootstrap-select.min.js]) до елементів DOM, які мають класи [selectpicker] та [$('.selectpicker')]. Такий елемент є лише один — випадаючий список:

      <select data-style="btn-primary" class="selectpicker" select-enable="">
....
      </select>

У розділі 3.6.6 було показано, що це стилізує випадаючий список наступним чином:

Як і у випадку з лікарями, нам доводиться також внести зміни до веб-сервісу.

3.7.7.3. Зміна веб-сервісу — 1

  

Клас [RdvMedecinsController] доповнюється новим методом:


package rdvmedecins.web.controllers;

...

@Controller
public class RdvMedecinsCorsController {

    @Autowired
    private ApplicationModel application;

    // надсилання параметрів клієнту
    private void sendOptions(HttpServletResponse response) {
        if (application.isCORSneeded()) {
            // встановлення заголовка CORS
            response.addHeader("Access-Control-Allow-Origin", "*");
            // дозволяємо заголовок [Authorization]
            response.addHeader("Access-Control-Allow-Headers", "Authorization");
        }

    }

    // список лікарів
    @RequestMapping(value = "/getAllMedecins", method = RequestMethod.OPTIONS)
    public void getAllMedecins(HttpServletResponse response) {
        sendOptions(response);
    }

    // список клієнтів
    @RequestMapping(value = "/getAllClients", method = RequestMethod.OPTIONS)
    public void getAllClients(HttpServletResponse response) {
        sendOptions(response);
    }
}
  • рядки 29–32: метод [getAllClients] оброблятиме запит HTTP [OPTIONS], який надсилатиме йому браузер;

3.7.7.4. Тестування додатка – 1

Тепер ми готові до тестування. Запускаємо веб-сервер, а потім вводимо правильні значення у форму Angular. Отримуємо таку відповідь:

Image

Це повідомлення про помилку з’являється, коли Angular не зміг виконати запит HTTP. У такому разі причини слід шукати в журналах консолі. Там можна знайти таке повідомлення:

XMLHttpRequest cannot load http://localhost:8080/getAllClients. У запитуваному ресурсі відсутній заголовок «Access-Control-Allow-Origin». Тому доступу з джерела «http://localhost:63342» не дозволено.

Проблема, яку вважали вирішеною. Тож давайте подивимося на мережеві обміни, що відбулися:

Image

Бачимо, що операція [getAllClients] із методом HTTP [OPTIONS]пройшла успішно, але операція [getAllClients] із методом HTTP [GET] була скасована. Відповідь на запит [OPTIONS] була такою:

Image

Заголовки HTTP з CORS присутні. Тепер розглянемо обмін даними HTTP під час GET:

Image

Запит HTTP виглядає коректним. Зокрема, видно заголовок автентифікації.

Окрім попереднього повідомлення про помилку, у журналах консолі міститься таке повідомлення:


[dao] getData[/getAllClients] error réponse : {"data":"","status":0,"config":{"method":"GET","transformRequest":[null],"transformResponse":[null],"timeout":1000,"url":"http://localhost:8080/getAllClients","headers":{"Accept":"application/json, text/plain, */*","Authorization":"Basic YWRtaW46YWRtaW4="}},"statusText":""}

Це запис, який систематично створює метод [dao.getData] після отримання відповіді на свій запит HTTP. Можна відзначити дві речі:

  • [status=0]: це означає, що саме Angular скасував запит HTTP;
  • [method=GET]: а скасовано було саме запит GET;

Якщо поєднати це з першим повідомленням, виходить, що для запиту GET Angular також очікує на заголовки CORS. Однак наразі наш веб-сервіс надсилає їх лише для запитів HTTP та [OPTIONS]. Дуже дивно, що ця помилка виникає саме зараз, а не при запиті щодо списку лікарів. Я не маю пояснень.

Тому потрібно знову внести зміни до веб-сервісу.

3.7.7.5. Зміна веб-сервісу – 2

  

Методи [GET] та [POST] обробляються у класі [RdvMedecinsController]. Ми маємо змінити його, щоб ці методи надсилали заголовки CORS. Ми робимо це таким чином:


@RestController
public class RdvMedecinsController {

    @Autowired
    private ApplicationModel application;

    @Autowired
    private RdvMedecinsCorsController rdvMedecinsCorsController;

...

    // список клієнтів
    @RequestMapping(value = "/getAllClients", method = RequestMethod.GET)
    public Reponse getAllClients(HttpServletResponse response) {
        // заголовки CORS
        rdvMedecinsCorsController.getAllClients(response);
        // стан додатка
        if (messages != null) {
            return new Reponse(-1, messages);
        }
        // список клієнтів
        try {
            return new Reponse(0, application.getAllClients());
        } catch (Exception e) {
            return new Reponse(1, Static.getErreursForException(e));
        }
    }
...
  • рядок 8: ми хочемо повторно використати код, який ми розмістили в контролері [RdvMedecinsCorsController]. Тому вставляємо його сюди;
  • рядок 14: метод, що обробляє запит [GET /getAllClients]. Ми вносимо дві зміни:
    • рядок 14: ми вставляємо об’єкт [HttpServletResponse] у параметри методу,
    • рядок 16: використовуємо методи класу [RdvMedecinsCorsController], щоб помістити в цей об’єкт заголовки CORS;

3.7.7.6. Тестування додатка – 2

Запускаємо нову версію веб-сервісу та повторно запитуємо список клієнтів. Отримуємо таку відповідь:

  • у [1] відповідь є, але вона порожня [2];
  • у [3]: обмін даними через мережу пройшов успішно;

У журналах консолі метод [dao.getData] відобразив отриману відповідь:


[dao] getData[/getAllClients] success réponse : {"data":{"status":0,"data":[{"id":1,"version":1,"titre":"Mr","nom":"MARTIN","prenom":"Jules"},{"id":2,"version":1,"titre":"Mme","nom":"GERMAN","prenom":"Christine"},{"id":3,"version":1,"titre":"Mr","nom":"JACQUARD","prenom":"Jules"},{"id":4,"version":1,"titre":"Melle","nom":"BISTROU","prenom":"Brigitte"}]},"status":200,"config":{"method":"GET","transformRequest":[null],"transformResponse":[null],"timeout":1000,"url":"http://localhost:8080/getAllClients","headers":{"Accept":"application/json, text/plain, */*","Authorization":"Basic YWRtaW46YWRtaW4="}},"statusText":"OK"} 

Отже, метод успішно отримав список клієнтів. Після перевірки коду ми почали підозрювати наступну інструкцію, яку не дуже добре розуміємо:


// стиль випадаючого списку
$('.selectpicker').selectpicker();

Видаляємо рядок 2 як коментар і пробуємо ще раз. Отримуємо таку відповідь:

Отже, ми визначили місце розташування проблеми. Проблема полягає у застосуванні методу [selectpicker] до випадаючого списку. Якщо подивитися на вихідний код сторінки, на якій виникає помилка, бачимо таке:

  • виявляється, що в [1] випадаючий список із його елементами дійсно присутній, але не відображається [style='display:none'];
  • у [2] бачимо, що кнопка [bootstrap select] відображається. Елементи випадаючого списку повинні з’являтися у списку <ul role='menu'>. Їх там немає, тому ми маємо порожній список. Схоже, що коли до випадаючого списку було застосовано метод [selectpicker], його вміст на той момент був порожнім;

Переглядаючи Інтернет у пошуках рішення, ми знайшли ось таке. Замінюємо код:


// оформлення випадаючого списку
$('.selectpicker').selectpicker();

на такий:


            // оформлення випадаючого списку
            $timeout(function(){
              $('.selectpicker').selectpicker();
});

Стиль [bootstrap-select] застосовується за допомогою функції [$timeout]. Ми вже зустрічали цю функцію, яка дозволяє виконати іншу функцію через певний проміжок часу. У даному випадку відсутність затримки означає нульову затримку. Попередні рядки додають подію до черги подій браузера. Коли обробка поточної події (натискання кнопки [Liste des clients]) завершиться, буде відображено вікно V. Одразу після цього браузер перевірить свій список подій. Завдяки нульовій затримці подія [$timeout] опиниться на початку списку та буде оброблена. Стиль [bootstrap-select] буде застосовано до заповненого випадаючого списку. Подивімося на результат:

Якщо знову подивитися на вихідний код відображеної сторінки, ми побачимо таке:

Кнопка [bootstrap-select], яка раніше була порожньою, тепер містить список клієнтів.

3.7.7.7. Використання директиви

У контролері C подання V ми знайшли такий код:


            // оформлення випадаючого списку
            $('.selectpicker').selectpicker();

Ми оперуємо об’єктом DOM. Багато розробників Angular не люблять оперувати DOM у коді контролера. На їхню думку, це слід робити у директиві. Директиву Angular можна розглядати як розширення мови HTML. Таким чином, можна створювати нові елементи або атрибути HTML. Розглянемо перший приклад:

Створюємо такий файл JS [selectEnable]:


angular.module("rdvmedecins").directive('selectEnable', ['$timeout', function ($timeout) {
  return {
    link: function (scope, element, attrs) {
      $timeout(function () {
        var selectpicker = $('.selectpicker');
        selectpicker.selectpicker();
      });
    }
  };
}]);
  • директива відповідає синтаксису контролера, до якого ми вже звикли:

angular.module("rdvmedecins").directive('selectEnable', ['$timeout', function ($timeout)

Директива належить до модуля [rvmedecins]. Це функція, яка приймає два параметри:

  • (продовження)
    • перший — це назва директиви [selectEnable];
    • другий — це масив ['obj1','obj2',..., function(obj1, obj2,...)], де [obj] — це об’єкти, які потрібно передати у функцію. Тут єдиним переданим об’єктом є попередньо визначений об’єкт [$timeout];
  • функція [directive] повертає об’єкт, який може мати різні атрибути. Тут єдиним атрибутом є атрибут [link] (рядок 3). Його значенням тут є функція, що приймає три параметри:
    • scope — шаблон подання, у якому використовується директива;
    • element: елемент подання, об’єкт директиви;
    • attrs: атрибути цього елемента;

Розглянемо приклад. Директива [selectEnable] може використовуватися в такому контексті:

<div select-enable="data"></div>

У наведеному вище прикладі атрибут [select-enable] застосовує директиву [selectEnable] до елемента HTML <div>. Директиву [doSomething] можна застосувати до будь-якого елемента HTML, додавши до нього атрибут [do-something]. Слід звернути увагу на зміну написання між назвою директиви та пов’язаним з нею атрибутом. Написання змінюється з [camelCase] на [camel-case].

Директиву [selectEnable] також можна використовувати таким чином:

<select-enable attr1='val1' attr2='val2' ...>...</select-enable>

Тут директива [doSomething] застосовується у вигляді тегу HTML <do-something>.

Повернемося до запису

<div select-enable="data"></div>

та до трьох параметрів функції [link] директиви [scope, element, attrs]:

  • scope: це шаблон подання, в якому знаходиться <div>;
  • element — це сама тег <div>;
  • attrs: масив атрибутів тегу <div>. Вони можуть використовуватися для передачі інформації директиві. У наведеному вище прикладі ми запишемо attrs['selectEnable'], щоб отримати інформацію [data]. Зверніть увагу на зміну запису [selectEnable] для позначення атрибута [select-enable];

Повернемося до коду директиви:


angular.module("rdvmedecins").directive('selectEnable', ['$timeout', function ($timeout) {
  return {
    link: function (scope, element, attrs) {
      $timeout(function () {
        $('.selectpicker').selectpicker();
      });
    }
  };
}]);
  • рядки 14–16: тут ми бачимо код, який раніше розмістили в контролері. Він виконується при виявленні директиви [select-enable] (у вигляді елемента або атрибута) під час відображення подання V.

Щоб реалізувати цю директиву, скопіюємо файл [app-17.html] у файл [app-17B.html] і змінимо його наступним чином:


      <select data-style="btn-primary" class="selectpicker" select-enable="">
        <option ng-repeat="client in clients.data" value="{{client.id}}">
          {{client.titre}} {{client.prenom}} {{client.nom}}
        </option>
</select>
  • рядок 1: застосовуємо директиву [selectEnable] до елемента HTML [select]. Оскільки директиві не потрібно передавати жодних даних, ми просто записуємо [select-enable=""];

Ми також модифікуємо контролер, дублюючи файл JS [rdvmedecins-05.js] у [rdvmedecins-05B.js] і вказуємо посилання на новий файл JS у файлі директиви [app-17B.html] та файл директиви [selectEnable.js]. Не слід забувати про цей останній пункт. Якщо файл директиви відсутній, атрибут [select-enable=""] не буде оброблятися, але Angular не повідомить про жодну помилку.


<script type="text/javascript" src="rdvmedecins-05B.js"></script>
<script type="text/javascript" src="selectEnable.js"></script>

У файлі JS [rdvmedecins-05B.js] ми видаляємо з контролера такі рядки:


            // оформлення випадаючого списку
            $timeout(function(){
              $('.selectpicker').selectpicker();
});

оскільки цю операцію тепер виконує директива.

3.7.7.8. Тестування додатка – 3

Під час тестування нового додатка [app-17B.html] отримуємо такий результат:

  • у [1] отримуємо порожній список.

У журналах консолі відображається наступне:

1
2
3
[dao] init
directive selectEnable
[dao] getData[/getAllClients] success réponse : {"data":{"status":0,"data":[{"id":1,"version":1,"titre":"Mr","nom":"MARTIN","prenom":"Jules"},{"id":2,"version":1,"titre":"Mme","nom":"GERMAN","prenom":"Christine"},{"id":3,"version":1,"titre":"Mr","nom":"JACQUARD","prenom":"Jules"},{"id":4,"version":1,"titre":"Melle","nom":"BISTROU","prenom":"Brigitte"}]},"status":200,"config":{"method":"GET","transformRequest":[null],"transformResponse":[null],"timeout":1000,"url":"http://localhost:8080/getAllClients","headers":{"Accept":"application/json, text/plain, */*","Authorization":"Basic YWRtaW46YWRtaW4="}},"statusText":"OK"}
  • рядок 1: ініціалізація служби [dao];
  • рядок 2: під час початкового відображення подання V виконується директива [selectEnable];
  • рядок 3: цей рядок з’являється, коли користувач натискає кнопку [Liste des clients]. Отже, можна констатувати, що директива [selectEnable] не виконується вдруге. У підсумку вона була виконана, коли список клієнтів був порожнім, і тому ми маємо порожній випадаючий список;

Іншими словами, операція:


$('.selectpicker').selectpicker();

не відбулася в потрібний момент. Проблему можна спробувати вирішити різними способами. Після численних невдалих тестів ми розуміємо, що зазначена вище операція має відбуватися лише один раз і виключно тоді, коли список, що розгортається, вже заповнено. Щоб досягти цього результату, ми переписуємо тег <select> наступним чином:


      <select data-style="btn-primary" class="selectpicker" select-enable="" ng-if="clients.data">
        <option ng-repeat="client in clients.data" value="{{client.id}}">
          {{client.titre}} {{client.prenom}} {{client.nom}}
        </option>
</select>

У рядку 1 тег <select> генерується лише за умови існування [clients.data]. Під час початкового відображення подання V цього не відбувається. Отже, тег <select> не буде згенеровано, а директива [selectEnable] не буде обчислена. Коли користувач натисне кнопку [Liste des clients], [clients.data] отримає нове значення в шаблоні M. Оскільки шаблон M змінився, тег <select> буде переоцінено і в цьому випадку згенеровано. Отже, директива [selectEnable] також буде обчислена. На момент її обчислення рядки 2–4 тегу <select> ще не були обчислені. Тому ми маємо порожній список клієнтів. Якщо написати директиву [selectEnable] наступним чином:


angular.module("rdvmedecins").directive('selectEnable', ['$timeout', 'utils', function ($timeout, utils) {
  return {
    link: function (scope, element, attrs) {
      utils.debug("directive selectEnable");
      $('.selectpicker').selectpicker();
    }
  }
}]);

рядок 5 буде виконано з порожнім списком, і на екрані з’явиться порожній випадаючий список. Тому слід написати:


angular.module("rdvmedecins").directive('selectEnable', ['$timeout', 'utils', function ($timeout, utils) {
  return {
    link: function (scope, element, attrs) {
      utils.debug("directive selectEnable");
      $timeout(function () {
        $('.selectpicker').selectpicker();
      })
    }
  }
}]);

щоб отримати очікуваний результат. Через наявність [$timeout] у рядку 5 рядок 6 буде виконано лише після повної оцінки подання V, тобто в той момент, коли тег <select> матиме всі свої елементи.

3.7.8. Приклад 8: розклад прийомів лікаря

Тепер ми розглянемо додаток, що відображає розклад лікаря.

3.7.8.1. Вигляд V додатка

Ми розглянемо таку форму:

  • у [1] запитується розклад пані PELISSIER [2] на 25 червня 2014 року [3];

Отримуємо такий результат: [4]:

Ми розглянемо обидва види окремо.

3.7.8.2. Форма

Ми дублюємо файл [app-17.html] у файл [app-18.html], а потім змінюємо код наступним чином:


<div class="container">
  <h1>Rdvmedecins - v1</h1>

  <!-- повідомлення про очікування -->
  <div class="alert alert-warning" ng-show="waiting.visible">
    ...
  </div>

  <!-- запит -->
  <div class="alert alert-info" ng-hide="waiting.visible">
    <div class="row" style="margin-bottom: 20px">
      <div class="col-md-3">
        <h2 translate="{{medecins.title}}"></h2>
        <select data-style="btn-primary" class="selectpicker">
          <option ng-repeat="medecin in medecins.data" value="{{medecin.id}}">
            {{medecin.titre}} {{medecin.prenom}} {{medecin.nom}}
          </option>
        </select>
      </div>
      <div class="col-md-3">
        <h2 translate="{{calendar.title}}"></h2>
        <div style="display:inline-block; min-height:290px;">
          <datepicker ng-model="calendar.jour" min-date="calendar.minDate" show-weeks="true"
                      class="well well-sm"></datepicker>
        </div>
      </div>
    </div>
    <button class="btn btn-primary" ng-click="execute()">{{agenda.title|translate}}</button>
  </div>

  <!-- список помилок -->
  <div class="alert alert-danger" ng-show="errors.show">
...
  </div>

  <!-- календар -->
  <div id="agenda" ng-show="agenda.show">
...
  </div>
</div>
...
<script type="text/javascript" src="rdvmedecins-06.js"></script>
  • рядки 5–7: повідомлення про очікування не змінюється;
  • рядки 12–19: список лікарів типу [bootstrap select];
  • рядки 20–26: календар [ui-bootstrap], який ми вже розглядали. Зверніть увагу, що вибраний день розміщується в шаблоні [calendar.jour] (атрибут ng-model);
  • рядок 28: кнопка, що викликає календар;
  • рядки 32–34: список помилок не змінюється;
  • рядки 37–39: календар, який ми розглянемо пізніше;
  • рядок 42: код JS переноситься у файл [rdvmedecins-06.js] шляхом копіювання з файлу [rdvmedecins-05.js];

3.7.8.3. Контролер C

Код JS додатка набуває такого вигляду:

Image

Зміни торкнуться лише сервісу [utils] та контролера [rdvMedecinsCtrl].

Контролер [rdvMedecinsCtrl] набуває такого вигляду:


// контролер
angular.module("rdvmedecins")
  .controller('rdvMedecinsCtrl', ['$scope', 'utils', 'config', 'dao', '$translate', '$timeout', '$filter', '$locale',
    function ($scope, utils, config, dao, $translate, $timeout, $filter, $locale) {
      // ------------------- ініціалізація шаблону
      // шаблон
      $scope.waiting = {text: config.msgWaiting, visible: false, cancel: cancel, time: 3000};
      $scope.server = {url: 'http://localhost:8080', login: 'admin', password: 'admin'};
      $scope.errors = {show: false, model: {}};
      $scope.medecins = {
        data: [
          {id: 1, version: 1, titre: "Mme", nom: "PELISSIER", prenom: "Marie"},
          {id: 2, version: 1, titre: "Mr", nom: "BROMARD", prenom: "Jacques"},
          {id: 3, version: 1, titre: "Mr", nom: "JANDOT", prenom: "Philippe"},
          {id: 4, version: 1, titre: "Melle", nom: "JACQUEMOT", prenom: "Justine"}
        ],
        title: config.listMedecins};
      $scope.agenda = {title: config.getAgendaTitle, data: undefined, show: false};
      $scope.calendar = {title: config.getCalendarTitle, minDate: new Date(), jour: new Date()};
      // оформлення списку, що розгортається
      $timeout(function () {
        $('.selectpicker').selectpicker();
      });
      // французька локаль для календаря
      angular.copy(config.locales['fr'], $locale);
 ...
    }
  ])
;
  • рядок 7: встановлюється час очікування 3 секунди перед викликом HTTP;
  • рядок 8: жорстко задаються елементи, необхідні для з'єднання з HTTP;
  • рядки 10–17: жорстко задається список лікарів;
  • рядок 18: шаблон [agenda] налаштовує відображення календаря у вікні;
  • рядок 19: шаблон [calendar] налаштовує відображення календаря у вікні. Мінімальну дату [minDate] встановлюємо на сьогодні, а поточну дату — також на сьогодні;
  • рядки 21–23: стиль випадаючого списку задається за допомогою методу, розглянутого раніше;
  • рядок 25: встановлюємо локаль додатка на «fr». За замовчуванням вона встановлена на «en»;

Метод, що виконується під час запиту календаря, є таким:


// виконання дії
      $scope.execute = function () {
        // дані форми
        var idMedecin = $('.selectpicker').selectpicker('val');

        // перевірка
        utils.debug("[homeCtrl] idMedecin", idMedecin);
        utils.debug("[homeCtrl] jour", $scope.calendar.jour);

        // форматування дати у форматі рррр-ММ-дд
        var formattedJour = $filter('date')($scope.calendar.jour, 'yyyy-MM-dd');
        // оновлення вікна
        $scope.waiting.visible = true;
        $scope.errors.show = false;
        $scope.agenda.show = false;
...
      };
  • рядок 4: отримуємо атрибут [value] вибраного лікаря. Тут знову використовується метод [selectpicker], який походить із файлу [bootstrap-select.min.js]. Слід пам’ятати про формат опцій випадаючого списку:

          <option ng-repeat="medecin in medecins.data" value="{{medecin.id}}">
            {{medecin.titre}} {{medecin.prenom}} {{medecin.nom}}

Отже, значенням (атрибутом value) опції є ідентифікатор лікаря [id].

  • рядок 11: переводимо день, обраний користувачем, у формат [aaaa-mm-jj], який є форматом дати, очікуваним веб-сервером;
  • рядки 13–15: коли метод [execute] завершиться, буде відображено смугу очікування, а все інше — приховано;

Далі код виглядає так:


// імітація очікування
        var task = utils.waitForSomeTime($scope.waiting.time);
        // запит на розклад прийомів лікаря
        var promise = task.promise.then(function () {
          // шлях до URL служби
          var path = config.urlSvrAgenda + "/" + idMedecin + "/" + formattedJour;
          // запит розкладу
          task = dao.getData($scope.server.url, $scope.server.login, $scope.server.password, path);
          // повертається підтвердження про завершення завдання
          return task.promise;
        });
        // аналізується результат виклику служби [dao]
        promise.then(function (result) {
          // завершення очікування
          $scope.waiting.visible = false;
          // помилка?
          if (result.err == 0) {
            // готується шаблон календаря
            $scope.agenda.data = result.data;
            $scope.agenda.show = true;
            // форматування відображення розкладу
            angular.forEach($scope.agenda.data.creneauxMedecin, function (creneauMedecin) {
              creneauMedecin.creneau.text = utils.getTextForCreneau(creneauMedecin.creneau);
            });
            // створюється подія для стилізації таблиці після відображення подання
            $timeout(function () {
              $("#creneaux").footable();
            });
          } else {
            // сталися помилки під час отримання календаря
            $scope.errors = {
              title: config.getAgendaErrors,
              messages: utils.getErrors(result),
              show: true
            };
}
  • рядок 2: асинхронне завдання очікування тривалістю 3 секунди;
  • рядки 5–10: код, який буде виконано після завершення цього очікування;
  • рядок 6: створюється запит до URL, який запитує [/getAgendaMedecinJour/1/2014-06-25];
  • рядок 8: запитується URL. Запускається асинхронне завдання;
  • рядок 10: виконується обіцянка цього асинхронного завдання;
  • рядки 14–38: код, який буде виконано, коли виклик HTTP поверне свою відповідь;
  • рядок 13: [result] — це відповідь, надіслана методом [dao.getData]. Тут слід пам’ятати про формат відповіді веб-сервера:

Параметр [result.data] у рядку 19 є атрибутом [data] [1], наведеним вище. Цей атрибут, у свою чергу, містить атрибут [creneauxMedecin] [2], наведений вище. Він являє собою масив часових проміжків, кожен із яких містить дві інформації:

  • [rv]: форма JSON зустрічі або [null], якщо у цьому часовому проміжку не призначено жодної зустрічі;
  • [hDeb, mDeb, hFin, mFin]: інформація про час інтервалу;

Повернемося до коду диспетчера:

  • рядок 15: очікування завершено;
  • рядок 19: заповнюється шаблон [$scope.agenda], який керує відображенням календаря;
  • рядок 20: календар стає видимим;
  • рядки 22–24: виконується обхід кожного елемента C масиву [creneauxMedecin], про який ми щойно говорили;
  • рядок 23: кожен елемент C має атрибут [creneau], який є часовим проміжком. Він доповнюється атрибутом [text], який буде текстовим представленням часового проміжку у формі [10h20:10h40];
  • рядки 26–28: ми робимо таблицю HTML, яка використовується для відображення часових проміжків у календарі, «адаптивною». Ми розглядали це поняття в розділі 3.6.7;
 
  • рядок 27: щоб зробити таблицю «адаптивною», потрібно застосувати до неї метод [footable]. Тут ми стикаємося з тією самою проблемою, що й у випадку з компонентом [bootstrap-select]. Якщо просто написати рядок 17, то можна помітити, що таблиця не є «адаптивною». Цю проблему вирішують таким самим чином за допомогою функції [$timeout] (рядок 26);
  • рядки 31–34: випадок, коли виклик HTTP завершився невдало. У такому разі виводяться повідомлення про помилки;

3.7.8.4. Відображення календаря

Тепер повернемося до коду календаря у файлі [app-18.html]. Він виглядає так:


<!-- календар -->
  <div id="agenda" ng-show="agenda.show">
    <!-- випадок лікаря без вільних слотів для прийому -->
    <h4 class="alert alert-danger" ng-if="agenda.data.creneauxMedecin.length==0"
        translate="agenda_medecinsanscreneaux"></h4>
    <!-- розклад лікаря -->
    <div class="row tab-content alert alert-warning" ng-if="agenda.data.creneauxMedecin.length!=0">
      <div class="tab-pane active col-md-6">
        <table creneaux-table id="creneaux" class="table">
          <thead>
          <tr>
            <th data-toggle="true">
              <span translate="agenda_creneauhoraire"></span>
            </th>
            <th>
              <span translate="agenda_client">Client</span>
            </th>
            <th data-hide="phone">
              <span translate="agenda_action">Action</span>
            </th>
          </tr>
          </thead>
          <tbody>
          <tr ng-repeat="creneauMedecin in agenda.data.creneauxMedecin">
            <td>
            <span
              ng-class="! creneauMedecin.rv ? 'status-metro status-active' : 'status-metro status-suspended'">
              {{creneauMedecin.creneau.text}}
            </span>
            </td>
            <td>
              <span>{{creneauMedecin.rv.client.titre}} {{creneauMedecin.rv.client.prenom}} {{creneauMedecin.rv.client.nom}}</span>
            </td>
            <td>
              <a href="" ng-if="!creneauMedecin.rv" translate="agenda_reserver" class="status-metro status-active">
              </a>
              <a href="" ng-if="creneauMedecin.rv" translate="agenda_supprimer" class="status-metro status-suspended">
              </a>
            </td>
          </tr>
          </tbody>
        </table>
      </div>
    </div>
</div>
  • рядки 4–5: нагадаємо, що [agenda.data] — це календар, а [agenda.data.creneauxMedecin] — масив об’єктів типу [creneauMedecin]. Кожен елемент останнього типу має атрибут [creneauMedecin.creneau], який є часовим проміжком. Кожен часовий проміжок має два елементи, що нас цікавлять:
    • [creneauMedecin.creneau.rv] — це можливий RV (rv!=null), взятий з цього часового проміжку;
    • [creneauMedecin.creneau.text] — це текст [début:fin] для цього часового проміжку;
  • рядок 4: відображає спеціальне повідомлення, якщо у лікаря немає часових проміжків. Це малоймовірно, але трапляється, що наша база даних є неповною, і такий випадок існує. Генерація повідомлення HTML або його відсутність контролюється директивою [ng-if];

Image

Директива [ng-if] відрізняється від директив [ng-show, ng-hide]. Останні лише приховують область, присутню в документі. Якщо [ng-if='false'], то область видаляється з документа. Ми використали її тут для ілюстрації;

  • рядок 9: атрибут [id='creneaux'] є важливим. Саме він використовується в інструкції:

$("#creneaux").footable();
  • рядки 10–22: відображають заголовки таблиці [1];
  • рядки 23–45: відображають вміст таблиці [2];
  • рядок 24: відбувається обхід масиву [agenda.data.creneauxMedecin];
  • рядки 26–29: записується текст [3]. Використовується директива [ng-class], яка згенерує атрибут [class] елемента. Якщо тут є [creneauMedecin.rv==null], це означає, що слот вільний, і текст виділяється зеленим фоном. В іншому випадку — червоним;
  • рядок 32: записується ім’я клієнта, для якого було заброньовано RV або [4]. Якщо [rv==null], ця інформація відсутня, але Angular правильно обробляє цей випадок і не повідомляє про помилку;
  • рядки 34–39: відображають одну з двох кнопок — [Réserver] або [Supprimer]. Вибір тієї чи іншої кнопки залежить від наявності або відсутності зустрічі;

3.7.8.5. Модифікація веб-сервера

Як і в попередніх прикладах, веб-сервер потрібно змінити, щоб URL [/getAgendaMedecinJour] надсилав заголовки CORS:

  

У класі [RdvMedecinsCorsController] додаємо новий метод:


    // розклад прийому лікаря
    @RequestMapping(value = "/getAgendaMedecinJour/{idMedecin}/{jour}", method = RequestMethod.OPTIONS)
    public void getAgendaMedecinJour(HttpServletResponse response) {
        sendOptions(response);
}

Цей метод надсилатиме заголовки CORS для запитів HTTP та [OPTIONS]. Те саме потрібно зробити для запитів HTTP та [GET] у класі [RdvMedecinsController]:


@RequestMapping(value = "/getAgendaMedecinJour/{idMedecin}/{jour}", method = RequestMethod.GET)
    public Reponse getAgendaMedecinJour(@PathVariable("idMedecin") long idMedecin, @PathVariable("jour") String jour, HttpServletResponse response) {
        // заголовки CORS
        rdvMedecinsCorsController.getAgendaMedecinJour(response);
...
}

3.7.8.6. Використання директив

Як і раніше, ми перенесемо обробку DOM у директиви. У нас є дві операції з DOM:

  • під час початкового відображення подання:

      // оформлення випадаючого списку
      $timeout(function () {
        $('.selectpicker').selectpicker();
});
  • під час відображення календаря:

            // створення події для оформлення таблиці після відображення подання
            $timeout(function () {
              $("#creneaux").footable();
});

У першому випадку ми використаємо директиву [selectEnable], яку вже розглядали. У другому випадку ми створюємо директиву [footable] у наступному файлі JS [footable.js]:


angular.module("rdvmedecins").directive('footable', ['$timeout', 'utils', function ($timeout, utils) {
  return {
    link: function (scope, element, attrs) {
      utils.debug("directive footable");
      $timeout(function () {
        $("#creneaux").footable();
      })
    }
  }
}]);

Отже, ми використовуємо ту саму техніку, що й для директиви [selectEnable].

Код HTML [app-18.html] дублюється у [app-18B.html]. Потім його модифікуємо наступним чином:


        <select data-style="btn-primary" class="selectpicker" select-enable="">
          <option ng-repeat="medecin in medecins.data" value="{{medecin.id}}">
            {{medecin.titre}} {{medecin.prenom}} {{medecin.nom}}
          </option>
</select>
  • рядок 1: застосовуємо директиву [selectEnable] (через атрибут [select-enable]) до тегу <select> лікарів;

    <div class="row tab-content alert alert-warning" ng-if="agenda.data.creneauxMedecin.length!=0">
      <div class="tab-pane active col-md-6">
        <table id="creneaux" class="table" footable="">
          <thead>
<tr>
  • рядок 3: застосовується директива [footable] (через атрибут [footable]) до таблиці HTML календаря;

<script type="text/javascript" src="rdvmedecins-06B.js"></script>
<!-- інструкції -->
<script type="text/javascript" src="selectEnable.js"></script>
<script type="text/javascript" src="footable.js"></script>
  • рядки 3–4: вказуються посилання на файли JS обох директив;
  • рядок 1: код JS з файлу [app-18B.html] є кодом JS з файлу [app-18.html], дубльованим у файлі [rdvmedecins-06B.js];

Файл [rdvmedecins-06B.js] ідентичний файлу [rdvmedecins-06.js], за винятком двох деталей. Рядки, що стосуються DOM, зникають:


      // оформлення випадаючого списку
      $timeout(function () {
        $('.selectpicker').selectpicker();
});

            // створення події для оформлення таблиці після відображення подання
            $timeout(function () {
              $("#creneaux").footable();
});

Таким чином, виконання програми [app-18B.html] дає ті самі результати, що й виконання [app-18.html].

3.7.9. Приклад 9: створення та скасування бронювань

Тепер ми розглянемо додаток, що дозволяє створювати та скасовувати бронювання.

3.7.9.1. Вигляд V додатка

Ми представимо таку форму:

  • у [1] можна буде зробити бронювання. Бронювання буде зроблено для випадкового клієнта;
  • на сторінці [2] можна буде скасувати бронювання, які ми зробили;

Ми дублюємо файл [app-18.html] у [app-19.html], а потім змінюємо код наступним чином:


<div class="container">
  <h1>Rdvmedecins - v1</h1>

  <!-- повідомлення про очікування -->
  <div class="alert alert-warning" ng-show="waiting.visible">
  ...
  </div>

  <!-- список помилок -->
  <div class="alert alert-danger" ng-show="errors.show">
...
  </div>

  <!-- розклад -->
  <div id="agenda" ng-show="agenda.show">
..
    <!-- розклад лікаря -->
    <div class="row tab-content alert alert-warning" ng-if="agenda.data.creneauxMedecin.length!=0">
      <div class="tab-pane active col-md-6">
        <table id="creneaux" class="table" footable="">
...
          <tbody>
          <tr ng-repeat="creneauMedecin in agenda.data.creneauxMedecin">
...
            <td>
              <a href="" ng-if="!creneauMedecin.rv" translate="agenda_reserver" class="status-metro status-active"  ng-click="reserver(creneauMedecin.creneau.id)">
              </a>
              <a href="" ng-if="creneauMedecin.rv" translate="agenda_supprimer" class="status-metro status-suspended" ng-click="supprimer(creneauMedecin.rv.id)">
              </a>
            </td>
          </tr>
          </tbody>
        </table>
      </div>
    </div>
  </div>
</div>
....
<script type="text/javascript" src="rdvmedecins-07.js"></script>
<script type="text/javascript" src="footable.js"></script>
  • рядки 5–7: повідомлення про очікування — те саме, що й у попередній версії;
  • рядки 10–12: повідомлення про помилки — те саме, що й у попередній версії;
  • рядки 15–36: календар такий самий, як у попередній версії, за винятком двох деталей:
    • рядок 26: натискання на кнопку [réserver] (атрибут ng-click) обробляється методом [reserver] моделі M подання V. Йому передається номер часового проміжку бронювання;
    • рядок 26: натискання на кнопку [supprimer] обробляється методом [reserver] моделі M подання V. Йому передається номер зустрічі, яку потрібно видалити;
  • рядок 39: код JS, який керує додатком, міститься у файлі [rdvmedecins-07.js];
  • рядок 40: код JS з директиви [footable], застосованої в рядку 20;

3.7.9.2. Контролер C

Код JS з [rdvmedecins-07.js] спочатку отримується шляхом копіювання з файлу [rdvmedecins-06.js]. Потім його модифікують. Як і раніше, присутні звичні великі блоки коду. Зміни в основному вносяться в контролер:

Image

Ми опишемо контролер C для подання V у кілька етапів.

3.7.9.3. Ініціалізація контролера C

Код ініціалізації контролера виглядає так:


angular.module("rdvmedecins")
  .controller('rdvMedecinsCtrl', ['$scope', 'utils', 'config', 'dao', '$translate', '$timeout', '$filter', '$locale',
    function ($scope, utils, config, dao, $translate, $timeout, $filter, $locale) {
      // ------------------- ініціалізація шаблону
      // шаблон
      $scope.waiting = {text: config.msgWaiting, visible: false, cancel: cancel, time: 3000};
      $scope.server = {url: 'http://localhost:8080', login: 'admin', password: 'admin'};
      $scope.errors = {show: false, model: {}};
      $scope.medecins = {
        data: [
          {id: 1, version: 1, titre: "Mme", nom: "PELISSIER", prenom: "Marie"},
          {id: 2, version: 1, titre: "Mr", nom: "BROMARD", prenom: "Jacques"},
          {id: 3, version: 1, titre: "Mr", nom: "JANDOT", prenom: "Philippe"},
          {id: 4, version: 1, titre: "Melle", nom: "JACQUEMOT", prenom: "Justine"}
        ],
        title: config.listMedecins
      };
      var médecin = $scope.medecins.data[0];
      var clients = [
        {id: 1, version: 1, titre: "Mr", nom: "MARTIN", prenom: "Jules"},
        {id: 2, version: 1, titre: "Mme", nom: "GERMAN", prenom: "Christine"},
        {id: 3, version: 1, titre: "Mr", nom: "JACQUARD", prenom: "Maurice"},
        {id: 4, version: 1, titre: "Melle", nom: "BISTROU", prenom: "Brigitte"}
      ];
      // французький формат дати
      angular.copy(config.locales['fr'], $locale);
      var today = new Date();
      var formattedDay = $filter('date')(today, 'yyyy-MM-dd');
      var fullDay = $filter('date')(today, 'fullDate');
      $scope.agenda = {title: config.agendaTitle, data: undefined, show: false, model: {titre: médecin.titre, prenom: médecin.prenom, nom: médecin.nom, jour: fullDay}};


      // ---------------------------------------------------------------- початковий календар
      // глобальне асинхронне завдання
      var task;
      // запит на календар
      getAgenda();

      // ------------------------------------------------------------------ бронювання
      $scope.reserver = function (creneauId) {
....
      };

      // ------------------------------------------------------------ видалення RV
      $scope.supprimer = function (idRv) {
...
      };

      // отримання календаря
      function getAgenda() {
 ...
      }

      // скасування очікування
      function cancel() {
...
      }
} ]);
  • рядок 6: налаштування повідомлення очікування. За замовчуванням очікування триватиме 3 секунди перед виконанням виклику HTTP;
  • рядок 7: інформація, необхідна для викликів HTTP;
  • рядок 8: налаштування повідомлення про помилки;
  • рядки 9–17: фіксований список лікарів;
  • рядок 18: приватний лікар. Саме на його прийомні години будуть здійснюватися записи;
  • рядки 19–24: постійні клієнти;
  • рядок 26: потрібно вносити зміни у французькі дати;
  • рядок 27: зустрічі призначатимуться на сьогоднішню дату;
  • рядок 28: веб-сервіс бронювання очікує дати у форматі «aaaa-mm-jj»;
  • рядок 29: сьогоднішня дата у форматі [jeudi 26 juin 2014];
  • рядок 30: налаштування календаря. Атрибут [model] передає параметри інтернаціоналізованого повідомлення, яке буде відображено:

        agenda_title: "Agenda de {{titre}} {{prenom}} {{nom}} le {{jour}}"
  • рядок 35: глобальна змінна [task] у певний момент часу представляє асинхронне завдання, що виконується;
  • рядок 37: запитується початковий календар;

Це все, що відбувається під час початкового завантаження сторінки. Якщо все проходить успішно, на екрані відображається розклад на сьогодні пані PELISSIER.

Image

3.7.9.4. Отримання календаря

Розклад отримується за допомогою наступного методу [getAgenda]:


      // отримання календаря
      function getAgenda() {
        // шлях до сервісу URL
        var path = config.urlSvrAgenda + "/" + médecin.id + "/" + formattedDay;
        // запит на календар
        task = dao.getData($scope.server.url, $scope.server.login, $scope.server.password, path);
        // повідомлення про очікування
        $scope.waiting.visible = true;
        // аналізується результат виклику служби [dao]
        task.promise.then(function (result) {
          // завершення очікування
          $scope.waiting.visible = false;
          // помилка?
          if (result.err == 0) {
            // готується шаблон календаря
            $scope.agenda.data = result.data;
            $scope.agenda.show = true;
            // форматування відображення розкладу
            angular.forEach($scope.agenda.data.creneauxMedecin, function (creneauMedecin) {
              creneauMedecin.creneau.text = utils.getTextForCreneau(creneauMedecin.creneau);
            });
          } else {
            // сталися помилки під час отримання розкладу
            $scope.errors = {title: config.getAgendaErrors, messages: utils.getErrors(result), show: true};
          }
        });
}

Цей код є тим самим, що розглядався в попередньому додатку. Є дві зміни:

  • немає імітованого очікування перед викликом HTTP;
  • рядок 4: використовується лікар, створений під час ініціалізації контролера, а також відформатований день, що був побудований;

Цей код було виділено в окрему функцію, оскільки він також використовується функціями [reserver] та [supprimer].

3.7.9.5. Бронювання часового проміжку

Нагадаємо, що клієнти обираються випадковим чином.

Код бронювання такий:


$scope.reserver = function (creneauId) {
        utils.debug("réservation du créneau", creneauId);
        // створюється RV із випадковим клієнтом у часовому проміжку, визначеному [id]
        var idClient = clients[Math.floor(Math.random() * clients.length)].id;
        utils.debug("réservation du créneau pour le client", idClient);
        // імітація очікування
        $scope.waiting.visible = true;
        var task = utils.waitForSomeTime($scope.waiting.time);
        // додається часовий проміжок
        var promise = task.promise.then(function () {
          // шлях до сервісу URL
          var path = config.urlSvrResaAdd;
          // дані, що передаються до сервісу
          var post = {jour: formattedDay, idCreneau: creneauId, idClient: idClient};
          // запуск асинхронного завдання
          task = dao.getData($scope.server.url, $scope.server.login, $scope.server.password, path, post);
          // повертається обіцянка про завершення завдання
          return task.promise;
        });

        // аналіз результату завдання
        promise = promise.then(function (result) {
          if (result.err != 0) {
            // виникли помилки під час перевірки rv
            $scope.errors = {title: config.postResaErrors, messages: utils.getErrors(result, $filter), show: true};
          } else {
            // запитується новий розклад
            getAgenda();
          }
        });

      };
  • рядок 1: нагадуємо, що параметром функції [reserver] є номер часового проміжку (атрибут id);
  • рядок 4: клієнт обирається випадковим чином зі списку клієнтів, жорстко визначеного в коді ініціалізації. Зберігається його ідентифікатор [id];
  • рядки 7–8: очікування протягом 3 секунд;
  • рядки 11–18: ці рядки виконуються лише після закінчення 3 секунд;
  • рядок 12: URL служби бронювання [/ajouterRv]. Цей URL відрізняється від тих, з якими ми стикалися досі. У веб-службі він визначений наступним чином:

    @RequestMapping(value = "/ajouterRv", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
public Reponse ajouterRv(@RequestBody PostAjouterRv post, HttpServletResponse response) {
  • (продовження)
    • рядок 1: URL не має параметрів і запитується разом із POST;
    • рядок 2: параметри надсилаються у вигляді об’єкта JSON. Цей об’єкт буде десеріалізовано в параметр [post] (@RequestBody);

Ми розглядали приклад такого POST (параграф 2.12.2):

  • у [0] — URL веб-сервісу;
  • у [1] використовується метод POST;
  • у [2] — текст JSON інформації, переданої веб-сервісу у формі {день, idClient, idCreneau};
  • у [3] клієнт повідомляє веб-сервісу, що надсилає йому інформацію JSON;

Повернемося до коду JS функції [reserver]:

  • рядок 14: створюється значення для відправки у вигляді об’єкта JS. Angular серіалізує його у JSON під час відправки;
  • рядок 16: виконується виклик функції HTTP. Значення, яке потрібно відправити, є останнім параметром функції [dao.getData]. Коли цей параметр присутній, функція [dao.getData] створює POST замість GET (див. код у розділі 3.7.6.4);
  • рядок 18: повертається обіцянка виклику HTTP;
  • рядки 23–29: виконуються лише тоді, коли виклик HTTP повернув свою відповідь;
  • рядок 23: параметр [result] має вигляд [err,data] або [err,messages], де [err] — це код помилки;
  • рядки 23–26: якщо сталися помилки, відображається повідомлення про помилку;
  • рядок 28: якщо бронювання пройшло успішно, знову відображається новий календар;

3.7.9.6. Зміна сервера

  

У класі [RdvMedecinsCorsController] додаємо такий метод:


    // надсилання параметрів клієнту
    private void sendOptions(HttpServletResponse response) {
        if (application.isCORSneeded()) {
            // встановлено заголовок CORS
            response.addHeader("Access-Control-Allow-Origin", "*");
            // заголовку дозволено [authorization]
            response.addHeader("Access-Control-Allow-Headers", "authorization");
        }

    @RequestMapping(value = "/ajouterRv", method = RequestMethod.OPTIONS)
    public void ajouterRv(HttpServletResponse response) {
        sendOptions(response);
}

Додавання здійснюється у рядках 10–13. Заголовки рядків 2–8 будуть надіслані для URL [/ajouterRv] (рядок 10) та методу HTTP [OPTIONS] (рядок 10).

Клас [RdvMedecinsController] змінюється наступним чином:


    @RequestMapping(value = "/ajouterRv", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
    public Reponse ajouterRv(@RequestBody PostAjouterRv post, HttpServletResponse response) {
        // заголовки CORS
        rdvMedecinsCorsController.ajouterRv(response);
...

Для методу [POST] (рядок 1) та URL [/ajouterRv] (рядок 1) викликається метод, який ми щойно додали в [RdvMedecinsCorsController] (рядок 4), отже, повертаються ті самі заголовки HTTP, що й для методів HTTP та [OPTIONS].

3.7.9.7. Tests

Проведемо перший тест, у якому зарезервуємо будь-який часовий проміжок:

 

Як завжди в таких випадках, слід переглянути журнали консолі:


[dao] getData[/ajouterRv] error réponse : {"data":"","status":0,"config":{"method":"POST","transformRequest":[null],"transformResponse":[null],"timeout":1000,"url":"http://localhost:8080/ajouterRv","data":{"jour":"2014-06-30","idCreneau":1,"idClient":4},"headers":{"Accept":"application/json, text/plain, */*","Authorization":"Basic YWRtaW46YWRtaW4=","Content-Type":"application/json;charset=utf-8"}},"statusText":""}

Метод [dao.getData] завершився невдачею з кодом [status=0], що означає, що Angular скасував запит. Причину помилки можна знайти в логах:

XMLHttpRequest cannot load http://localhost:8080/ajouterRv. Поле заголовка запиту Content-Type не дозволено Access-Control-Allow-Headers.

Якщо подивитися на мережевий трафік, то бачимо таке:

  • у [1] та [2]: був лише один запит HTTP, запит [OPTIONS];
  • у [3] клієнт Angular запитує два дозволи:
    • дозвіл на відправку заголовків HTTP та [accept, authorization, content-type];
    • дозвіл на відправку команди POST;
  • у [4]: сервер надає дозвіл на заголовок [authorization]. Нагадаємо, що на стороні сервера саме ми самі надсилаємо цей дозвіл;

Отже, нововведення полягає в тому, що під час операції POST клієнт Angular запитує у сервера додаткові дозволи. Тому потрібно змінити сервер, щоб він їх надав:

  

У класі [RdvMedecinsCorsController] ми змінюємо приватний метод, який генерує заголовки HTTP, що надсилаються для команд OPTIONS, GET та POST:


    // надсилання параметрів клієнту
    private void sendOptions(HttpServletResponse response) {
        if (application.isCORSneeded()) {
            // встановлюємо заголовок CORS
            response.addHeader("Access-Control-Allow-Origin", "*");
            // дозволяються певні заголовки
            response.addHeader("Access-Control-Allow-Headers", "accept, authorization, content-type");
            // дозволяється POST
            response.addHeader("Access-Control-Allow-Methods", "POST");
        }
}
  • рядок 7: додано дозвіл для заголовків HTTP та [accept, content-type];
  • рядок 9: додано дозвіл для методу POST;

Повторюємо тест після перезапуску сервера:

 

Цього разу бронювання вдалося здійснити.

3.7.9.8. Скасування зустрічі

Код функції [supprimer] такий:


$scope.supprimer = function (idRv) {
        utils.debug("suppression rv n°", idRv);
        // імітація очікування
        $scope.waiting.visible = true;
        task = utils.waitForSomeTime($scope.waiting.time);
        // додається часовий проміжок
        var promise = task.promise.then(function () {
          // шлях до сервісу URL
          var path = config.urlSvrResaRemove;
          // дані, що передаються до служби
          var post = {idRv: idRv};
          // запуск асинхронного завдання
          task = dao.getData($scope.server.url, $scope.server.login, $scope.server.password, path, post);
          // повертається обіцянка про завершення завдання
          return task.promise;
        });

        // аналіз результату завдання
        promise = promise.then(function (result) {
          if (result.err != 0) {
            // виникли помилки під час видалення rv
            $scope.errors = {title: config.postRemoveErrors, messages: utils.getErrors(result, $filter), show: true};
            // оновлюється UI
            $scope.waiting.visible = false;
          } else {
            // запитується новий календар
            getAgenda();
          }
        });
      };
  • рядок 1: слід пам’ятати, що параметром функції є номер зустрічі, яку потрібно видалити. Цей код дуже схожий на код бронювання. Ми коментуємо лише відмінності;
  • рядок 9: URL служби тут є [/supprimerRV], і тут також доступ до неї здійснюється через POST:

    @RequestMapping(value = "/supprimerRv", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
public Reponse supprimerRv(@RequestBody PostSupprimerRv post, HttpServletResponse response) {

Параметр, що надсилається, тут знову передається у формі JSON. У розділі 2.12.17 ми показали, як виглядає POST, створений вручну:

  • у [1] — URL веб-сервісу;
  • у [2] використовується метод POST;
  • у [3] — текст JSON інформації, переданої веб-сервісу у формі {idRv};
  • у [4] клієнт вказує веб-сервісу, що надсилає йому інформацію JSON;

Повернемося до коду JS функції [supprimer]:

  • рядок 11: створюється об’єкт, що надсилається. Angular автоматично серіалізує його у JSON;

Решта коду аналогічна коду бронювання.

3.7.9.9. Зміни на стороні сервера

На стороні сервера ми вносимо такі зміни:

  

У класі [RdvMedecinsCorsController] додаємо такий метод:


    // надсилання параметрів клієнту
    private void sendOptions(HttpServletResponse response) {
        if (application.isCORSneeded()) {
            // виправляється заголовок CORS
            response.addHeader("Access-Control-Allow-Origin", "*");
            // дозволяються певні заголовки
            response.addHeader("Access-Control-Allow-Headers", "accept, authorization, content-type");
            // дозволяється POST
            response.addHeader("Access-Control-Allow-Methods", "POST");
        }
    }
...
    @RequestMapping(value = "/supprimerRv", method = RequestMethod.OPTIONS)
    public void supprimerRv(HttpServletResponse response) {
        sendOptions(response);
}

Додавання здійснюється у рядках 13–16. Заголовки рядків 2–10 будуть надіслані для URL [/supprimerRv] (рядок 13) та методу HTTP [OPTIONS] (рядок 13).

Клас [RdvMedecinsController], у свою чергу, змінено наступним чином:


    @RequestMapping(value = "/supprimerRv", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
    public Reponse supprimerRv(@RequestBody PostSupprimerRv post, HttpServletResponse response) {
        // заголовки CORS
        rdvMedecinsCorsController.supprimerRv(response);
...

Для методів [POST] (рядок 1) та URL і [/supprimerRv] (рядок 1) викликається метод, який ми щойно додали в [RdvMedecinsCorsController] (рядок 4), отже, повертаються ті самі заголовки HTTP, що й для методів HTTP та [OPTIONS].

3.7.10. Приклад 10: створення та скасування бронювань — 2

Тепер ми розглянемо той самий додаток, що й раніше, але замість бронювання для випадкового клієнта його буде обрано зі списку, що розгортається.

3.7.10.1. Вигляд V додатка

Ми представимо таку форму:

Клієнтів обиратимуть у [1].

Код схожий на код попереднього додатка, тому ми розглянемо лише основні відмінності.

Ми дублюємо файл [app-19.html] у [app-20.html], а потім створюємо код випадаючого списку клієнтів [1]:


<!-- список клієнтів -->
  <div class="alert alert-info">
    <h3>{{agenda.title|translate:agenda.model}}</h3>

    <div class="row" ng-show="clients.show">
      <div class="col-md-3">
        <h2 translate="{{clients.title}}"></h2>
        <select data-style="btn-primary" class="selectpicker" select-enable="" ng-if="clients.data">
          <option ng-repeat="client in clients.data" value="{{client.id}}">
            {{client.titre}} {{client.prenom}} {{client.nom}}
          </option>
        </select>
      </div>
    </div>
  </div>
  • рядки 8–12: випадаючий список буде реалізовано за допомогою компонента [bootstrap-select];
  • рядок 1: директива [selectEnable] застосовується через атрибут [select-enable];
  • рядок 1: тег <select> генерується лише за умови існування [clients.data] (# null, undefined). Цей момент є важливим і пояснено в параграфі 3.7.7.8;

Крім того, ми імпортуємо нові файли JS:


<script type="text/javascript" src="rdvmedecins-08.js"></script>
<!-- інструкції -->
<script type="text/javascript" src="selectEnable.js"></script>
<script type="text/javascript" src="footable.js"></script>
  • рядок 1: файл [rdvmedecins-08.js] отримується шляхом копіювання файлу [rdvmedecins-0.js];
  • рядки 3–4: імпортуються файли обох директив;

3.7.10.2. Контролер C

Код контролера C змінюється наступним чином:


// контролер
angular.module("rdvmedecins")
  .controller('rdvMedecinsCtrl', ['$scope', 'utils', 'config', 'dao', '$translate', '$timeout', '$filter', '$locale',
    function ($scope, utils, config, dao, $translate, $timeout, $filter, $locale) {
      // ------------------- ініціалізація шаблону
...
      // клієнти
      $scope.clients = {title: config.listClients, show: false, model: {}};

      //------------------------------------------- ініціалізація подання
      // глобальне асинхронне завдання
      var task;
      // запитуються клієнти, а потім календар
      getClients().then(function () {
        getAgenda();
      });
...

      // виконання дії
      function getClients() {
....
      };
} ]);
  • рядок 8: об’єкт [$scope.clients] налаштовує випадаючий список клієнтів у вікні V;
  • рядки 14–16: в асинхронному режимі спочатку запитується список клієнтів, а після його отримання — розклад пані PELISSIER на сьогодні. Синтаксис, що використовується тут, працює лише тому, що функція [getClients] повертає обіцянку (promise);

Метод [getClients] запитує список клієнтів:


function getClients() {
        // оновлюємо UI
        $scope.waiting.visible = true;
        $scope.clients.show = false;
        $scope.errors.show = false;
        // запит списку клієнтів;
        task = dao.getData($scope.server.url, $scope.server.login, $scope.server.password, config.urlSvrClients);
        var promise = task.promise;
        // аналізується результат попереднього виклику
        promise = promise.then(function (result) {
          // result={err: 0, data: [client1, client2, ...]}
          // result={err: n, messages: [msg1, msg2, ...]}
          if (result.err == 0) {
            // отримані дані вносимо до моделі
            $scope.clients.data = result.data;
            // оновлюємо UI
            $scope.clients.show = true;
            $scope.waiting.visible = false;
          } else {
            // сталися помилки під час отримання списку клієнтів
            $scope.errors = { title: config.getClientsErrors, messages: utils.getErrors(result), show: true, model: {}};
            // оновлюємо файл UI
            $scope.waiting.visible = false;
          }
        });
        // виконується обіцянка
        return promise;
      };

Це код, з яким ми вже стикалися і який вже коментували. Важливим моментом, на який слід звернути увагу, є рядок 31:

  • рядок 27: повертається обіцянка з рядка 10, тобто остання обіцянка, отримана в коді. Ця обіцянка буде отримана лише тоді, коли виклик HTTP поверне свою відповідь;

Метод [reserver] дещо змінюється:


      $scope.reserver = function (creneauId) {
        utils.debug("réservation du créneau", creneauId);
        // створюється RV для вибраного клієнта
        var idClient = $(".selectpicker").selectpicker('val');
        ...
        });
  • рядок 4: резервування здійснюється не для випадкового клієнта, а для клієнта, обраного зі списку клієнтів.

3.7.11. Приклад 11: директива [selectEnable2]

У цьому прикладі розглядаються директиви.

3.7.11.1. Вигляд V

Додаток відображає такий вигляд:

 

3.7.11.2. Код HTML для цього виду

Код HTML для подання [app-21.html] має такий вигляд:


<div class="container">
  <h1>Rdvmedecins - v1</h1>

  <!-- повідомлення про очікування -->
  <div class="alert alert-warning" ng-show="waiting.visible">
    ...
  </div>

  <!-- список помилок -->
  <div class="alert alert-danger" ng-show="errors.show">
   ...
  </div>

  <!-- список клієнтів -->
  <div class="alert alert-info">
    <div class="row" ng-show="clients.show">
      <div class="col-md-4">
        <h2 translate="{{clients.title}}"></h2>
        <select data-style="btn-primary" id="selectpickerClients" select-enable2="" ng-if="clients.data">
          <option ng-repeat="client in clients.data" value="{{client.id}}">
            {{client.titre}} {{client.prenom}} {{client.nom}}
          </option>
        </select>
      </div>
    </div>
  </div>

  <!-- список лікарів -->
  <div class="alert alert-info">
    <div class="row" ng-show="medecins.show">
      <div class="col-md-4">
        <h2 translate="{{medecins.title}}"></h2>
        <select data-style="btn-primary" id="selectpickerMedecins" select-enable2="" ng-if="medecins.data">
          <option ng-repeat="medecin in medecins.data" value="{{medecin.id}}">
            {{medecin.titre}} {{medecin.prenom}} {{medecin.nom}}
          </option>
        </select>
      </div>
    </div>
  </div>
</div>
...
<script type="text/javascript" src="rdvmedecins-09.js"></script>
<!-- інструкції -->
<script type="text/javascript" src="selectEnable2.js"></script>
  • рядки 19–23: випадаючий список клієнтів;
  • рядок 19: застосовується директива [selectEnable2] (атрибут [select-enable2]);
  • рядок 19: лише якщо [clients.data] не порожній;
  • рядок 19: список, що розгортається, ідентифікується атрибутом [id="selectpickerClients"];
  • рядки 33–37: випадаючий список лікарів;
  • рядок 33: застосовується директива [selectEnable2] (атрибут [select-enable2]);
  • рядок 33: лише якщо [medecins.data] не порожній;
  • рядок 33: список, що розгортається, ідентифікується атрибутом [id="selectpickerMedecins"];
  • рядок 43: імпортується новий файл JS [rdvmedecins-09.js];
  • рядок 45: імпортується файл JS з нової директиви;

3.7.11.3. Директива [selectEnable2]

Код директиви [selectEnable2] такий:


angular.module("rdvmedecins").directive('selectEnable2', ['$timeout', 'utils', function ($timeout, utils) {
  return {
    link: function (scope, element, attrs) {
      utils.debug("directive selectEnable2 attrs", attrs);
      $timeout(function () {
        $('#' + attrs['id']).selectpicker();
      })
    }
  }
}]);
  • рядок 4: виводимо значення параметра [attrs], щоб зрозуміти, як працює код. Ми побачимо, що attrs['id']='selectpickerClients' для списку клієнтів;
  • рядок 6: щоб знайти в DOM елемент з [id='x'], ми пишемо [$('#x')]. Отже, щоб знайти список клієнтів, потрібно написати [$('#selectpickerClients')]. Це досягається за допомогою синтаксису [$('#' + attrs['id'])];

Отже, директива [selectEnable2] використовує інформацію, що міститься в одному з атрибутів елемента HTML, до якого вона застосовується.

3.7.11.4. Контролер C

Контролер C міститься у файлі JS [rdvmedecins-09.js] і має таку структуру:


// контролер
angular.module("rdvmedecins")
  .controller('rdvMedecinsCtrl', ['$scope', 'utils', 'config', 'dao',
    function ($scope, utils, config, dao) {
      // ------------------- ініціалізація шаблону
      // повідомлення про очікування
      $scope.waiting = {text: config.msgWaiting, visible: false, cancel: cancel, time: 3000};
      // дані для входу
      $scope.server = {url: 'http://localhost:8080', login: 'admin', password: 'admin'};
      // помилки
      $scope.errors = {show: false, model: {}};
      // лікарі
      $scope.medecins = {title: config.listMedecins, show: false, model: {}};
      // клієнти
      $scope.clients = {title: config.listClients, show: false, model: {}};

      // глобальне асинхронне завдання
      var task;
      // ---------------------------------------------------- ініціалізація подання
      // оновлюємо UI
      $scope.waiting.visible = true;
      $scope.clients.show = false;
      $scope.medecins.show = false;
      $scope.errors.show = false;
      // запитуються клієнти, а потім лікарі
      getClients().then(function () {
        getMedecins();
      });

      // список клієнтів
      function getClients() {
        ...
      }

      // список лікарів
      function getMedecins() {
...
      }

      // скасування черги
      function cancel() {
...
      }
    } ]);
  • рядки 26–28: спочатку запитуються клієнти, а потім лікарі;

3.7.11.5. Тестування

Випробуйте цю нову версію.

3.7.12. Приклад 12: директива [list]

Ми повернемося до того самого прикладу, що й раніше, але хочемо спростити код HTML за допомогою директиви. Наразі ми маємо такий код HTML:


<!-- список клієнтів -->
  <div class="alert alert-info">
    <div class="row" ng-show="clients.show">
      <div class="col-md-4">
        <h2 translate="{{clients.title}}"></h2>
        <select data-style="btn-primary" id="selectpickerClients" select-enable2="" ng-if="clients.data">
          <option ng-repeat="client in clients.data" value="{{client.id}}">
            {{client.titre}} {{client.prenom}} {{client.nom}}
          </option>
        </select>
      </div>
    </div>
  </div>
  <!-- список лікарів -->
  <div class="alert alert-info">
    <div class="row" ng-show="medecins.show">
      <div class="col-md-4">
        <h2 translate="{{medecins.title}}"></h2>
        <select data-style="btn-primary" id="selectpickerMedecins" select-enable2="" ng-if="medecins.data">
          <option ng-repeat="medecin in medecins.data" value="{{medecin.id}}">
            {{medecin.titre}} {{medecin.prenom}} {{medecin.nom}}
          </option>
        </select>
      </div>
    </div>
  </div>

Рядки 14–26 ідентичні рядкам 1–13. Вони стосуються лікарів, а не клієнтів. Ми хотіли б мати змогу написати таке:


  <!-- список клієнтів -->
  <list model="clients" ng-if="clients.show"></list>
  <!-- список лікарів -->
<list model="medecins" ng-if="medecins.show"></list>

Цей код передбачає використання нової директиви [list], яку ми зараз створимо.

3.7.12.1. Директива [list]

Директива [list] розміщена у файлі JS [list.js]. Її код такий:


angular.module("rdvmedecins")
  .directive("list", ['utils', '$timeout', function (utils, $timeout) {
    // інстанція повернутої директиви
    return {
      // елемент HTML
      restrict: "E",
      // URL фрагмента
      templateUrl: "list.html",
      // унікальна область дії для кожного екземпляра директиви
      scope: true,
      // функція, що пов'язує з документом
      link: function (scope, element, attrs) {
        utils.debug("directive list attrs", attrs);
        scope.model = scope[attrs['model']];
        utils.debug("directive list model", scope.model);
        $timeout(function () {
          $('#' + scope.model.id).selectpicker();
        })
      }
    }
}]);
  • рядок 2: визначає директиву з назвою «list»;
  • рядок 6: атрибут [restrict] визначає способи використання директиви. [restrict: "E"] означає, що директива [list] може використовуватися як елемент HTML <list ...>...</list>. [restrict: "A"] означає, що директива [list] може використовуватися як атрибут, наприклад <div ... list='...'>. [restrict: "AE"] означає, що директива [list] може використовуватися як атрибут і як елемент;
  • рядок 8: атрибут [templateUrl] вказує ім’я фрагмента HTML, який слід використовувати при зустрічі з тегом. Цей фрагмент буде тілом тегу;
  • рядок 10: атрибут [scope] визначає область дії шаблону директиви. [scope: true] означає, що два елементи типу <list> матимуть кожен свій шаблон. За замовчуванням (область дії не ініціалізована) вони використовують спільні шаблони;
  • рядок 12: функція [link], яку ми вже використовували кілька разів;

Щоб зрозуміти наведений вище код, слід згадати, як саме буде використовуватися ця директива:


  <!-- список клієнтів -->
  <list model="clients" ng-if="clients.show"></list>
  <!-- список лікарів -->
<list model="medecins" ng-if="medecins.show"></list>

Директива [list] використовується як елемент HTML <list>. Цей елемент має два атрибути:

  • [model]: значенням якого буде елемент моделі M подання V, у якому знаходиться директива [list]. Цей елемент буде заповнювати модель директиви;
  • [ng-if]: який забезпечить, щоб код HTML директиви не генерувався, якщо немає чого відображати;

Повернемося до коду функції [link] директиви:


link: function (scope, element, attrs) {
        utils.debug("directive list attrs", attrs);
        scope.model = scope[attrs['model']];
        utils.debug("directive list model", scope.model);
        $timeout(function () {
          $('#' + scope.model.id).selectpicker();
        })
      }

Поєднаємо цей код JS з кодом HTML, який використовує директиву:


  <list model="clients" ng-if="clients.show"></list>
  • рядок 3: attrs['model'] тут має значення «clients»;
  • рядок 3: scope[attrs['model']] має значення scope['clients'] і, отже, представляє [$scope.clients], тобто поле [clients] моделі подання. Це поле матиме значення {id: '...', data: [client1, client2, ...], show: ..., title: '...'};
  • рядок 3: до моделі директиви додається поле [model]. Ця модель успадкувала модель подання, в якому вона знаходиться. Тому слід уникати конфліктів з можливим полем [model], яке також може бути у поданні. У цьому випадку конфлікту не буде;
  • рядок 4: виводимо [scope.model], щоб краще зрозуміти код;
  • рядки 5–7: ми бачимо код, з яким уже стикалися. Різниця полягає в тому, що id компонента раніше вказувався в атрибуті attrs['id']. Тут він буде вказаний у [scope.model.id];

Тепер розглянемо код HTML, згенерований директивою. Через атрибут [templateUrl: "list.html"] директиви його потрібно шукати у файлі [list.html]:


<!-- список клієнтів або лікарів -->
<div class="alert alert-info" ng-show="model.show">
  <div class="row">
    <div class="col-md-4">
      <h2 translate="{{model.title}}"></h2>
      <select data-style="btn-primary" id="{{model.id}}" ng-if="model.data">
        <option ng-repeat="element in model.data" value="{{element.id}}">
          {{element.titre}} {{element.prenom}} {{element.nom}}
        </option>
      </select>
    </div>
  </div>
</div>
  • Перше, про що слід пам’ятати під час читання цього коду, — це те, що директива створила об’єкт [scope.model] у формі [{id :'...', data:[client1, client2, ...], show : ..., title :'...'}]. Цей об’єкт [model] (scope є неявним у коді HTML) використовується кодом HTML директиви;
  • рядок 2: використання [model.show] для показу/приховування виду, згенерованого директивою;
  • рядок 5: використання [model.title] для встановлення заголовка;
  • рядок 6: використання [model.id] для присвоєння ідентифікатора тегу <select>. Цей id використовується кодом JS директиви;
  • рядок 6: використання [model.data] для генерації тегу <select> лише у разі наявності даних для відображення;
  • рядки 7–9: використання [model.data] для генерації елементів випадаючого списку;

3.7.12.2. Код HTML

Код HTML для додатка [app-22.html] має такий вигляд:


<div class="container">
  <h1>Rdvmedecins - v1</h1>

  <!-- повідомлення про очікування -->
  <div class="alert alert-warning" ng-show="waiting.visible">
    ...
  </div>

  <!-- список помилок -->
  <div class="alert alert-danger" ng-show="errors.show">
    ...
  </div>

  <!-- список клієнтів -->
  <list model="clients" ng-if="clients.show"></list>
  <!-- список лікарів -->
  <list model="medecins" ng-if="medecins.show"></list>
</div>
...
<script type="text/javascript" src="rdvmedecins-10.js"></script>
<!-- інструкції -->
<script type="text/javascript" src="list.js"></script>
  • рядок 22: не забувайте включити код JS з директиви;

3.7.12.3. Контролер C

Контролер C майже не змінюється:


angular.module("rdvmedecins")
  .controller('rdvMedecinsCtrl', ['$scope', 'utils', 'config', 'dao',
    function ($scope, utils, config, dao) {
      // ------------------- ініціалізація шаблону
...
      // лікарі
      $scope.medecins = {title: config.listMedecins, show: false, id: 'medecins'};
      // клієнти
      $scope.clients = {title: config.listClients, show: false, id: 'clients'};
...
  • у рядках 7 і 9 ми додаємо атрибут [id] до шаблонів лікарів та клієнтів;

3.7.12.4. Тестування

Тести дають ті самі результати, що й у попередньому прикладі.

3.7.13. Приклад 13: оновлення шаблону директиви

Продовжуємо вивчати директиви та використовуємо приклад зі списком, що розгортається. Тут ми хочемо дослідити поведінку директиви [list], коли змінюється вміст списку, що розгортається.

3.7.13.1. Види V

Існують такі види:

  • у [1] вперше запитується список клієнтів;
  • у [2] вдруге запитується список клієнтів. Цей другий список потім об'єднується з першим [3]. У цьому прикладі ми розглянемо оновлення компонента [Bootstrap select].

3.7.13.2. Сторінка HTML

Сторінка HTML [app-23.html] отримується шляхом копіювання [app-22.html], а потім модифікується наступним чином:


<div class="container">
  <h1>Rdvmedecins - v1</h1>

  <!-- повідомлення про очікування -->
  <div class="alert alert-warning" ng-show="waiting.visible">
    ...
  </div>

  <!-- список помилок -->
  <div class="alert alert-danger" ng-show="errors.show">
    ...
  </div>

  <!-- кнопка -->
  <div class="alert alert-warning">
    <button class="btn btn-primary" ng-click="getClients()">{{clients.title|translate}}</button>
  </div>

  <!-- список клієнтів -->
  <list2 model="clients" ng-if="clients.show"></list2>
</div>
...
<script type="text/javascript" src="rdvmedecins-11.js"></script>
<!-- директиви -->
<script type="text/javascript" src="list2.js"></script>

Зміни порівняно з попереднім додатком такі:

  • рядки 15–17: додано кнопку;
  • рядок 20: використання нової директиви [list2];
  • рядок 23: використання нового файлу JS;
  • рядок 25: імпорт файлу JS із директиви [list2];

3.7.13.3. Директива [list2]

Директива [list2] у [list2.js] має такий вигляд:


angular.module("rdvmedecins")
  .directive("list2", ['utils', '$timeout', function (utils, $timeout) {
    // екземпляр повернутої директиви
    return {
      // елемент HTML
      restrict: "E",
      // URL фрагмента
      templateUrl: "list.html",
      // область дії, унікальна для кожного екземпляра директиви
      scope: true,
      // функція зв’язку з документом
      link: function (scope, element, attrs) {
        utils.debug('directive list2');
        scope.model = scope[attrs['model']];
        $timeout(function () {
          $('#' + scope.model.id).selectpicker('refresh');
        })
      }
    }
}]);

Єдина відмінність від директиви [list] полягає в рядку 16: за допомогою методу [selectpicker('refresh')] компоненту [Bootstrap-select] надсилається запит на оновлення. Ідея полягає в тому, що кожного разу, коли користувач запитуватиме новий список клієнтів, ми оновлюватимемо випадаючий список. Це не спрацює, але це основна ідея.

3.7.13.4. Контролер C

Контролер міститься у файлі [rdvmedecins-11.js], отриманому шляхом копіювання файлу [rdvmedecins-10.js]:


      // клієнти
      $scope.clients = {title: config.listClients, show: false, id: 'clients', data: []};
...
      // список клієнтів
      $scope.getClients = function getClients() {
        // оновлюємо UI
        $scope.waiting.visible = true;
        $scope.errors.show = false;
        // запитуємо список клієнтів;
        task = dao.getData($scope.server.url, $scope.server.login, $scope.server.password, config.urlSvrClients);
        var promise = task.promise;
        // аналізуємо результат попереднього виклику
        promise = promise.then(function (result) {
          // result={err: 0, data: [client1, client2, ...]}
          // result={err: n, messages: [msg1, msg2, ...]}
          if (result.err == 0) {
             // отримані дані поміщаються в новий шаблон, щоб примусово оновити сторінку
            $scope.clients = {title: $scope.clients.title, data: $scope.clients.data.concat(result.data), show: $scope.clients.show, id: $scope.clients.id};
            // оновлюємо UI
            $scope.clients.show = true;
            $scope.waiting.visible = false;
          } else {
            // сталися помилки під час отримання списку клієнтів
            $scope.errors = { title: config.getClientsErrors, messages: utils.getErrors(result), show: true, model: {}};
            // оновлюється UI
            $scope.waiting.visible = false;
          }
        });
}
  • рядок 1: щоб забезпечити об’єднання масивів у [clients.data], цей об’єкт ініціалізується порожнім масивом;
  • рядок 18: новий список клієнтів об'єднується зі списками, що вже містяться в масиві [clients.data];

Раніше ми писали:

// вносимо отримані дані в модель
$scope.clients.data = result.data;

Тепер пишемо:

// дані, що були отримані, вносяться до нової моделі, щоб примусово оновити відображення
$scope.clients = {title: $scope.clients.title, data: $scope.clients.data.concat(result.data), show: $scope.clients.show, id: $scope.clients.id};

Щоб зрозуміти цей код, потрібно згадати, як модель M використовується у поданні V у випадку директиви [list2]:


  <!-- список клієнтів -->
<list2 model="clients" ng-if="clients.show"></list2>

Шаблон, який використовується директивою [list2], — це [clients]. Він буде переоцінений у поданні V лише в тому випадку, якщо [clients] зміниться у шаблоні M цього подання. Перша ідея щодо зміни — це написати:

$scope.clients.data=$scope.clients.data.concat(result.data) ;

щоб врахувати той факт, що новий список клієнтів має бути доданий до попередніх. Таким чином, ми змінюємо [clients.data], але не [clients]. Я не знайомий із тонкощами JavaScript, але не дивно, що [clients] є покажчиком, так само як і [clients.data]. Покажчик [clients] не змінюється при зміні покажчика [clients.data]. Отже, директива [list2] не переоцінюється. Саме це й спостерігається під час налагодження програми (F12 у Chrome).

Якщо написати:

$scope.clients = {title: $scope.clients.title, data: $scope.clients.data.concat(result.data), show: $scope.clients.show, id: $scope.clients.id};

ми переконуємося, що [$scope.clients] дійсно отримує нове значення. Покажчик [$scope.clients] вказує на новий об’єкт. Отже, директива [list2] мала б бути переоцінена. Однак бажаного результату не досягається. Розглянемо знімки екрана, коли двічі запитується список клієнтів:

  • у [1] є лише чотири елементи замість восьми;
  • у [2] ці чотири елементи містяться в [select], але останній приховано (style='display: none');
  • у [3] ці чотири клієнти знаходяться в іншій архітектурі HTML, і саме її бачить користувач, коли натискає на випадаючий список;

Нарешті, журнали консолі містять таку інформацію:

1
2
3
4
[dao] init
[dao] getData[/getAllClients] success réponse : {"data":{"status":0,"data":[{"id":1,"version":1,"titre":"Mr","nom":"MARTIN","prenom":"Jules"},{"id":2,"version":1,"titre":"Mme","nom":"GERMAN","prenom":"Christine"},{"id":3,"version":1,"titre":"Mr","nom":"JACQUARD","prenom":"Jules"},{"id":4,"version":1,"titre":"Melle","nom":"BISTROU","prenom":"Brigitte"}]},"status":200,"config":{"method":"GET","transformRequest":[null],"transformResponse":[null],"timeout":1000,"url":"http://localhost:8080/getAllClients","headers":{"Accept":"application/json, text/plain, */*","Authorization":"Basic YWRtaW46YWRtaW4="}},"statusText":"OK"}
directive list2
[dao] getData[/getAllClients] success réponse : {"data":{"status":0,"data":[{"id":1,"version":1,"titre":"Mr","nom":"MARTIN","prenom":"Jules"},{"id":2,"version":1,"titre":"Mme","nom":"GERMAN","prenom":"Christine"},{"id":3,"version":1,"titre":"Mr","nom":"JACQUARD","prenom":"Jules"},{"id":4,"version":1,"titre":"Melle","nom":"BISTROU","prenom":"Brigitte"}]},"status":200,"config":{"method":"GET","transformRequest":[null],"transformResponse":[null],"timeout":1000,"url":"http://localhost:8080/getAllClients","headers":{"Accept":"application/json, text/plain, */*","Authorization":"Basic YWRtaW46YWRtaW4="}},"statusText":"OK"}
  • рядок 1: запускається служба [dao];
  • рядок 2: служба [dao] отримує перший список клієнтів;
  • рядок 3: виконується директива [list2];
  • рядок 4: служба [dao] отримує другий список клієнтів;

Вивід рядка 2 здійснюється за допомогою такого коду в директиві:


      link: function (scope, element, attrs) {
        utils.debug('directive list2');
        ...
}

Розглянемо життєвий цикл директиви [list2]:

  • між рядками 1 і 2 вона не активована, хоча погляд було відображено вперше. Це пов’язано з її атрибутом [ng-if="clients.show"] у погляді V:

<list2 model="clients" ng-if="clients.show"></list2>
  • рядок 3: після отримання першого списку лікарів [clients.show] приймає значення true, і директива активується;
  • після отримання другого списку клієнтів бачимо, що код директиви [list2] не викликається. Саме тому ми не бачимо другого списку;

Щоб вирішити цю проблему, ми змінюємо директиву [list2] наступним чином:


angular.module("rdvmedecins")
  .directive("list2", ['utils', '$timeout', function (utils, $timeout) {
    // екземпляр повернутої директиви
    return {
      // елемент HTML
      restrict: "E",
      // URL фрагмента
      templateUrl: "list.html",
      // унікальна область дії для кожного екземпляра директиви
      scope: true,
      // функція зв’язку з документом
      link: function (scope, element, attrs) {
        // щоразу, коли змінюється атрибут attrs["model"], модель директиви також має змінюватися
        scope.$watch(attrs["model"], function (newValue) {
          utils.debug("directive list2 newValue", newValue);
          // модель директиви оновлюється
          scope.model = newValue;
          $timeout(function () {
            $('#' + scope.model.id).selectpicker('refresh');
          })
        });
      }
    }
}]);
  • рядок 14: функція [scope.$watch] дозволяє відстежувати значення моделі. Її синтаксис має вигляд [scope.$watch('var'), f], де [var] — ідентифікатор змінної моделі, а f — функція, яку слід виконати, коли ця змінна змінює своє значення. У цьому випадку ми хочемо відстежувати змінну [clients]. Тому потрібно написати [scope.$watch('clients')]. Оскільки attrs['model'] = 'clients', ми записуємо [scope.$watch(attrs["model"], function (newValue)];
  • рядок 14: другим параметром функції [scope.$watch] є функція, яку слід виконати, коли значення спостережуваної змінної змінюється. Параметр [newValue] — це нове значення змінної, тобто для нас — нове значення змінної [clients] у моделі;
  • рядок 17: це нове значення присвоюється полю [model] у моделі директиви;

Після внесення цієї зміни журнали змінюються:

З наведеного вище видно, що після отримання другого списку клієнтів директива [list2] дійсно виконується знову, що підтверджує результат [2].

3.7.14. Приклад 14: директиви [waiting] та [errors]

Повернемося до коду HTML з попереднього додатка:


<div class="container">
  <h1>Rdvmedecins - v1</h1>

  <!-- повідомлення про очікування -->
  <div class="alert alert-warning" ng-show="waiting.visible">
  ...
  </div>

  <!-- список помилок -->
  <div class="alert alert-danger" ng-show="errors.show">
  ...
  </div>

  <!-- кнопка -->
  <div class="alert alert-warning">
    <button class="btn btn-primary" ng-click="getClients()">{{clients.title|translate}}</button>
  </div>

  <!-- список клієнтів -->
  <list2 model="clients" ng-if="clients.show"></list2>
</div>
  • рядки 5–7: повідомлення про очікування;
  • рядки 10–12: повідомлення про помилки;

Ми вирішили помістити коди HTML цих двох повідомлень у директиви.

3.7.14.1. Новий код HTML

Новий код HTML [app-24.html] має такий вигляд:


<div class="container">
  <h1>Rdvmedecins - v1</h1>

  <!-- повідомлення про очікування -->
  <waiting model="waiting"></waiting>

  <!-- список помилок -->
  <errors model="errors"></errors>

  <!-- кнопка -->
  <div class="alert alert-warning">
    <button class="btn btn-primary" ng-click="getClients()">{{clients.title|translate}}</button>
  </div>

  <!-- список клієнтів -->
  <list2 model="clients" ng-if="clients.show"></list2>
</div>
...
<script type="text/javascript" src="rdvmedecins-12.js"></script>
<!-- директиви -->
<script type="text/javascript" src="list2.js"></script>
<script type="text/javascript" src="errors.js"></script>
<script type="text/javascript" src="waiting.js"></script>
  • рядок 5: директива для повідомлення про очікування;
  • рядок 8: директива для повідомлення про помилки;
  • рядок 19: новий файл JS, пов'язаний із додатком;
  • рядки 21–23: файли JS для трьох директив;

3.7.14.2. Директива [waiting]

Код JS директиви [waiting] міститься у наступному файлі [waiting.js]:


angular.module("rdvmedecins")
  .directive("waiting", ['utils', function (utils) {
    // екземпляр повернутої директиви
    return {
      // елемент HTML
      restrict: "E",
      // URL фрагмента
      templateUrl: "waiting.html",
      // область дії, унікальна для кожного екземпляра директиви
      scope: true,
      // функція зв’язку з документом
      link: function (scope, element, attrs) {
        // щоразу, коли змінюється атрибут ["model"], шаблон сторінки також має змінюватися
        scope.$watch(attrs["model"], function (newValue) {
          utils.debug("[waiting] watch newValue", newValue);
          scope.model = newValue;
        });
      }
    }
  }]);

Цей код побудований за тією ж логікою, що й код директиви [list2], яку ми вже розглядали.

У рядку 8 міститься посилання на такий файл: [waiting.html]:


<div class="alert alert-warning" ng-show="model.show">
  <h1>{{ model.title.text | translate:model.title.values}}
    <button class="btn btn-primary pull-right" ng-click="model.cancel()">{{'cancel'|translate}}</button>
    <img src="assets/images/waiting.gif" alt=""/>
  </h1>
</div>

У коді JS додатка шаблон [$scope.waiting] цього коду HTML буде визначено таким чином:


// повідомлення очікування
$scope.waiting = {title: {text: config.msgWaiting, values: {}}, show: false, cancel: cancel, time: 3000};

3.7.14.3. Директива [errors]

Код JS директиви [errors] міститься у наступному файлі [errors.js]:


angular.module("rdvmedecins")
  .directive("errors", ['utils', function (utils) {
    // екземпляр повернутої директиви
    return {
      // елемент HTML
      restrict: "E",
      // URL фрагмента
      templateUrl: "errors.html",
      // область дії, унікальна для кожного екземпляра директиви
      scope: true,
      // функція зв’язку з документом
      link: function (scope, element, attrs) {
        // щоразу, коли змінюється атрибут ["model"], шаблон сторінки також має змінюватися
        scope.$watch(attrs["model"], function (newValue) {
          utils.debug("[errors] watch newValue", newValue);
          scope.model = newValue;
        });
      }
    }
}]);

Цей код побудований за тією ж логікою, що й код директиви [list2], який ми вже розглядали.

У рядку 8 міститься посилання на такий файл: [errors.html]:


<div class="alert alert-danger" ng-show="model.show">
  {{model.title.text|translate:model.title.values}}
  <ul>
    <li ng-repeat="message in model.messages">{{message|translate}}</li>
  </ul>
</div>

У коді JS додатка шаблон [$scope.errors] цього коду HTML буде визначено таким чином:


// сталися помилки під час отримання списку клієнтів
$scope.errors = { title: { text: config.getClientsErrors, values: {}}, messages: utils.getErrors(result), show: true, model: {}};

3.7.15. Приклад 15: навігація

До цього моменту ми використовували односторінкові додатки. У цьому прикладі ми розглянемо багатосторінкові додатки та навігацію між ними.

3.7.15.1. Види V додатка

  • у [1] — URL для подання № 1;
  • у [2] — його вміст;
  • у [3] — перехід на сторінку 2;
  • у [4] — вигляд № 2;
  • у [5] — перехід на сторінку 3;
  • у [6] — перехід до виду № 3;
  • у [7] — перехід на сторінку 1;
  • у [8] — повернення до виду № 1;

3.7.15.2. Організація коду

Ми починаємо нову організацію коду:

  
  • види додатка будуть розміщені в папці [views];
  • модуль додатка буде розміщено в папці [modules];
  • контролери додатка будуть розміщені в папці [controllers];

Так само у фінальній версії:

  • сервіси будуть розміщені в папці [services];
  • директиви будуть розміщені в папці [directives];

3.7.15.3. Контейнер представлень

Види з папки [views] будуть відображатися в наступному контейнері [app-25.html]:


<!DOCTYPE html>
<html ng-app="rdvmedecins">
<head>
  ...
</head>
<body>
    <div class="container" ng-controller="mainCtrl">
        <!-- панель навігації -->
        <ng-include src="'views/navbar.html'"></ng-include>

        <!-- поточний вигляд -->
        <ng-view></ng-view>
    </div>

...
<!-- модуль -->
<script type="text/javascript" src="modules/rdvmedecins-13.js"></script>
<!-- контролери -->
<script type="text/javascript" src="controllers/mainController.js"></script>
<script type="text/javascript" src="controllers/page1Controller.js"></script>
<script type="text/javascript" src="controllers/page2Controller.js"></script>
<script type="text/javascript" src="controllers/page3Controller.js"></script>
</body>
</html>
  • рядок 7: тіло контейнера керується файлом [mainCtrl];
  • рядок 9: директива [ng-include] дозволяє включити зовнішній файл HTML, у даному випадку — панель навігації;
  • рядок 12: різні види, що відображаються контейнером, розміщуються всередині директиви [ng-view]. У підсумку ми маємо контейнер, який відображає:
    • завжди ту саму панель навігації (рядок 9);
    • різні види у рядку 12;
  • рядки 16–22: імпортуються файли JS з модуля додатка [rdvmedecins-13.js] та його контролерів;

3.7.15.4. Модуль додатка

Файл [rdvmedecins-13.js] визначає модуль додатка та маршрутизацію між поданнями:


// --------------------- модуль Angular
angular.module("rdvmedecins", [ 'ngRoute' ]);

angular.module("rdvmedecins").config(["$routeProvider", function ($routeProvider) {
// ------------------------ маршрутизація
  $routeProvider.when("/page1",
    {
      templateUrl: "views/page1.html",
      controller: 'page1Ctrl'
    });
  $routeProvider.when("/page2",
    {
      templateUrl: "views/page2.html",
      controller: 'page2Ctrl'
    });
  $routeProvider.when("/page3",
    {
      templateUrl: "views/page3.html",
      controller: 'page3Ctrl'
    });
  $routeProvider.otherwise(
    {
      redirectTo: "/page1"
    });
}]);
  • рядок 1: визначається модуль [rdvmedecins]. Він залежить від модуля [ngRoute], що надається бібліотекою [angular-route.min.js]. Саме цей модуль забезпечує маршрутизацію, визначену в рядках 6–24;
  • рядок 4: визначає функцію [config] модуля [rdvmedecins]. Нагадаємо, що ця функція виконується перед будь-яким створенням екземпляра служби. Це функція конфігурації модуля. Тут налаштовується саме її маршрутизація. Це здійснюється за допомогою об’єкта [$routeProvider], що надається модулем [ngRoute];
  • рядки 6–10: визначають вигляд, який має відображатися, коли користувач запитує URL [/page1]. Це внутрішня маршрутизація в рамках додатка. URL насправді є [/rdvmedecins-angular-v1/app-21.html#/page1]. Бачимо, що все ще використовується URL з контейнера [/rdvmedecins-angular-v1/app-21.html], але з додатковою інформацією, що стоїть за символом #. Саме цю додаткову інформацію обробляє маршрутизація Angular;
  • рядок 8: вказує фрагмент HTML, який потрібно вставити в директиву [ng-view] контейнера:
  • рядок 9: вказує ім’я контролера цього фрагмента;
  • рядки 11–15: визначають вигляд, який слід відобразити, коли користувач запитує URL [/page2];
  • рядки 16–20: визначають вигляд, який слід відобразити, коли користувач запитує URL [/page3];
  • рядки 21–24: визначають маршрутизацію, яку слід виконати, коли запитуваний URL не є одним із трьох попередніх (otherwise, рядок 21);
  • рядок 23: перенаправлення на URL [/page1], тобто на вигляд, визначений у рядках 6–10;

3.7.15.5. Контролер контейнера переглядів

Ми бачили, що контейнер переглядів оголошував контролер:


<div class="container" ng-controller="mainCtrl">

Контролер [mainCtrl] визначено у файлі [mainController.js]:


// контролер
angular.module("rdvmedecins")
  .controller('mainCtrl', ['$scope', '$location',
    function ($scope, $location) {

      // шаблони сторінок
      $scope.page1 = {};
      $scope.page2 = {};
      $scope.page3 = {};
      // глобальний шаблон
      var main = $scope.main = {};
      main.text = "[Modèle global]";

      // методи, доступні для перегляду
      main.showPage1 = function () {
        $location.path("/page1");
      };
      main.showPage2 = function () {
        $location.path("/page2");
      };
      main.showPage3 = function () {
        $location.path("/page3");
      }
}]);
  • рядок 3: контролер [mainCtrl] потребує об’єкта [$location], який надає модуль маршрутизації [ngRoute]. Цей об’єкт дозволяє змінювати вигляд (рядки 16, 19, 22);

Повернемося до коду контейнера:


    <div class="container" ng-controller="mainCtrl">
        <!-- панель навігації -->
        <ng-include src="'views/navbar.html'"></ng-include>

        <!-- поточний вигляд -->
        <ng-view></ng-view>
</div>
  • контролер [mainCtrl] будує модель зони 1-7;
  • вид, включений у рядку 6, також має контролер. Наприклад, вид [page1] має контролер [page1Ctrl]. Останній будує модель області, що відображається у рядку 6. Отже, у цій області ми маємо дві моделі:
    • модель, побудована контролером [mainCtrl];
    • модель, побудована контролером [page1Ctrl];

Існує спадковість шаблонів. У вікні, що відображається в рядку 6, видно обидва шаблони контролерів [mainCtrl] та [pagexCtrl]. Якщо дві змінні в цих шаблонах мають однакову назву, одна з них приховає іншу. Щоб уникнути такого конфлікту імен, ми створюємо чотири шаблони під чотирма назвами:

page
contrôleur
modèle
ligne du code
контейнер
mainCtrl
рука
11
сторінка 1
page1Ctrl
сторінка 1
7
сторінка 2
page2Ctrl
сторінка 2
8
сторінка 3
page3Ctrl
сторінка 3
9
  • рядок 12: визначає елемент [text] у шаблоні [main];

Рядки 7–11 мають дуже особливий наслідок: вони визначають [$scope] контролера [mainCtrl] і в ньому створюють чотири змінні [main, page1, page2, page3]. Ці чотири змінні будуть використовуватися як відповідні шаблони контейнера та трьох подань, які він по черзі міститиме.

3.7.15.6. Навігаційна панель

Панель навігації визначається в контейнері наступним чином:


    <div class="container" ng-controller="mainCtrl">
        <!-- панель навігації -->
        <ng-include src="'views/navbar.html'"></ng-include>

        <!-- поточний вигляд -->
        <ng-view></ng-view>
</div>

Навігаційна панель визначена у рядку 3. Це означає, що вона знає лише шаблон [main]. Її код такий:


<div class="navbar navbar-inverse navbar-fixed-top" role="navigation">
  <div class="container">
    <div class="navbar-header">
      <button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
        <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="navbar-brand" href="#">RdvMedecins</a>
    </div>
    <div class="collapse navbar-collapse">
      <ul class="nav navbar-nav">
        <li class="active">
          <a href="">
            <span ng-click="main.showPage1()">Page 1</span>
          </a>
        </li>
        <li class="active">
          <a href="">
            <span ng-click="main.showPage2()">Page 2</span>
          </a>
        </li>
        <li class="active">
          <a href="">
            <span ng-click="main.showPage3()">Page 3</span>
          </a>
        </li>
      </ul>
    </div>
  </div>
</div>
  • у рядках 16, 21, 26 використовуються методи моделі [main];
  • рядок 16: клік на посилання [Page1] запустить виконання методу [$scope.main.showPage1]. Цей метод визначено в контролері [mainCtrl] наступним чином:

      // глобальний шаблон
      var main = $scope.main = {};
      main.text = "[Modèle global]";

      // методи, доступні для подання
      main.showPage1 = function () {
        $location.path("/page1");
};
  • у рядку 6 наведеного вище коду видно, що метод [main.showPage1] насправді є методом [$scope.main.showPage1]. Отже, саме цей метод і буде виконано;
  • рядок 7: змінюємо URL у додатку на [/page1]. Повернемося до маршрутизації, яка була визначена в головному модулі:

  $routeProvider.when("/page1",
    {
      templateUrl: "views/page1.html",
      controller: 'page1Ctrl'
});

бачимо, що фрагмент [views/page1.html] буде вставлений у контейнер, а його контролером є [page1Ctrl].

3.7.15.7. Вигляд [/page1] та його контролер

Фрагмент [views/page1.html] має такий вигляд:


<h1>Page 1</h1>
<div class="alert alert-info">
  <ul>
    <li>Modèle global : {{main.text}}</li>
    <li>Modèle local : {{page1.text}}</li>
  </ul>
</div>

Нагадаємо, що у поданні, вставленому в контейнер, видно модель [main]. Саме це ми хочемо перевірити в рядку 4. Крім того, контролер [page1Ctrl] фрагмента [views/page1.html] визначає модель [page1]. Саме вона використовується в рядку 5.

Код контролера [page1Ctrl] такий:


angular.module("rdvmedecins")
  .controller('page1Ctrl', ['$scope',
    function ($scope) {

      // шаблон сторінки 1
      var page1=$scope.page1;
      page1.text="[Modèle local dans page 1]";
}]);
  • рядок 2: вставлений тут [$scope] не є порожнім. Оскільки контролер [page1Ctrl] керує областю, вставленою в контейнер, що керується [mainCtrl], [$scope] у рядку 2 містить елементи [$scope], визначеного контролером [mainCtrl]. Це важливо розуміти. [$scope], визначений контролером [mainCtrl], містить такі елементи: [main, page1, page2, page3]. Це означає, що ми маємо доступ до моделей усіх представлень. Це не обов’язково бажано, але в даному випадку саме так і є. У фінальній версії клієнта Angular ми використаємо цю особливість для зберігання в моделі [main] інформації, яка має бути спільною для всіх переглядів. Це буде концепція, аналогічна концепції «сесії» на стороні сервера;
  • рядок 6: з [$scope] отримуємо модель [page1] зі сторінки 1, а потім працюємо з нею (рядок 7). У результаті отримуємо таке відображення:
 

Види [/page2] та [/page3] побудовані за тим самим шаблоном, що й вид [/page1] (див. знімки екрана на сторінці 240).

3.7.15.8. Керування навігацією

Тепер ми хочемо керувати навігацією таким чином: [page1 --> page2 --> page3 --> page1]. Отже, якщо користувач перебуває на сторінці 1 ([/page1]) і вводить у браузері URL або [/page3], то така навігація не повинна бути прийнята, і користувач має залишитися на сторінці 1.

Щоб досягти цього результату, ми змінюємо контролери сторінок наступним чином:


angular.module("rdvmedecins")
  .controller('page1Ctrl', ['$scope', '$location',
    function ($scope, $location) {
      // навігація дозволена?
      var main = $scope.main;
      if (main.lastUrl && main.lastUrl != '/page3') {
        // повернення до останньої URL
        $location.path(main.lastUrl);
        return;
      }
      // зберігаємо URL сторінки
      main.lastUrl = '/page1';
      // шаблон сторінки
      var page1 = $scope.page1;
      page1.text = "[Modèle local dans page 1]";
    }]);
  • рядок 12: коли сторінка відображатиметься, ми збережемо її URL у моделі [main.lastUrl]. Тут ми використовуємо концепцію, про яку говорили раніше: використання шаблону [main] для зберігання інформації, спільної для всіх переглядів. У даному випадку це останній переглянутий URL;
  • код у рядках 4–12 дублюється та адаптується до трьох переглядів. Тут ми перебуваємо в перегляді [/page1];
  • рядок 5: отримуємо модель [main];
  • рядок 6: якщо шаблон [main.lastUrl] існує і відрізняється від [/page3], то перехід заборонено (останній переглянутий шаблон URL існує і не є /page3);
  • рядок 8: тоді повертаємося до останнього відвіданого URL;

Давайте спробуємо:

  • у [1] ми перебуваємо на сторінці 1 і вводимо URL зі сторінки 3 у [2];
  • у [3] перехід не відбувся, і ми повернулися до URL на сторінці 1;

3.7.16. Висновок

Ми розглянули всі випадки використання, з якими ми зіткнемося у фінальній версії клієнта Angular. Під час його презентації ми більше зосередимося на функціональних можливостях додатка, ніж на деталях його реалізації. Щодо останніх, ми обмежимося посиланням на приклад, що ілюструє розглянутий випадок використання.

3.8. Остаточна версія клієнта Angular

3.8.1. Структура проєкту

Остаточний проект має такий вигляд:

  • [1] — весь проект. [app.html] — головна сторінка додатка;
  • у [2] — контролери;
  • у [3] — директиви;
  • у [4] — сервіси та модуль Angular [main.js] додатка;
  • у [5] — різні види, які вставляються в головну сторінку [app.html];

3.8.2. Залежності проєкту

Залежності проекту такі:

 

Роль цих різних елементів було пояснено в розділі 3.4, на сторінці 134.

3.8.3. Шаблон [app.html]

Шаблон виглядає наступним чином:


<!DOCTYPE html>
<html ng-app="rdvmedecins">
<head>
  <title>RdvMedecins</title>
  <!-- META -->
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta name="description" content="Angular client for RdvMedecins">
  <meta name="author" content="Serge Tahé">
  <!-- CSS -->
  <link rel="stylesheet" href="bower_components/bootstrap/dist/css/bootstrap.min.css"/>
  <link href="bower_components/bootstrap/dist/css/bootstrap-theme.min.css" rel="stylesheet"/>
  <link href="bower_components/bootstrap-select/bootstrap-select.min.css" rel="stylesheet"/>
  <link href="assets/css/rdvmedecins.css" rel="stylesheet"/>
  <link href="assets/css/footable.core.min.css" rel="stylesheet"/>
</head>
<!-- контролер [appCtrl], шаблон [app] -->
<body ng-controller="appCtrl">
<div class="container">
 ...
</div>
<!-- ядро Bootstrap JavaScript ================================================== -->
<script type="text/javascript" src="bower_components/jquery/dist/jquery.min.js"></script>
<script type="text/javascript" src="bower_components/bootstrap/dist/js/bootstrap.min.js"></script>
<script type="text/javascript" src="bower_components/bootstrap-select/bootstrap-select.min.js"></script>
<script src="bower_components/footable/js/footable.js" type="text/javascript"></script>
<!-- AngularJS -->
<script type="text/javascript" src="bower_components/angular/angular.min.js"></script>
<script type="text/javascript" src="bower_components/angular-ui-bootstrap-bower/ui-bootstrap-tpls.min.js"></script>
<script type="text/javascript" src="bower_components/angular-route/angular-route.min.js"></script>
<script type="text/javascript" src="bower_components/angular-translate/angular-translate.min.js"></script>
<script type="text/javascript" src="bower_components/angular-base64/angular-base64.min.js"></script>
<!-- модулі -->
<script type="text/javascript" src="modules/main.js"></script>
<!-- сервіси -->
<script type="text/javascript" src="services/config.js"></script>
<script type="text/javascript" src="services/dao.js"></script>
<script type="text/javascript" src="services/utils.js"></script>
<!-- директиви -->
<script type="text/javascript" src="directives/waiting.js"></script>
<script type="text/javascript" src="directives/errors.js"></script>
<script type="text/javascript" src="directives/footable.js"></script>
<script type="text/javascript" src="directives/debug.js"></script>
<script type="text/javascript" src="directives/list.js"></script>
<!-- контролери -->
<script type="text/javascript" src="controllers/appController.js"></script>
<script type="text/javascript" src="controllers/loginController.js"></script>
<script type="text/javascript" src="controllers/homeController.js"></script>
<script type="text/javascript" src="controllers/agendaController.js"></script>
<script type="text/javascript" src="controllers/resaController.js"></script>
</body>
</html>
  • рядок 18: зверніть увагу, що [appCtrl] є контролером головної сторінки;
  • рядки 19–21: вміст шаблону;

Цей вміст має такий вигляд:


<div class="container">
  <!-- навігаційні панелі -->
  <ng-include src="'views/navbar-start.html'" ng-show="app.navbarstart.show"></ng-include>
  <ng-include src="'views/navbar-run.html'" ng-show="app.navbarrun.show"></ng-include>
  <!-- джамботрон -->
  <ng-include src="'views/jumbotron.html'"></ng-include>
  <!-- заголовок сторінки -->
  <div class="alert alert-info" ng-show="app.titre.show" translate="{{app.titre.text}}"
       translate-values="{{app.titre.model}}"></div>
  <!-- помилки на сторінці -->
  <errors model="app.errors" ng-show="app.errors.show"></errors>
  <!-- повідомлення про очікування -->
  <waiting model="app.waiting" ng-show="app.waiting.show"></waiting>
  <!-- поточний вигляд -->
  <ng-view></ng-view>
  <!-- налагодження -->
  <debug model="app" ng-show="app.debug.on"></debug>
</div>

Незалежно від того, який вигляд відображається, він завжди матиме такі елементи:

  • рядки 3–4: панель керування. Дві панелі в рядках 3 і 4 взаємовиключні;

Image

Image

  • рядок 6: логотип / текст програми:

Image

  • рядок 8: заголовок

Image

  • рядок 11: повідомлення про помилки:

Image

  • рядок 13: повідомлення про очікування:

Image

  • рядок 17: інформація про налагодження:

Image

Усі вищезазначені елементи контролюються директивою [ng-show / ng-hide], завдяки якій, навіть якщо вони присутні, вони не обов’язково відображаються.

3.8.4. Види додатка

У коді шаблону сторінки ми маємо:


<div class="container">
  ...
  <!-- поточний вигляд -->
  <ng-view></ng-view>
  ...
</div>

У рядку 4 приймаються різні види додатка. Вони визначені в модулі [main.js]:

Image

Роль конфігурації різних маршрутів було пояснено в розділі 3.7.15.4, на сторінці 242.

Вигляд [login.html] є порожнім, тобто він не додає жодних елементів до тих, що вже присутні на головній сторінці.

Вигляд [home.html] додає до головної сторінки такий елемент:

Image

Вигляд [agenda.html] додає до шаблону сторінки такий елемент:

Image

Вигляд [resa.html] додає до шаблону сторінки такий елемент:

Image

3.8.5. Функціональні можливості додатка

Види клієнта Angular вже були представлені в розділі 1.3.3, на сторінці 7. Щоб полегшити читання цього нового розділу, ми наводимо їх тут ще раз. Перший вид має такий вигляд:

  • у [6] — це сторінка входу в додаток. Це додаток для запису на прийом до лікарів;
  • [7] — прапорець, що дозволяє ввімкнути або вимкнути режим [debug]. Останній характеризується наявністю рамки [8], яка відображає шаблон поточного виду;
  • у [9] — штучна тривалість очікування в мілісекундах. За замовчуванням вона дорівнює 0 (очікування відсутнє). Якщо N — значення цього часу очікування, будь-яка дія користувача буде виконана після очікування тривалістю N мілісекунд. Це дозволяє побачити, як додаток реалізує управління очікуванням;
  • у [10] — URL сервера Spring 4. Якщо слідувати попередньому, це [http://localhost:8080];
  • у [11] та [12] — ідентифікатор та пароль користувача, який бажає скористатися додатком. Є два користувачі: admin/admin (логін/пароль) з роллю (ADMIN) та user/user з роллю (USER). Лише роль ADMIN має право користуватися додатком. Роль USER наведено лише для того, щоб показати, що відповідає сервер у цьому випадку використання;
  • у [13] — кнопка, що дозволяє підключитися до сервера;
  • у [14] — мова додатка. Їх дві: французька (за замовчуванням) та англійська.
  • у [1] — вхід у систему;
  • після входу можна вибрати лікаря, до якого хочете записатися на прийом [2], та день прийому [3];
  • запитуємо [4], щоб переглянути розклад обраного лікаря на обраний день;
  • отримавши розклад лікаря, можна забронювати час прийому [5];
  • у [6] вибирають пацієнта для прийому та підтверджують цей вибір у [7];

Після підтвердження прийому система автоматично повертає до календаря, де новий прийом тепер занесено. Цей прийом можна буде згодом видалити за допомогою [7].

Основні функції описано. Вони прості. Ті, що не були описані, — це функції навігації для повернення до попереднього екрана. На завершення розглянемо управління мовою:

  • у [1] можна перейти з французької на англійську;
  • на [2] — перехід на англійську мову, включаючи календар;

3.8.6. Модуль [main.js]

Модуль [main.js] визначає модуль Angular, який керуватиме додатком:

 
  • рядок 4: модуль називається [rdvmedecins];
  • рядок 5: модуль [ngRoute] використовується для маршрутизації URL;
  • рядок 6: модуль [translate] використовується для інтернаціоналізації текстів;
  • рядок 7: модуль [base64] використовується для кодування рядка «login:password» у Base64;
  • рядок 8: модуль [ngLocale] використовується для інтернаціоналізації календаря;
  • рядок 9: модуль [ui.bootstrap] використовується для роботи з календарем;
  • рядок 12: налаштування маршрутів;
  • рядок 40: інтернаціоналізація повідомлень;

3.8.7. Контролер головної сторінки

Нагадаємо код HTML головної сторінки [app.html]:


<body ng-controller="appCtrl">
<div class="container">
...

У рядку 1 весь основний блок (body) головної сторінки контролюється контролером [appCtrl]. Завдяки своєму розташуванню він є загальним і головним контролером додатка. Як було пояснено в розділі 3.7.15, модель, побудована цим контролером, успадковується всіма поданнями, які будуть вставлені в головну сторінку.

Його код такий:


angular.module("rdvmedecins")
  .controller("appCtrl", ['$scope', 'config', 'utils', '$location', '$locale',
    function ($scope, config, utils, $location, $locale) {

      // налагодження
      utils.debug("[app] init");

      // ----------------------------------------ініціалізація сторінки
      // шаблони # сторінок
      $scope.app = {waitingTimeBeforeTask: config.waitingTimeBeforeTask};
      $scope.login = {};
      $scope.home = {};
      $scope.agenda = {};
      $scope.resa = {};
      // шаблон поточної сторінки
      var app = $scope.app;
      ...

      // ---------------------------------- методи

      // скасування поточного завдання
      app.cancel = function () {
...
      };

      // вихід із системи
      app.deconnecter = function () {
        ...
      };

      // цей код має залишитися тут, оскільки він посилається на попередню функцію [cancel]
      app.waiting = {title: {text: config.msgWaitingInit, values: {}}, cancel: app.cancel, show: true};
    }])
;

У рядках 10–14 визначено п’ять шаблонів, які використовуються в додатку:

Modèle
Vue
Contrôleur
$scope.app
app.html
appCtrl
$scope.login
login.html
loginCtrl
$scope.home
home.html
homeCtrl
$scope.resa
resa.html
resaCtrl
$scope.agenda
agenda.html
agendaCtrl

Важливо розуміти, що об’єкт [$scope], який є шаблоном контролера головної сторінки, успадковується всіма поданнями та контролерами. Таким чином, контролер [loginCtrl] має доступ до елементів [$scope.app, $scope.login, $scope.home, $scope.resa, $scope.agenda]. Інакше кажучи, контролер має доступ до моделей інших контролерів. У досліджуваному додатку ретельно уникають використання цієї можливості. Так, наприклад, контролер [loginCtrl] працює лише з двома моделями:

  • своєю — [$scope.login];
  • та з моделлю батьківського контролера [$scope.app];

Те саме стосується всіх інших контролерів. Модель [$scope.app] використовуватиметься як спільна пам’ять між різними контролерами. Коли контролер C1 повинен передати інформацію контролеру C2, це відбуватиметься таким чином:

У [C1]:

$scope.app.info=value ;

У [C2]:

var value=$scope.app.info ;

В обох випадках $scope успадковується від контролера [appCtrl] і, отже, є ідентичним (це покажчик) у [C1] та [C2]. Об’єкт [$scope.app], який слугує спільною пам’яттю між контролерами, у коментарях часто називатимуть session, наслідуючи сесію, що використовується у класичних веб-додатках і позначає спільну пам’ять між послідовними запитами HTTP.

Повернемося до коду контролера [appCtrl]:


      // шаблони # сторінок
      $scope.app = {waitingTimeBeforeTask: config.waitingTimeBeforeTask};
      $scope.login = {};
      $scope.home = {};
      $scope.agenda = {};
      $scope.resa = {};
      // шаблон поточної сторінки
      var app = $scope.app;
      // [app.debug] та [utils.verbose] завжди мають бути синхронізовані
      app.debug = utils.verbose;
      app.debug.on = config.debug;
      // наразі немає заголовка сторінки
      app.titre = {show: false};
      // навігаційних панелей немає
      app.navbarrun = {show: false};
      app.navbarstart = {show: false};
      // помилок немає
      app.errors = {show: false};
      // локаль за замовчуванням
      angular.copy(config.locales['fr'], $locale);
      // поточний вигляд
      app.view = {url: undefined, model: {}, done: false};
      // поточне завдання
app.task = app.view.model.task = {action: utils.waitForSomeTime(app.waitingTimeBeforeTask), isFinished: false};
  • рядок 8: [$scope.app] буде шаблоном головної сторінки. Це також буде спільна пам'ять між різними контролерами. Замість того, щоб скрізь писати [$scope.app.champ=value], покажчик [$scope.app] присвоюється змінній [app], і тоді ми будемо писати [app.champ=value]. Потрібно просто пам’ятати, що [app] — це шаблон, представлений на головній сторінці;
  • рядок 11: [app.debug.on] — це логічне значення, яке керує режимом debug програми. За замовчуванням він має значення true. Його значення пов’язане з прапорцем [debug] на панелях навігації;
  • рядок 15: [app.navbarrun.show] керує відображенням наступної панелі навігації:

Image

  • рядок 16: [app.navbarstart.show] керує відображенням наступної навігаційної панелі:

Image

  • рядок 18: [app.errors] — це шаблон панелі помилок;

Image

  • рядок 22: [app.view] міститиме інформацію про поточний вигляд, який наразі відображається тегом [ng-view] на головній сторінці. У ньому ми зазначимо таку інформацію:
    • [url]: URL поточного виду, наприклад [/agenda];
    • [model]: шаблон поточного виду, наприклад [$scope.agenda];
    • [done]: для vrai вказує, що поточний вигляд завершив свою роботу і відбувається перехід до іншого вигляду;

Ці дані використовуються для керування навігацією.

  • рядок 24: запускає асинхронне завдання — імітацію очікування. На асинхронне завдання вказують два покажчики: [app.view.model.task.action] та [app.task];

У контролері [appCtrl] було виокремлено два методи:


      // скасування поточного завдання
      app.cancel = function () {
...
      };

      // вихід із системи
      app.deconnecter = function () {
        ...
};
  • рядок 2: функція [app.cancel] слугує для скасування поточного завдання, для якого наразі відображається повідомлення про очікування. Усі подання показують це повідомлення, тому скасування завдання відбуватиметься саме тут;
  • рядок 7: функція [app.deconnecter] повертає користувача на сторінку автентифікації. Усі види, крім виду [/login], надають цю можливість;

Функція [app.deconnecter] виглядає наступним чином:


      // вихід
      app.deconnecter = function () {
        // повернення на сторінку входу
        $location.path(config.urlLogin);
};
  • рядок 4: повернення на сторінку входу URL [/login];

3.8.8. Управління асинхронним завданням

У нашому додатку в будь-який момент часу виконується лише одне асинхронне завдання. Можливо, їх може бути декілька. Наприклад, під час запуску додаток запитує у веб-сервісу список лікарів, а потім — список клієнтів за допомогою двох послідовних запитів HTTP. Те саме можна було б зробити за допомогою двох одночасних запитів HTTP. Angular надає інструменти для такого управління. У даному випадку ми не обрали цей варіант.

Поточне завдання скасовується за допомогою такого коду в контролері [appCtrl]:


      // скасування поточного завдання
      app.cancel = function () {
        utils.debug("[app] cancel task");
        // скасування асинхронного завдання поточного виду
        var task = app.view.model.task;
        task.isFinished = true;
        task.action.reject();

        ...
};
  • рядок 5: завдання шукається в [app.view.model.task]. Крім того, усі контролери забезпечать, щоб їхні асинхронні завдання посилалися на цей об’єкт;
  • рядок 6: для позначення того, що завдання завершено;
  • рядок 7: для завершення завдання з помилкою. Ця нотація відрізняється від тієї, що використовувалася в розглянутих прикладах Angular:
    • у прикладах об’єкт [task] був об’єктом [$q.defer()], який можна було завершити;
    • у фінальній версії об’єкт [task] — це об’єкт із полями [action, isFinished], де [action] — це об’єкт [$q.defer()], якийможна завершити, а [isFinished] — булеве значення, яке вказує, що дія завершена;

Розглянемо життєвий цикл об’єкта [task] на прикладі. Під час запуску, після контролера [appCtrl], управління переходить до контролера [loginCtrl] для відображення подання [views/login.html]. Його код ініціалізації такий:


      // отримання батьківської моделі
      var login = $scope.login;
      var app = $scope.app;
      // поточний вигляд
app.view = {url: config.urlLogin, model: login, done: false};

У рядку 5 ми бачимо [model=login]. Це означає, що при зміні об’єкта [login] змінюється об’єкт [app.view.model], а отже, і [$scope.app.view.model]. Коли в контролері [loginCtrl] потрібно виконати імітовану затримку, записуємо:


// імітований очікування
var task = login.task = {action: utils.waitForSomeTime(app.waitingTimeBeforeTask), isFinished: false};

Додавши поле [task] до об’єкта [login], воно, отже, було додано до об’єкта [$scope.app.view.model]. Якщо користувач скасує очікування, код у [appCtrl.cancel]:


// модель поточної сторінки
var app = $scope.app;
...
var task = app.view.model.task;
task.isFinished = true;
task.action.reject();

успішно завершить імітовану очікування (рядки 4–6).

3.8.9. Контроль навігації

У додатку використовуються такі правила навігації:

URL cible
URL précédente
Navigation autorisée
/login
будь-яке
так
/home
/login
так, якщо контролер [loginCtrl] повідомив, що завершив свою роботу

/home
так

/календар
так
/agenda
/домашня сторінка
так, якщо контролер [homeCtrl] повідомив, що завершив свою роботу

/resa
так

/календар
так
/resa
/календар
так, якщо контролер [homeCtrl] повідомив, що завершив свою роботу

/resa
так

Це реалізовано за допомогою такого коду:

Для [agendaCtrl]:

Image

  • рядки 11–20: реалізація правила навігації;
  • рядок 26: новий поточний вигляд;

Для [resaCtrl]:

Image

  • рядки 12–20: реалізація правила навігації:
  • рядок 27: новий поточний вигляд;

Для [loginCtrl]:

Image

  • тут немає жодного контролю навігації, оскільки правило передбачає, що перейти до URL [/login] можна з будь-якого місця. Отже, якщо користувач введе цей URL у свій браузер, це спрацює незалежно від поточного перегляду;
  • рядок 16: новий поточний вигляд;

Код для контролера [homeCtrl] наведено в розділі 3.8.7.

Нарешті, для такого правила, як:

/agenda
/home
так, якщо контролер [homeCtrl] повідомив, що завершив свою роботу

ось приклад коду, який переводить з URL [/home] на URL [/agenda]:

 

Вище ми перебуваємо в методі [afficherAgenda] контролера [homeCtrl]. Користувач запросив розклад прийому лікаря.

  • рядок 107: обіцянка завдання HTTP;
  • рядок 109: змінна [app] була ініціалізована значенням [$scope.app]. Як ми вже бачили, останній об’єкт використовується як шаблон для подання [app.html]. Цей шаблон [$scope.app] також використовується для зберігання інформації, яка має бути спільною для всіх подань;
  • рядок 111: аналізується код помилки, повернутий завданням;
  • рядок 113: результат [result.data] записується в шаблон [app];
  • рядок 116: контролер [homeCtrl] передасть управління контролеру [agendaCtrl]. Він повідомляє йому, що завершив свою роботу, використовуючи код із рядка 115. Цей код буде оброблений контролером [agendaCtrl] наступним чином:

Image

  • рядок 11: отримується об’єкт [$scope.app.view];
  • рядок 15: обробка поля [$scope.app.view.done], ініціалізованого [homeCtrl];

3.8.10. Служби

  

Сервіси [config, utils, dao] — це ті, що вже були описані під час презентації Angular:

  • сервіс [config] було представлено в розділі 3.7.4;
  • сервіс [utils] було представлено в розділі 3.7.5;
  • сервіс [dao] був представлений у розділі 3.7.6;

Для довідки нагадуємо структуру цих послуг:

Сервіс [config]

  • у [1]: бачимо, що код налічує приблизно 250 рядків. Основну частину цього коду становить винесення ключів інтернаціоналізованих повідомлень [2]. Ми уникаємо жорсткого вбудовування цих ключів у код;

Сервіс [utils]

 
  • рядок 8: ми ще не зустрічали змінну [verbose]. Вона керує функцією [debug] наступним чином:
 
  • рядки 22–25: функція [utils.debug] нічого не робить, якщо значення [verbose.on] дорівнює false. Ця змінна пов’язана зі змінною контролера [appCtrl]:
 
  • рядок 21: [app.debug] приймає значення покажчика [utils.verbose]. Отже, будь-яка зміна, внесена до [app.debug], буде також внесена до [utils.verbose];
  • рядок 22: початкове значення [app.debug.on] береться з файлу конфігурації. За замовчуванням це значення true.. Це значення може змінюватися з часом. Користувач має можливість змінити його в панелях навігації:
 
  • рядок 45: прапорець (type=checkbox) дозволяє змінити значення [app.debug.on] (атрибут ng-model);

Сервіс [dao]

 

3.8.11. Директиви

  

Директиви [errors, footable, list, waiting] — це ті, що вже були описані під час презентації Angular:

  • директива [footable] була представлена в розділі 3.7.8.6;
  • директива [list] була введена у розділі 3.7.12;
  • директиви [errors] та [waiting] були введені у параграфі 3.7.14;

Ми не зустрічали директиву [debug]. Вона має такий вигляд:

 

Файл [debug.html], на який посилається рядок 11, має такий вигляд:

 
  • рядок 2: директива [debug] відображає свій шаблон у форматі JSON у банері Bootstrap (рядок 1);

Ця директива використовується лише на головній сторінці [app.html]:

 
  • директива [debug] використовується у рядку 35. Отже, вона відображає форму JSON шаблону [$scope.app] у режимі налагодження (атрибут ng-show). У результаті виходить щось на зразок цього:

Для інтерпретації цього потрібно добре знати код, але коли це знання є, наведена вище інформація стає корисною для налагодження. Тут виділено елементи відображеної моделі [$scope.app]. Нагадаємо, що [$scope.app] — це пам’ять, спільна для контролерів;

  • [waitingBeforeTask] — імітований час очікування перед будь-яким запитом HTTP;
  • [debug]: режим налагодження — обов’язково дорівнює true, якщо відображається ця панель;
  • [navbarrun]: логічне значення, яке керує відображенням наступної навігаційної панелі:

Image

  • [navbarstart]: логічне значення, яке керує відображенням наступної навігаційної панелі:

Image

  • [errors]: шаблон директиви [errors];
  • [view]: містить інформацію про поточну сторінку;
  • [waiting]: шаблон директиви [waiting];
  • [serverUrl, username, password]: дані для підключення до веб-сервісу;
  • [medecins]: шаблон для директиви [list], що застосовується до лікарів;
  • [clients]: те саме для клієнтів;
  • [menu]: керує відображеними пунктами меню. Вони визначені у [navbar-run.html]:

Image

Пункти меню знаходяться у рядках 16, 23, 29 та 36.

  • [formattedJour]: день, обраний у календарі у форматі «aaaa-mm-jj»;
  • [agenda]: розклад лікаря. У ньому є вільні (rv==null) та заброньовані часові проміжки. Для останніх вказано ім’я клієнта, який здійснив бронювання;
  • [selectedCreneau]: часовий проміжок, обраний для бронювання;

3.8.12. Контролер [loginCtrl]

  

Контролер [loginCtrl] пов’язаний з поданням [views/login.html], яке у поєднанні з головною сторінкою формує таку сторінку:

Image

Контролер [loginCtrl] має такий вигляд:

Image

  • рядок 13: [login] буде шаблоном поточного виду;
  • рядок 14: [app] — це спільна пам'ять між контролерами;
  • рядок 16: [app.view] заповнюється даними поточного виду;

Цей код ініціалізації буде присутній у кожному контролері. Для контролера C1, що відповідає погляду V1 із моделлю M1, ми отримаємо такий код ініціалізації:

1
2
3
var app=$scope.app;
var M1=$scope.M1;
app.view={url: config.urlV1, model:M1, done:false};
  • рядок 18: можливо, ви пам’ятаєте, що [appCtrl] запустив імітований очікуваний процес, на який посилається об’єкт [app.task.action]. Ми використовуємо [promise] цього завдання, щоб дочекатися його завершення;
  • рядок 39: метод [login.setLang] обробляє зміну мови;
  • рядок 47: метод [login.authenticate] обробляє автентифікацію користувача;

Розглянемо основні етапи роботи методу автентифікації:

Image

  • рядки 50–51: [app.waiting] — це шаблон панелі очікування;
  • рядок 53: [app.errors] — це шаблон смуги повідомлень про помилки;
  • рядок 55: запускається імітований процес очікування. На об’єкт [action, isFinished] посилається [login.task], а отже, оскільки [app.view.model=login], то й [app.view.model.task]. Нагадаємо, що це є умовою для того, щоб завдання могло бути скасоване;
  • рядок 57: після закінчення імітованого очікування завантажуються дані про лікарів;
  • рядок 62: коли запит на лікарів отримано, його аналізують. Якщо лікарів вдалося залучити, то запускається запит на клієнтів;
  • рядок 83: аналізується отримана відповідь і відображається кінцевий вигляд. Це здійснюється за допомогою такого коду:

Image

  • рядок 87: булеве значення [task.isFinished] встановлюється на true у таких випадках:
    • користувач скасував очікування;
    • запит лікарів завершився з помилкою;
  • рядки 91–98: випадок, коли були клієнти;
  • рядок 93: [app.clients] — це шаблон директиви [list], яка відображатиме клієнтів у випадаючому списку;
  • рядки 97–98: готуємося до зміни перегляду (рядок 98), але перед цим вказуємо, що контролер завершив свою роботу (рядок 97). Нагадаємо, що [$scope.app.view.done] використовується для управління навігацією;

Важливо зазначити, що дані про лікарів та клієнтів були збережені в кеші браузера. Відтепер їх більше не потрібно запитувати у веб-сервісі.

3.8.13. Контролер [homeCtrl]

  

Контролер [homeCtrl] пов’язаний з поданням [views/home.html], яке у поєднанні з головною сторінкою формує таку сторінку:

Image

Структура контролера [homeCtrl] є такою:

Image

  • рядки 12–20: це контроль навігації. Він є у всіх контролерах, крім [loginCtrl], оскільки сторінка [/login.html] доступна без будь-яких умов;

Image

  • рядки 25–28: тут ми бачимо рядки, аналогічні тим, що зустрічаються в контролері [loginCtrl]. Таким чином, [home] є шаблоном подання, пов’язаним із контролером;
  • рядок 33: атрибут, з яким ми ще не стикалися. Це модель заголовного банера подання:

Image

  • рядок 36: [home.datepicker] — це шаблон календаря;
  • рядок 38: [app.menu] — це шаблон меню навігаційної панелі. Тут буде присутній варіант [Agenda]. Саме він дозволяє переглянути розклад прийому лікаря;

Нарешті, контролер має два методи:

Image

Відображення розкладу (рядок 51) розглядалося в розділі 3.7.8.

3.8.14. Контролер [agendaCtrl]

  

Контролер [agendaCtrl] пов’язаний з поданням [views/agenda.html], яке у поєднанні з шаблоном сторінки формує таку сторінку:

Image

Структура контролера [agendaCtrl] є такою:

Image

  • рядки 10–20 забезпечують управління навігацією;

Image

  • рядки 23–26: [agenda] буде шаблоном подання, пов’язаного з контролером [agendaCtrl];
  • рядки 36–44: [app.titre] — це шаблон наступного заголовка:

Image

  • рядок 46: у меню буде опція [Home / Accueil]:

Image

Методи контролера такі:

Image

  • рядок 95: метод [agenda.supprimer] розглянуто в розділі 3.7.9;

Метод [agenda.home] є методом суто навігаційним:

Image

Метод [agenda.reserver] є таким:

Image

  • рядок 73: параметром функції [reserver] є номер часового проміжку (id);
  • рядки 77–86: призначені для пошуку часового проміжку з цим ідентифікатором;
  • рядок 82: знайдений часовий проміжок заноситься до спільної пам’яті [app]. Контролер [resaCtrl], який перейме управління (рядок 90), використає цю інформацію для відображення свого заголовка;
  • рядки 89–90: перехід до [/resa.html];

3.8.15. Контролер [resaCtrl]

  

Контролер [resaCtrl] пов’язаний з поданням [views/resa.html], яке у поєднанні з головною сторінкою формує наступну сторінку:

Image

Структура контролера [resaCtrl] є такою:

Image

  • рядки 12–20: управління навігацією;

Image

  • рядки 24–27: [resa] буде шаблоном поточного виду;
  • рядки 38–45: [app.titre] — шаблон наступного заголовка:

Image

  • рядок 47: відображаються два пункти меню:

Image

Методи контролера такі:

Image

Метод [resa.valider] було розглянуто в розділі 3.7.9.

3.8.16. Управління мовами

Усі контролери надають такий метод [setLang]:

Image

Його можна було б винести в контролер [appCtrl].