UPDATE
Результат можно посмотреть так: Скачать приложение Allcountjs на Google Play Market или Apple App Store. Запустить дему CRM. И в мобильное приложении вставить ссылку на сгенеренную дему.
Правда это приложение универсальное и не содержит кастомной клиентской логики и интерфейсов, поэтому канбан доски в нем не будет. Но зато в нем можно запустить любое AllcountJS приложение, например что-нибудь ещё из демо галереи.

Сейчас в большинстве проектов по разработке ПО требуется одновременно иметь как веб, так и мобильную версию приложения. Обычно это довольно непросто, но с AllcountJS эта задача упрощается в разы. В предыдущей статье мы создали простую CRM, которая позволяет отслеживать статус продажи клиентам на наглядной канбан доске.
В этой статье мы создадим мобильное приложение для этой CRM. Кроме фреймворка AllcountJS будем использовать ещё и ionic framework, о котором на хабре тоже уже писали тут и тут.

image

Установка инструментов и начальные настройки


Если вы выполнили все шаги из предыдущей статьи, то CRM должна быть доступна на http://localhost:9080.
В противном случае установим AllcountJS:
$ npm install -g allcountjs-cli

И создадим CRM из шаблона:
 $ allcountjs init --template cusdev-crm

Во время инициализации в первую очередь будет запрошено название директории для приложения. Я использую crm-app-allcountjs. Далее установим все предлагаемые зависимости и запустим приложение:
$ cd crm-app-allcountjs && npm install
$ allcountjs run

Если всё прошло успешно, то CRM будет доступна по адресу http://localhost:9080.
Теперь установим ionic и создадим пустое мобильное приложение.
$ npm install -g cordova ionic

При необходимости, вы можете взглянуть на Getting Started with Ionic.
Создадим из шаблона приложение ionic:
$ ionic start crm-app-allcountjs blank
$ ionic serve

Если всё прошло успешно, то вы увидите шаблон мобильного приложения ionic. Теперь установим bower и allcountjs-ionic
$ npm install -g bower
$ bower install allcountjs-ionic --save

Мобильное приложение


Tеперь откроем crm-app-allcountjs/www/index.html и добавим зависимость allcountjs сразу после ionic.bundle.js
<script src="lib/underscore/underscore.js"></script>
<script src="lib/jquery/dist/jquery.js"></script>
<script src="lib/jquery.inputmask/dist/jquery.inputmask.bundle.js"></script>
<script src="lib/allcountjs-angular-base/allcount-base.js"></script>
<script src="lib/allcountjs-ionic/allcount-mobile.js"></script>

Ещё нужно вставить внутрь
<body ng-app="starter"></body>
строку
<ion-nav-view></ion-nav-view>

В итоге наш index.html должен выглядеть так:
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="initial-scale=1, maximum-scale=1, user-scalable=no, width=device-width">
    <title></title>
    <link href="lib/ionic/css/ionic.css" rel="stylesheet">
    <link href="css/style.css" rel="stylesheet">
    <!-- IF using Sass (run gulp sass first), then uncomment below and remove the CSS includes above
    <link href="css/ionic.app.css" rel="stylesheet">
    -->
    <!-- ionic/angularjs js -->
    <script src="lib/ionic/js/ionic.bundle.js"></script>  
    <script src="lib/underscore/underscore.js"></script>
    <script src="lib/jquery/dist/jquery.js"></script>
    <script src="lib/jquery.inputmask/dist/jquery.inputmask.bundle.js"></script>
    <script src="lib/allcountjs-angular-base/allcount-base.js"></script>  
    <script src="lib/allcountjs-ionic/allcount-mobile.js"></script>
    <!-- cordova script (this will be a 404 during development) -->
    <script src="cordova.js"></script>
    <!-- your app's js -->
    <script src="js/app.js"></script>
  </head>
  <body ng-app="starter">
    <ion-nav-view></ion-nav-view>
  </body>
</html>


Теперь изменим crm-app-allcountjs/www/js/app.js. В первую очередь добавим зависимость allcount-mobile:

angular.module('starter', ['ionic', 'allcount-mobile'])


Затем нам нужно задать адрес нашего сервера через lcApiConfig в методе run():

.run(function($ionicPlatform, lcApiConfig) { 
  lcApiConfig.setServerUrl('http://localhost:9080'); 
  ... 
})


Теперь остаётся прописать стандартные пути AllcountJS добавив вызов config()

.config(function ($stateProvider, $urlRouterProvider) { 
   $stateProvider 
   .setupStandardAllcountMainState('app', 'lib/allcountjs-ionic/templates')
   .setupStandardAllcountStates('app', 'lib/allcountjs-ionic/templates'); 
   $urlRouterProvider.otherwise('/app/main');
});

В итоге app.js должен выглядеть так:

angular.module('starter', ['ionic', 'allcount-mobile'])
.run(function($ionicPlatform, lcApiConfig) {
  lcApiConfig.setServerUrl('http://localhost:9080');
  $ionicPlatform.ready(function() {
    // Hide the accessory bar by default (remove this to show the accessory bar above the keyboard
    // for form inputs)
    if(window.cordova && window.cordova.plugins.Keyboard) {
      cordova.plugins.Keyboard.hideKeyboardAccessoryBar(true);
    }
    if(window.StatusBar) {
      StatusBar.styleDefault();
    }
  });
})
.config(function ($stateProvider, $urlRouterProvider) {
  $stateProvider
  .setupStandardAllcountMainState('app', 'lib/allcountjs-ionic/templates')
  .setupStandardAllcountStates('app', 'lib/allcountjs-ionic/templates');
  $urlRouterProvider.otherwise('/app/main');
});

Теперь проверим результат выполнив ionic serve из директории crm-app-allcountjs. Вы должны увидеть окно логина в систему. Логин и пароль администратора по-умолчанию admin/admin. После входа вы увидите меню приложения.

Канбан доска


image

Казалось бы что приложение готово. Но, если вы помните, то у нашей СРМ была канбан доска. Сделаем её аналог и для мобильного приложения. В общем случае клиентскую логику и какие-то особые интерфейсы придётся писать для мобильного приложения отдельно.

Создадим веб представление для нашего FlowBoard (www/flow-board.html):
<ion-view view-title="{{title}}">
    <ion-nav-buttons side="right">
        <a class="button button-icon icon ion-ios-plus-empty" ng-href="#/app/entity/{{mainEntityTypeId}}/new"></a>
    </ion-nav-buttons>
    <ion-content>
        <ion-list lc-list="mainEntityTypeId"
                  paging="{start: 0, count: 20}"
                  publish-methods="viewState.gridMethods"
                  infinite-scroll-end="viewState.infiniteScrollEnd"
                >
            <ion-item ng-repeat="item in items" ng-href="#/app/entity/{{mainEntityTypeId}}/{{item.id}}" class="card">
                <h2>{{item.name}} from {{item.company}}</h2>
                <p>{{item.lastContactDate | date}}</p>
            </ion-item>
            <p ng-if="items && !items.length" class="padding" style="text-align: center"><b lc-message="No records"></b></p>
        </ion-list>
        <ion-infinite-scroll
                ng-if="!viewState.infiniteScrollEnd"
                on-infinite="loadNextItems()"
                distance="1%"
                immediate-check="true">
        </ion-infinite-scroll>
    </ion-content>
</ion-view>

Добавим контроллер FlowBoardController в конец www/js/app.js (не забудьте убрать “;” в конце)

.controller('FlowBoardController', function ($scope) {
    $scope.viewState = {};
    $scope.mainEntityTypeId = 'FlowBoard';
    $scope.$on('$ionicView.enter',
        function () {
            $scope.viewState.gridMethods.updateGrid();
        }
    );
    $scope.loadNextItems = function () {
        $scope.viewState.gridMethods.infiniteScrollLoadNextItems().then(function () {
            $scope.$broadcast('scroll.infiniteScrollComplete');
        })
    };
});

И зарегистрируем новое состояние app.flowBoard в config() in www/js/app.js сразу после setupStandardAllcountMainState():

.config(function ($stateProvider, $urlRouterProvider) {
  $stateProvider
  .setupStandardAllcountMainState('app', 'lib/allcountjs-ionic/templates')
  .state('app.flowBoard', {
    url: "/entity/FlowBoard",
    views: {
      'menuContent': {
        templateUrl: "flow-board.html",
        controller: 'FlowBoardController'
      }
    }
  })
  .setupStandardAllcountStates('app', 'lib/allcountjs-ionic/templates');
  $urlRouterProvider.otherwise('/app/main');
})

Что же мы сделали? Мы использовали директиву lc-list в нашем представлении для загрузки в FlowBoard экземпляров сущностей, ng-repeat для отображения их в виде карточек (обратите внимание на класс card для экземпляра) и ion-infinite-scroll в FlowBoardController для бесконечного скролла. Самый хитрый момент тут, в том что маршрут app.flowBoard переопределяет стандартный маршрут app.entity. Это нужно для того что бы подменить отображение для сущности FlowBoard со стандартного на только что сделанное нами представление.

Уже не плохо, но это ещё не канбан доска. Теперь мы подгрузим наши статусы. Для того чтобы навигироваться по колонкам со статусами и фильтровать записи по соответствующему статусу, обернём lc-list в ion-slide повторённый для каждого статуса в ion-slide-box.

В итоге наш модифицированный www/flow-board.html будет выглядеть так:
<ion-view view-title="{{title}}">
    <ion-nav-buttons side="right">
        <a class="button button-icon icon ion-ios-plus-empty" ng-href="#/app/entity/{{mainEntityTypeId}}/new"></a>
    </ion-nav-buttons>
        <ion-slide-box on-slide-changed="updateTitle($index)" style='height:100%'>
            <ion-slide ng-repeat="column in boardColumns">
                <ion-content>
                    <ion-list lc-list="mainEntityTypeId"
                              paging="{start: 0, count: 20}"
                              filtering="columnFiltering(column)"
                              publish-methods="column.gridMethods"
                              infinite-scroll-end="column.infiniteScrollEnd"
                              >
                        <ion-item ng-repeat="item in items" ng-href="#/app/entity/{{mainEntityTypeId}}/{{item.id}}" class="card">
                            <h2>{{item.name}} from {{item.company}}</h2>
                            <p>{{item.lastContactDate | date}}</p>
                        </ion-item>
                        <p ng-if="items && !items.length" class="padding" style="text-align: center"><b lc-message="No records"></b></p>
                    </ion-list>
                    <ion-infinite-scroll
                                         ng-if="!column.infiniteScrollEnd"
                                         on-infinite="column.loadNextItems()"
                                         distance="1%"
                                         immediate-check="true">
                    </ion-infinite-scroll>
                </ion-content>
            </ion-slide>
        </ion-slide-box>
</ion-view>


И www/js/app.js с изменениями в контроллере:

angular.module('starter', ['ionic', 'allcount-mobile'])
.run(function($ionicPlatform, lcApiConfig) {
  lcApiConfig.setServerUrl('http://localhost:9080');
  $ionicPlatform.ready(function() {
    // Hide the accessory bar by default (remove this to show the accessory bar above the keyboard
    // for form inputs)
    if(window.cordova && window.cordova.plugins.Keyboard) {
      cordova.plugins.Keyboard.hideKeyboardAccessoryBar(true);
    }
    if(window.StatusBar) {
      StatusBar.styleDefault();
    }
  });
})
.config(function ($stateProvider, $urlRouterProvider) {
  $stateProvider
  .setupStandardAllcountMainState('app', 'lib/allcountjs-ionic/templates')
  .state('app.flowBoard', {
    url: "/entity/FlowBoard",
    views: {
      'menuContent': {
        templateUrl: "flow-board.html",
        controller: 'FlowBoardController'
      }
    }
  })
  .setupStandardAllcountStates('app', 'lib/allcountjs-ionic/templates');
  $urlRouterProvider.otherwise('/app/main');
})
.controller('FlowBoardController', function ($scope, lcApi, $ionicSlideBoxDelegate) {
  $scope.mainEntityTypeId = 'FlowBoard';
  $scope.$on('$ionicView.enter',function () {
    $scope.boardColumns && $scope.boardColumns.forEach(function (column) { 
      column.gridMethods.updateGrid();
    })
  });
  lcApi.getFieldDescriptions($scope.mainEntityTypeId).then(function (descriptions) {
    var statusFieldDescription = _.find(descriptions, function (d) {
      return d.field === 'status';
    });
    return lcApi.referenceValues(statusFieldDescription.fieldType.referenceEntityTypeId);
  }).then(function (statusReferenceValues) {
    $scope.boardColumns = statusReferenceValues;
    $scope.boardColumns = _.filter($scope.boardColumns, function (obj) {
      return !!obj.id;
    })
    $scope.boardColumns.forEach(function (column) {
      column.loadNextItems = function () {
        column.gridMethods.infiniteScrollLoadNextItems().then(function () {
          $scope.$broadcast('scroll.infiniteScrollComplete');
        })
      };
    })
    $ionicSlideBoxDelegate.update();
    $scope.updateTitle(0);
  });  
  $scope.updateTitle = function (columnIndex) {
    $scope.title = $scope.boardColumns[columnIndex].name;
  }
  $scope.columnFiltering = function (column) {
    return {filtering: {status: column.id}};
  }
});


Запуск приложения в эмуляторе


Перед запуском приложения вам необходимо изменить url вашего сервера на доступный для эмулятора. Например можно использовать IP адрес в локальной сети. Изменим /www/js/app.js
lcApiConfig.setServerUrl('http://192.168.0.16:9080')

Теперь сбилдим и посмотрим на результат. Я использую iOS, но вы можете использовать и Android.
$ ionic platform add ios
$ ionic build ios
$ ionic emulate ios

Если вы тоже создаёте iOS приложение, то стоит остановиться ещё и на новой политике безопастности для iOS 9. Для того что бы использовать незащищённые http endpoints необходимо предварительно добавить в platforms/ios/crm-app-allcountjs/crm-app-allcountjs-Info.plist после первого тега следующий текст:
<key>NSAppTransportSecurity</key>
<dict>
  <key>NSAllowsArbitraryLoads</key>
  <true/>
</dict>

Подробности можно посмотреть на stackoverflow.

Мобильные приложения с AllcountJS — это просто


Сейчас с помощью ionic framework мы достаточно быстро упаковали наше веб-приложение в мобильное. Единственное на что нужно было потратить время — это создание аналога канбан доски. Но если для веб приложения не требуется какая-нибудь особенная клиентская логика или интерфейсы, то создание веб интерфейса, REST API и мобильного приложения с AllcountJS становится по настоящему быстрым. Кто заинтересовался и хочет узнать подробности — добро пожаловать на Allcountjs.com

Комментарии (4)


  1. chuikoffru
    20.11.2015 07:04

    А версию без jade планируете?


    1. paveltiunov
      20.11.2015 10:49

      Добрый день, Константин! Спасибо за вопрос!
      Да, планируем. Наш roadmap можно посмотреть здесь: github.com/allcount/allcountjs/blob/master/docs/roadmap.md. Первым на очереди пока React.


      1. b1rdex
        24.11.2015 14:10

        По-моему вы неправильно поняли вопрос. Не без angularjs, а без jade. То есть без jade, а с чистым html. Такое поддерживается?
        А так же интересна поддержка postgresql.


        1. paveltiunov
          24.11.2015 14:54

          Да. Возможно. Спасибо за уточнение, Анатолий!
          Я правильно понимаю, что речь идет об использовании HTML с Angular в качестве Single Page Application?
          Если да, то эта задача выполнена только для мобильной версии frontend'а. Для веб-приложения на текущий момент задача не стоит.
          Frontend AllcountJS не является SPA и мы используем шаблонизатор jade для того, чтобы задать общий template, где лежит меню системы, mixin'ы для стандартных представлений и чтобы передать контекст текущей entity в angular.
          Реализация postgresql есть. В ближайшее время будем дополнять ее миграциями и поддержкой хранения файлов.