As you can see, we want only specific fields to be shown in the Grid View and we've chosen 3 fields for that view
Next you'd want to define the board view in your view_name.jade file
extends project/card-board block panelBody .panel-body h4 {{item.Your_Entity_Field_1}} p {{item.Your_Entity_Field_2}} p {{item.Your_Entity_Field_5}}
You can note that in the board view, we want 3 other fields to be displayed. It's also important to note the need for a status field for the board view.
Hope this helps you in defining Multiple Views for an Entity in your application.
In case you have further queries on this, please feel free to join us in the Chat Room on Gitter https://gitter.im/allcount/allcountjs
Watch this space for more tid-bits coming your way!
As you are already aware that the purpose of AllcountJS is to make life easier for someone with minimal coding skills to get up and running with a POC for their idea.
While the current documentation available is a good enough starting point for anyone with prior AngularJS coding experience, we decided to make it more easier for novice coders to get a jumpstart.
This is going to be a multi-part short "How To" posts that are based on the queries we've been getting on our Community Chat Room on Gitter https://gitter.im/allcount/allcountjs
In the first post of this series, we'll cover How To configure a Child Menu on your AllcountJS Application.
As you can see, defining a Child Menu is as simple as this and all you need to do is to create customView to correspond to the menu item.
Hope this helps you in customizing your application's menu, in case you have further queries on this, please feel free to join us in the Chat Room on Gitter https://gitter.im/allcount/allcountjs
Watch this space for more tid-bits coming your way!
Allcount team wins iHack hackathon contest in 4 nominations: IBM special grant prize for hosting and market place valuated at $24,000, HSE Lab acceleration program, people’s choice award and grand prize. Allcount team implemented loan assessment conveyor application in 48 hours with AllcountJS framework and won an opportunity to make it live in one of the most biggest Russian banks: VTB24. It’s a big step forward for AllcountJS and Node.js enterprise community.
Making product: a marketplace for Moto parts with AllcountJS
Foreword
You are about to find this interesting as we are going to talk about a live product. A Proof Of Concept we put together as an example of what you could get done with AllcountJS - http://zrum.ru.
You can take a look at the Source Code of the Moto Parts Aggregator & Market Place POC we’ve built - https://github.com/allcount/moto-parts-marketplace
“All moto parts in one place”, the Google for moto parts.
Introduction
Imagine you came up with a wonderful idea, a brilliant solution, that you think will generate a lot of attention from people.
What should you do to ensure your idea gets shape and form and gets nurtured towards execution?
First and foremost is to defeat the most powerful enemy ever -- Time.
An idea is only worth its execution. A startup should race against time or be wiped out. We believe that the Lean Startup principles could lead you and your team to success. One important concept from Lean Startup is Minimum Viable Product, which basically means that you should spend minimum efforts in minimum time and we take that up for today’s topic: How to built a product faster than you can even imagine?
We’ll not be covering MVP much here, but you can read more about it here.
A few words about this particular case
Assume we recently found that it is hard to search and buy moto parts here in Russia today. And local bikers community is hopefully quite big. So we've decided to build a moto parts aggregator: A place where all the price lists are available and are combined into the one super-list with the user-friendly search. "All moto parts in one place", the Google for moto parts.
MVP
Getting started
If you haven't installed Node.js, MondoDB and AllcountJS yet you should do it. I’ll assume that you already know how to create basic AllcountJS configuration, so I won’t go in detail about that here.
The Model
We need actual stores to get the parts from. So the providers:
The "referenceName" property is needed to let the framework know how to show references (what field should be used for references labels).
We have 3 providers so far and all of them provide XLS (MS Excel) files containing price lists. Therefore, "parseMethod" indicates what function (by its name) should be used to parse these XLS files. You'll learn about functions later when we'll discuss about how to setup the content.
Moto parts should be connected to providers and information about parts availability should exist:
Now we have data storage. Let's move on to the user's interface.
The View
Everyone likes simple clear interfaces, and so do we! Do you remember Google's search interface? Just one field on the screen. So let's use that valuable experience and build something like that:
include mixins include main-mixins - var hasToolbar = false - var entityTypeId = 'PartAvailability' doctype html html head +defaultHead() block head body(style="padding-top:20px") div(ng-app='allcount', ng-init="viewState = {mode: 'list', filtering: {}}", ng-controller="SearchController") .splash(style="padding: 2em 0 2em; background: linear-gradient(rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5)), url('/images/splash.jpg');background-position-y: -700px;background-position-x: center;") .container .row .col-lg-12(ng-if="!viewState.filtering.textSearch") span.app-logo i(class="fa fa-search fa-fw") h1 zrum.ru h2 All moto parts in one place .col-lg-12(ng-if="viewState.filtering.textSearch").ng-cloak h1 i(class="fa fa-search fa-fw") | zrum.ru .container .row.text-center: .col-xs-12 h1(ng-if="!viewState.filtering.textSearch") Search available moto parts input.form-control.input-lg(style="margin-top: 10px", placeholder="Enter name of the part", ng-model="viewState.filtering.textSearch", ng-model-options="{ debounce: 700 }") div(style="margin-top: 20px", ng-if="viewState.filtering.textSearch") +pagingControl() .screen-container(ng-cloak, ng-if="viewState.filtering.textSearch") +defaultList()(ng-show="viewState.paging && viewState.paging.count > 0") .row(ng-repeat="item in items") .col-lg-9.col-md-8 h3: a(ng-href="{{item.siteUrl}}", target="_blank", ng-click="trackNavigate(item)") {{item.name}} h4 {{item.provider.name}} h4: a(ng-href="tel:{{item.phone}}", ng-click="callItem(item)") {{item.phone}} .col-lg-3.col-md-4.text-right h2 {{(item.price / 100) | currency}} h3.text-success(ng-if="item.isAvailable") Available h3.text-danger(ng-if="!item.isAvailable") Not available +noEntries() +defaultEditAndCreateForms() include core-scripts script. angular.module('allcount').config(["$locationProvider", function ($locationProvider) { $locationProvider.html5Mode({enabled: false}); }]); angular.module('allcount').controller('SearchController', ['$scope', function ($scope) { $scope.$watch('viewState.filtering.textSearch', function (textSearch) { lc.track('search', {query: textSearch}); }) $scope.$watch('viewState.paging.count', function (count) { if (count === 0 && $scope.viewState && $scope.viewState.filtering && $scope.viewState.filtering.textSearch) { lc.track('not-found', {query: $scope.viewState.filtering.textSearch}); } }) $scope.trackNavigate = function (item) { lc.track('navigate', {item: item}); } $scope.callItem = function (item) { lc.track('call', {item: item}); } }]);
Surely we have some CSS (or rather LESS) files under the hood, but it's not so important now. One thing you should definitely know is that these LESS files should be put under ./public/assets/less directory where . is the directory where application's config is.
The content
One of the most interesting questions here is how to fill the price list. There is no way to involve end users into this process. So the solution is to scrape and parse data from the actual moto parts shops. We'll divide this process into two stages:
get data from the shop's XLS price lists
scrape the data (i.e. links) from web sites in order to allow end users to actually buy the parts
Each of this stages we could divide into two subtasks:
service itself (worker's code)
UI invoking that service
Let's implement each service.
Link scrapping
Every moto parts provider has their own web site and their own data formats, hence we need to implement custom functions for each of them to get actual information about moto parts. Let's create ./services/scraper.js file:
var request = require('request'); var cheerio = require('cheerio'); var rp = require('request-promise'); var _ = require('underscore') module.exports = function (Q) { var pool = {maxSockets: 2}; return { parseLbaMoto: function () { var self = this; var lbaRootLink = 'http://www.lbamoto.ru'; return this.cheerioRequest(lbaRootLink).then(function ($) { var categoriesList = $('a', $('ul.level1')).map(function (i, el) { return $(this).attr('href'); }).get(); categoriesList = _.unique(categoriesList); console.log(categoriesList.join("\n")); //categoriesList = _.take(categoriesList, 10); //TODO return Q.all(categoriesList.map(function (categoryLink) { var categoryUrl = lbaRootLink + categoryLink + '/results,0-1000'; return self.cheerioRequest(categoryUrl).then(function ($) { console.log('Scraping: ' + categoryUrl); return _.filter($('.row > .product').map(function () { var link = $('a', $(this)); if (!link.text()) { return; } var priceBlock = $('.product-price__base', $(this)); if (!priceBlock || !priceBlock.text()) { return; } var priceMatch = priceBlock.text().match(/(\d+)\s+руб/); if (!priceMatch) { return; } return { name: link.text().trim(), siteUrl: lbaRootLink + link.attr('href'), price: parseInt(priceMatch[1]) * 100 }; }).get(), _.identity); }) })).then(_.flatten); }) }, parseMrMoto: function () { var self = this; var entryLink = 'http://www.mr-moto.ru/Netshop/shop/charges/'; var rootLink = 'http://www.mr-moto.ru'; function drillDown(categoriesList, selector) { return Q.all(categoriesList.map(function (categoryUrl) { return self.cheerioRequest(categoryUrl).then(function ($) { var collectHrefs = self.collectHrefs($); return collectHrefs($(selector), rootLink); }) })).then(_.flatten); } return this.cheerioRequest(entryLink).then(function ($) { var collectHrefs = self.collectHrefs($); var categoriesList = collectHrefs($('.divmenu1 a'), rootLink); console.log(categoriesList.join("\n")); return drillDown(categoriesList, '.divmenu2 a').then(function (secondList) { return drillDown(secondList, '.divmenu3 a').then(function (thirdList) { return _.unique(_.union(categoriesList, secondList, thirdList)); }) }).then(function (allCategories) { console.log(allCategories.join("\n")); //allCategories = _.take(allCategories, 10); //TODO return Q.all(allCategories.map(function (categoryLink) { var categoryUrl = categoryLink + '?portion=-1'; return self.cheerioRequest(categoryUrl).then(function ($) { console.log('Scraping: ' + categoryUrl); return _.filter($('.tovblock').map(function () { var link = $($('a', $(this))[1]); var priceBlock = $($('div', link)[1]); if (!priceBlock || !priceBlock.text()) { return; } var priceMatch = priceBlock.text().trim(); if (!priceMatch) { return; } return { name: $($('div', link)[0]).text().trim(), siteUrl: rootLink + link.attr('href'), price: parseInt(priceMatch) * 100 }; }).get(), _.identity); }) })).then(_.flatten); }) }); }, parseDriveBike: function () { var self = this; var entryLink = 'http://www.drivebike.ru/zapchasti'; return this.cheerioRequest(entryLink).then(function ($) { var collectHrefs = self.collectHrefs($); var categoriesList = collectHrefs($('.amshopby-cat-level-1 a')); console.log(categoriesList.join("\n")); return categoriesList; }).then(function (allCategories) { console.log(allCategories.join("\n")); allCategories = _.unique(allCategories); //allCategories = _.take(allCategories, 0); //TODO return Q.all(allCategories.map(function (categoryLink) { var categoryUrl = categoryLink + '?limit=60&mode=grid'; function scrapePage(pageUrl) { return self.cheerioRequest(pageUrl).then(function ($) { console.log('Scraping: ' + pageUrl); var itemsOnPage = _.filter($('li.item').map(function () { var link = $('.product-name a', $(this)); var priceBlock = $('.price-box .regular-price, .price-box .special-price', $(this)); if (!priceBlock || !priceBlock.text()) { return; } var priceMatch = priceBlock.text().trim().replace(/\s/g, '').match(/(\d+)/); if (!priceMatch) { return; } var manufacturer = $('.manufacturer-name', $(this)); return { name: ((manufacturer && manufacturer.text().trim() + ' ') || '') + link.text().trim(), siteUrl: link.attr('href'), price: parseInt(priceMatch[1]) * 100 }; }).get(), _.identity); var nextPage = $('a.i-next'); if (!nextPage || !nextPage.attr('href')) { return itemsOnPage; } return scrapePage(nextPage.attr('href')).then(function (nextPageItems) { return _.union(itemsOnPage, nextPageItems); }) }) } return scrapePage(categoryUrl); })).then(_.flatten).then(function (items) { return _.unique(items, _.property('name')); }); }); }, collectHrefs: function ($) { return function ($el, root) { return _.unique(_.filter($el.map(function (i, el) { if (root && $(this).attr('href').indexOf('/') !== 0) { return; } return (root || '') + $(this).attr('href'); }).get(), _.identity)); } }, cheerioRequest: function (url, retries) { var self = this; return Q(rp({ url: url, transform: function (body) { return cheerio.load(body) }, pool: pool })).catch(function (e) { console.error(e); if (retries === 0) { throw e; } return self.cheerioRequest(url, (retries || 3) - 1); }); } } }
Here you can see "parse <provider's name>" function which actually parses web sites of the shop with name "<provider's name>". I won't stop on technical aspects at this moment, you are very welcome to contact us if you're interested in such details.
Parsing XLS price lists
The same is about parsing the XLS files: every provider has their own quirky price lists. So we need some custom services implementation:
var request = require('request'); var XLSX = require('xlsx'); var _ = require('underscore') var querystring = require("querystring"); module.exports = function (Q) { var qRequest = Q.nfbind(request); return { parsePartAvailabilities: function (provider, url, parseMethod) { var self = this; if (url.indexOf('https://yadi.sk') !== -1) { return qRequest({ method: 'GET', url: 'https://cloud-api.yandex.net/v1/disk/public/resources?public_key=' + url, json: true }).then(function (resourceUrl) { console.log(resourceUrl[1]); var item = _.find(resourceUrl[1]._embedded.items, function (item) { return item.name.indexOf('xls') !== -1; }) console.log(item); var getDownloadUrl = 'https://cloud-api.yandex.net/v1/disk/public/resources/download?' + querystring.stringify({ public_key: item.public_key, path: item.path }); console.log(getDownloadUrl); return qRequest({ method: 'GET', url: getDownloadUrl, json: true }).then(function (downloadUrl) { console.log(downloadUrl[1]); return qRequest({ method: 'GET', url: downloadUrl[1].href, encoding: null }).then(function (res) { return self[parseMethod](provider, res[1]); }) }) }) } if (url.indexOf('mr-moto.ru') !== -1) { return self.extractMrMoto(provider, url, parseMethod); } return Q.nfcall(request, { method: 'GET', url: url, encoding: null }).then(function (res) { console.log(res[0]); return self[parseMethod](provider, res[1]); }) }, extractMrMoto: function (provider, url, parseMethod) { var self = this; return qRequest({method: 'GET', url: url}).then(function (res) { var fileUrl = res[1].match(/\<a\s+href\=\'(.*?)\'(.*?)\>остатки(.*?)\<\/a\>/)[1]; console.log(fileUrl); return qRequest({ method: 'GET', url: 'http://www.mr-moto.ru' + fileUrl, encoding: null }).then(function (res) { console.log(res[0]); return self[parseMethod](provider, res[1]); }) }) }, parseLbaMoto: function (provider, xlsxBinary) { var workbook = XLSX.read(xlsxBinary); var first_sheet_name = workbook.SheetNames[0]; var worksheet = workbook.Sheets[first_sheet_name]; function cellValue(c, r) { var cell = worksheet[XLSX.utils.encode_cell({c: c, r: r})]; //console.log(cell); return cell && cell.v; } var result = []; for (var i = 10; cellValue(3, i); i++) { if (!cellValue(23, i) || !cellValue(0, i)) { continue; } result.push({ sku: cellValue(0, i), name: cellValue(3, i), price: cellValue(23, i) * 100, provider: {id: provider.id, name: provider.name}, isAvailable: !!cellValue(21, i) }) } return result; }, parseMrMoto: function (provider, xlsxBinary) { var workbook = XLSX.read(xlsxBinary); var first_sheet_name = workbook.SheetNames[0]; var worksheet = workbook.Sheets[first_sheet_name]; function cellValue(c, r) { var cell = worksheet[XLSX.utils.encode_cell({c: c, r: r})]; //console.log(cell); return cell && cell.v; } var result = []; for (var i = 10; cellValue(0, i); i++) { if (!cellValue(5, i)) { continue; } result.push({ name: cellValue(0, i), price: cellValue(5, i) * 100, provider: provider, isAvailable: !!cellValue(8, i) }) } return result; }, parseDriveBike: function (provider, xlsxBinary) { var workbook = XLSX.read(xlsxBinary); var first_sheet_name = workbook.SheetNames[0]; var worksheet = workbook.Sheets[first_sheet_name]; function cellValue(c, r) { var cell = worksheet[XLSX.utils.encode_cell({c: c, r: r})]; //console.log(cell); return cell && cell.v; } var result = []; for (var i = 1; cellValue(0, i); i++) { if (!cellValue(5, i)) { continue; } result.push({ name: cellValue(5, i).replace("[", "").replace("]", ""), price: cellValue(7, i) * 100, provider: provider, isAvailable: true }) } return result; } } }
While this implementation looks mysterious, giant and overall scary, trust me, it is much more concise than it was in times of Java EE applications' prosperity!
Service usage
It's time to use our new services within our application's config. Basically we need to implement actions that would trigger our services. Let's do it! Here is the final config:
Labels are in Russian here. As you can see, we've pasted actions properties to the entities descriptions. AllcountJS will automatically place buttons to the appropriate interfaces for us. Nice! But it still won't work because we've injected ExcelParser and Scraper services into the actions, but AllcountJS knows nothing about where to get this services. In order to let it know that we should do a little bit customization in application's start up. Let's write app.js file right in the project's root directory:
var injection = require('allcountjs'); injection.bindFactory('port', process.env.PORT || 9080); injection.bindFactory('dbUrl', process.env.MONGOLAB_URI || 'mongodb://localhost:27017/motochast'); injection.bindFactory('gitRepoUrl', 'app-config'); injection.bindFactory('ExcelParser', require('./services/excel-parser')); injection.bindFactory('Scraper', require('./services/scraper')); var server = injection.inject('allcountServerStartup'); server.startup(function (errors) { if (errors) { throw new Error(errors.join('\n')); } });
You should run this by node app.js command as any usual Node.js application. Our MVP is ready to see the world now!
What's next?
Now we've got the MVP. Is that all you can do with AllcountJS? The answer is surely no. For instance, if you get an insight about demand for a mobile app -- you can easily get it. Do you need for some awesome custom user interfaces? No problem. Next you can customize your app however you want -- the only restriction is Node.js itself - Which means almost, sky is the limit and you don’t have to stop with the MVP but can go ahead and continue building the complete product. Our team would be more than happy to help you on your way. Welcome to our chat room!
It is unnecessary to introduce point of sales (POS) concept - it is everywhere in our life, not only in markets and places related to sales directly. Also it is tightly coupled with CRMs, inventory, warehousing, etc. With AllcountJS you can rapidly create all of this stuff. Let’s see how we can construct POS POC.
Considering model
First things first so let’s start with the essential thing -- items which we are going to sale.
The most interesting thing here is the stock field. It should be computed according to quantities of this items involved into transactions. So that's what we exactly wrote in our configuration: integer field stock is computed as sum of transactions' quantity - but with parentheses, dots and quotes. And the “transactions” here is the field defined as “relation” with currently absent entity. Relation is defined by it’s name, related entity type and the field’s name of the related entity which reference to this item. So let’s add the Transaction entity type:
As you can see, field “item” is referencing to the Item and maintaining the relation previously defined. So nothing special here except “showInGrid” option -- it tells the view to show only listed fields by its identifiers. Next let’s define the central part of our POS:
I called it “central” because this is the part where we define our main view: the “Point Of Sale” as a custom view located in file “pos.jade” which we will tackle with later. Also you’ve surely noticed “beforeSave” and “beforeDelete” functions. They are handlers which triggers when the appropriate event happens. More about such as functions you can learn from our documentation. I’ll just mention that we need this functions here to update order’s counter when order is completed and clear up all related items from this order when it’s removed. You can also see relational and computed fields here. The latter is to show up total price of particular order and the former is the relation with entities which should be considered as items containing this order:
Now you might noticed that every entity type which participate in the relational fields have its own “showInGrid” configuration. This is because usually we don’t want to show user the owner entity -- he get himself there through it. But certainly you have an ability to customize this behaviour as you’d like. Here we have the CRUD-hooks too: we need to update total price according to ordered quantity before save this particular order’s item, we need to remove transaction and create a new one when editing of order’s items is successfully completed and we need to remove the transaction made with ordered item if it exists when this order’s item is going to be removed. Also we mark the “final price” field as a total-row-participant by calling the “add to total row” function on it. By the way, we worship of DRY principle, hence we’ve reused function removing transation:
function removeTransaction(Crud, Entity) { var crud = Crud.crudForEntityType('Transaction'); return crud.find({filtering: {orderItem: Entity.id}}).then(function (transactions) { if (transactions.length) { return crud.deleteEntity(transactions[0].id); } }); }
Just in case of the consecutive numbering of orders we’ve got singleton counter of orders:
Recently we’ve mentioned this view as an essential thing for such as applications. Remember yourself tackling horrible and overloaded user’s interfaces. And how happy you might be pushing just a few big colored buttons and getting your job done by it. That’s why we emphasize our customizability of UI. The bottom line is: if you have an outstanding UX, you can easily express it in the UI! And this is what we exactly going to do now -- create a file called ‘pos.jade’ with the following content:
We are not going to stop on its content this time, but you can see it in action by running the demo!
Putting all things together
Alright, so far we’ve defined our nice and nifty view, decided about model and some business logic. If you follow our blog you should be familiar with starting up AllcountJS. I’ll be brief with it:
Create directory somewhere on your computer (e.g. mkdir pos-example)
Create the app.js file inside it with the following content (as you can see we’ve added menus there and some sample data just for the sake of presentability):
Place recently introduced pos.jade file right behind app.js
Install allcountjs-cli if you haven't done it yet.
Run the server by command allcountjs -c app.js (Note: you should already have Mongo DB service up and running)
Afterword
Hope you enjoyed reading this case study. The feedback would be very appreciated! Don't forget to follow our blog just not to miss something interesting! Also you are very welcome to our gitter chat -- you can ask there whatever you want regarding every Node.js-related aspect or development in general.
Kanban board mobile application with AllcountJS and Ionic
In previous blog post we discussed how to bootstrap your mobile AllcountJS application with Ionic. Today we’re going to go further and discover how to do some sort of meaningful application from user standpoint. We’re going to build kanban board view for one of our demos: customer development CRM.
You can run CusDev CRM Demo here.
After all our Kanban board should look like following
So let’s start. If you missed the Blog post about how to bootstrap your mobile app you could check it out for detailed explantation of how to do it. But for now I’ll go pretty quick with it.
Tools:
$ npm install -g allcountjs-cli cordova ionic
Bootstrapping and starting CusDev CRM AllcountJS application:
$ allcountjs init --template cusdev-crm
I’m using crm-app-allcountjs name for my newly created application.
$ cd crm-app-allcountjs && npm install $ allcountjs run
<!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>
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') .setupStandardAllcountStates('app', 'lib/allcountjs-ionic/templates'); $urlRouterProvider.otherwise('/app/main'); });
Now run ionic serve and you should see your AllcountJS mobile app is showing auth modal for your AllcountJS backend.
Cool! Project bootstrap part has been done and now we’re going to add our custom view. Let’s start with minimum viable setup and make 3 changes.
What was done so far? We use lc-list directive in our view to load FlowBoard entity type items, ng-repeat to render these items as cards (please note card class for item) and ion-infinite-scroll along with FlowBoardController logic to fetch items while scrolling. The most tricky moment here is app.flowBoard state overrides standard AllcountJS app.entity state and allows us to replace view for this entity flawlessly. Now if you run your ionic app you’ll see card view of your Board menu item.
Nice! But it isn’t kanban board actually. So what we’re going to do is to load our statuses, wrap our lc-list with ion-slide repeated for each status in ion-slide-box in order to slide between status columns and filter items for each lc-list with corresponding status id.
So our modified www/flow-board.html will look like
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}}; } });
Let’s build it and see the result. I’m using iOS but you can use Android as well. Don’t forget to override your server url to accessible one for emulator and to set up security policy properly (See previous blog post)
Mobile application and backend with Ionic and AllcountJS in less than a hour
Nowadays is the time when people expect both web UI and mobile client application in their phones for almost every project. And it’s actually pretty tough and time consuming problem to ship all of this stuff at the same time. In this tutorial we’re going to build backend, web UI and hybrid mobile application using AllcountJS and ionic in less than a hour.
Let’s start with backend and web UI. For this example I'll use twenty two lines hello world application template. If you haven’t set up AllcountJS develeopment environment yet please visit our Getting Started or watch Video tutorial from one of our previous blog posts.
Let’s init our backend project repository with
$ allcountjs init
I’m using default name helloworld-app for my project. The next step is to install dependencies as CLI suggests:
$ cd helloworld-app && npm install
And to get our server up and running we should execute following command at the project dir (don’t forget to startup your local MongoDB instance)
$ allcountjs run
If all went ok we should see something like
Using db url: mongodb://localhost:27017/helloworld-app Failed to fetch "app-config". Trying to use as regular directory. Application server for "app-config" listening on port 9080
So when you open http://localhost:9080/ you’d see your application up and running.
First part is finished here and we’re going to create mobile application as our next.
If you haven’t installed ionic it’s time to do that: Getting Started. Actually it’s as simple as
$ npm install -g cordova ionic
Now let’s change directory to parent of our helloworld-app and run init for ionic:
$ ionic start helloworld-app-mobile blank
Now let’s change current directory to newly created ionic app’s directory and run ionic serve in order to check what we have now:
$ cd helloworld-app-mobile && ionic serve
If all went nice you’d see blank ionic starter app. Now let’s install allcountjs-ionic with (if you haven’t installed bower yet please run npm install -g bower)
$ bower install allcountjs-ionic --save
Then open helloworld-app-mobile/www/index.html and add AllcountJS dependencies just right after ionic.bundle.js declaration
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'); });
Let’s check our result by running ionic serve from helloworld-app-mobile directory. You should see login dialog where you could just enter admin/admin. After login you’d see your app menu described in AllcountJS app config.
Cool! But let’s go further and build and run native iOS application then. In order to do this we’d need to add cordova ios platform
$ ionic platform add ios
But before you’ll run app on emulator you should change your app server url to something accessible for it. For myself I used my IP on local network. Let’s change it in helloworld-app-mobile/www/js/app.js
Another one thing I definitely need to mention is the new Apple security policy for iOS 9. So in order to use insecure http endpoints you should add this to your platforms/ios/helloworld-app-mobile/helloworld-app-mobile-Info.plist just after first <dict> tag before run (see http://stackoverflow.com/questions/30731785/how-do-i-load-an-http-url-with-app-transport-security-enabled-in-ios-9 for details):