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.
Build CRM on top of loopback with AllcountJS in 10 minutes
Loopback is a great backend implementation for enterprise applications in Node.js ecosystem. The only thing you need to build a full fledged application is the UI. In this blog post we’ll take a look on how to build CusDev CRM on top of loopback backend and AngularJS frontend with AllcountJS.
Setup
You’d need to install Node.js first. Then please install Strongloop and AllcountJS CLIs by invoking
$ npm install -g strongloop allcountjs-cli
Init project
We’ll use CusDev CRM template to initiate our project. To create new project you’ll have to run
$ allcountjs init --template cusdev-crm
I’m using loopback-crm as a directory name. Please cd to loopback-crm and initiate loopback application by
$ slc loopback
And also install AllcountJS dependencies by running
In order to allow AllcountJS to configure loopback you’d need to open server/server.js and add the following dependency injection configuration:
var injection = require('allcountjs'); injection.bindFactory('loopback', function () { return loopback }); injection.bindFactory('loopbackApp', function () { return app }); injection.bindFactory('gitRepoUrl', 'app-config'); injection.installModule(require('allcountjs-loopback')); var allcountjs = injection.inject('allcountServerStartup');
Then add allcountjs.startup() in loopback’s boot() call as follows:
boot(app, __dirname, function(err) { if (err) throw err; allcountjs.startup(function (errors) { if (errors) { throw errors; } // start the server if `$ node server.js` if (require.main === module) app.start(); }) });
Loopback installs test ‘/’ route by default that overrides default AllcountJS dashboard. So we need to delete it in server/boot/root.js and get empty route configuration as follows
module.exports = function(server) { };
If you’re on latest node version that supports ES6 promises then you’re all set and could run your app from loopback-crm directory:
$ node .
Your app should be app and running at http://0.0.0.0:3000.
For earlier node versions without ES6 promises support you should add promises implementation. We’ll use bluebird:
$ npm install --save bluebird
And then somewhere before boot in server/server.js add the following line:
global.Promise = require('bluebird');
After all changes server/server.js should look like this:
var loopback = require('loopback'); var boot = require('loopback-boot'); var app = module.exports = loopback(); var injection = require('allcountjs'); injection.bindFactory('loopback', function () { return loopback }); injection.bindFactory('loopbackApp', function () { return app }); injection.bindFactory('gitRepoUrl', 'app-config'); injection.installModule(require('allcountjs-loopback')); var allcountjs = injection.inject('allcountServerStartup'); app.start = function() { // start the web server return app.listen(function() { app.emit('started'); var baseUrl = app.get('url').replace(/\/$/, ''); console.log('Web server listening at: %s', baseUrl); if (app.get('loopback-component-explorer')) { var explorerPath = app.get('loopback-component-explorer').mountPath; console.log('Browse your REST API at %s%s', baseUrl, explorerPath); } }); }; // Bootstrap the application, configure models, datasources and middleware. // Sub-apps like REST API are mounted via boot scripts. boot(app, __dirname, function(err) { if (err) throw err; allcountjs.startup(function (errors) { if (errors) { throw errors; } // start the server if `$ node server.js` if (require.main === module) app.start(); }) });
Using PostgreSQL as data source
One of the main advantages of loopback is the opportunity to use multiple data sources at the same time. Let’s see how you can PostgreSQL data source along with AllcountJS.
I assume you already have installed PostgreSQL and started it locally. First we need to create db with createdb CLI:
$ createdb loopbackcrm
Then let’s install loopback’s postgresql connector:
By default AllcountJS will use db datasource to map declared entities. Datasource name could be overridden by dataSource property for entity or by defaultLoopbackDatasource property for application in AllcountJS app config.
One last thing we’d need to setup is migrations. To do this we’ll use loopback’s automigrate() method. You’d need to insert following code in server/server.js just before var allcountjs = injection.inject('allcountServerStartup');
injection.bindFactory('migrationService', function (loopbackApp) { return { compile: function () { return loopbackApp.dataSources.db.automigrate(); } } });
Let’s restart our server by node . and check your postgresql db with
$ psql loopbackcrm loopbackcrm=# \d
And you should see your tables:
List of relations Schema | Name | Type | Owner --------+-----------------------+----------+------- public | accesstoken | table | pavel public | acl | table | pavel public | acl_id_seq | sequence | pavel public | contact | table | pavel public | contact_id_seq | sequence | pavel public | forgotpassword | table | pavel public | forgotpassword_id_seq | sequence | pavel public | integration | table | pavel public | integration_id_seq | sequence | pavel public | migrations | table | pavel public | migrations_id_seq | sequence | pavel public | role | table | pavel public | role_id_seq | sequence | pavel public | rolemapping | table | pavel public | rolemapping_id_seq | sequence | pavel public | status | table | pavel public | status_id_seq | sequence | pavel public | user | table | pavel public | user_id_seq | sequence | pavel
Using StrongLoop API explorer
If you familiar with loopback you probably already tried to see what StrongLoop API explorer shows you. And you’ll be a little confused that it shows nothing. It’s because now it’s initialized before AllcountJS.
In order to fix it, you should remove it from component-config.json and call explorer init while allcountjs.startup() callback execution in server/server.js as follows:
boot(app, __dirname, function(err) { if (err) throw err; allcountjs.startup(function (errors) { if (errors) { throw errors; } require('loopback-component-explorer')(app, { basePath: '/api', mountPath: '/explorer' }); // start the server if `$ node server.js` if (require.main === module) app.start(); }) });
Restart your server and go to http://0.0.0.0:3000/explorer. You should see CRM entities are in place
Under the hood
In conclusion, I would like to reveal under the hood concept of AllcountJS-loopback integration. AllcountJS declares generic extensible DSL that can be projected to virtually any technology stack. In fact AllcountJS is a DSL + configuration of “how to project” DSL to technology.
It gives you an ability to bootstrap small projects in minutes using default implementations and tremendously reduce code scattering for big and advanced projects.
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):
Imagine that we need to store business contacts and track their status. It’s a typical CRM application feature. Let’s take a look at how AllcountJS framework could help us to solve this problem in 10 minutes. If you want to see the result right now - simply run the Demo
AllcountJS is an open-source rapid application development framework. It’s built on top of the MEAN (MongoDB, Express, AngularJS, NodeJS) stack. But “M” can be replaced with another database including SQL one.
Core part of AllcountJS application is a .js configuration file with mostly declarative description of application structure: entities, their fields, relations, views, roles and permissions.
All CRUD operations, default views, user management features are available out of the box.
Built-in Role Based Access Control allows to manage access rights based on user roles.
UI is generated automatically using AngularJS, Twitter Bootstrap, Jade and Font Awesome icons.
AllcountJS also provides JSON REST API to perform all operations available to users. If you need to add some specific functionality to your application you can use Dependency Injection mechanism.
Installing and Running
You can start working with AllcountJS in several ways: as a standalone application, as a dependency of another Node.js application or run a demo app at allcountjs.com.
The easiest way to see the result is just to run the application on the demo page
If you consider to deploy application on your site you should install Node.js, MongoDB and Git. Then install AllcountJS CLI by invoking an “npm install” command and perform project init:
AllcountJS CLI will ask you to enter some info about your project in order to pre-fill package.json. Next, open app-config/main.js file in the application directory and replace it’s contents with following piece of code:
To run it simply invoke in cusdevcrm-allcount directory
$ allcountjs run
It’ll use your local MongoDB instance to store data so please ensure it’s up and running.
Now let’s take a look at how it works.
General application settings
The name and icon of the application are defined with the appName and appIcon properties. AllcountJS uses Font Awesome icons. You can select any icon and use it simply by referring to it’s name. When referring to the icon you need to remove fa- prefix.
appName: "CusDev CRM", appIcon: "phone",
Authentication setting configured by onlyAuthenticated property. It declares that only authenticated users may use this application.
onlyAuthenticated: true
There is also a menuItems property, but we’ll look at it after we define the entities and views.
Contacts and Statuses
Now we’re ready to describe our business entities. They’re defined in the entities property. Assume that contact will have two mandatory text fields: Name and Company, some text field with contact details, last contact date and current status.
Status field references to the status entity. Which may have values like “Message Sent”, “Answered”, “Rejected”. Status entity has name and order fields.
Every entity can have many views. View in AllcountJS is like SQL view: they doesn’t have special storage in database and you can operate with them like with entities. Most common use case for views is to provide custom behavior, UI and access rights.
In our case we will use views only to provide specific UI for Contact. UI template for view or entity is defined in customView property.
views: { FlowBoard: { customView: "board" } }
It refers to .jade file containing template source code. AllcountJS uses jade template engine to generate resulting HTML for web view.
There is a card board template with drag and drop feature in AllcountJS. We will use it with some customizations. So let’s create board.jade file with this piece of code:
extends project/card-board block panelBody .panel-body h4 {{item.name}} p {{item.company}} p {{item.lastContactDate | date}}
Menu
Now it’s time to describe our application menu. There is menuItems property of the app. It consists of links to application entities.
Imagine that you have another application and want to integrate it with your new CRM. It is not a problem, because all application functions could be accessed by the REST API.
First you need to get access token. If your CRM app located at https://localhost:9080 than you need to send HTTP POST request to https://localhost:9080/api/sign-in with
{"username": "admin", "password": "admin"}
in the body. In response you will get token like this:
Next let’s try to get all contacts stored inside the CRM. Send HTTP GET request to https://localhost:9080/api/entity/FlowBoard or directly to https://localhost:9080/api/entity/Contact with
Compare your efforts: 30 vs 3000 LOC for very simple MEAN app
It took a while for me as a developer to realize there are plenty of very interesting hacker things in software development that doesn't make any sense for business. You could spend weeks to make some precious app or feature that doesn't give any business value at all. And it's always seems much more cooler for developer to craft stuff from scratch than use some existing solution. At first sight it doesn't hurt much to build it from scratch and gives you feeling of full control. But what's exactly the price?
Consider simple MEAN app from Mastering MEAN: Introducing the MEAN stack article. If you concat full source code of *.js and *.html excluding dependencies you'll get roughly 3000 lines of code (full source):
App with pretty same feature set made with AllcountJS would look like:
One could argue these 3000 LOC are generated by scaffolding and I don't spend time to write it. And while it's true there is another side of a coin: this code is a part of yours project code base. You need to support it, evolve it and read it after all.
We believe DRY principle and code reuse is much more viable alternative to boilerplates and scaffolding in rapidly changing software world. We started to develop AllcountJS to bring a tool that delivers business value as soon as possible with an opportunity to customize every part of it.
We're curious what do you think about it? Please share your thoughts in comments below and in our Gitter chat. We very appreciate that!
Task management is one of the most required features for business. While there are many solutions for this problem there is no silver bullet yet because of each business field has each own specifics. Development of a bespoke application to meet specific requirements in a such circumstance isn't rare. AllcountJS allows you to build custom task management applications from scratch pretty fast. Let's consider an example: how to build such application step by step.
If you unfamiliar with AllcountJS you probably would like to see our Getting Started guide first. While AllcountJS allows to use all power of Node.js in this article we will work only with App Config code. In order to run demo code you should do npm install allcountjs-cli then allcountjs init or simply run code from our demo page here and you'll get something like
Model
Let's start with model declaration. Probably most important thing in task management is to have task object (obviously) and some status flow. Let's define our model in main.js:
Such App Config produces working application that has two entities: Task and Status. Task object has summary and due date fields along with the status reference. Status entity has a name field used as a reference name while showing combo box and order field that will be used later to define order of statuses. As you can see required fields are marked with .required(). It means entity couldn’t be created or saved until these fields are filled. Also you could note onlyAuthenticated flag which designates app couldn’t be accessed without authentication.
Order of statuses
We could define our status priorities just by adding sorting: [['order', 1]], to the Status entity type:
Such declaration instructs AllcountJS to sort Status entity by order field in ascending order.
Board view
The one of the most powerful AllcountJS features is a Views concept. View is an Entity too but as a storage it uses another entity. So there is an opportunity to create many multiple behaviors and visualizations for same data source. Let’s create board view for our Task entity by adding following property
AllcountJS uses jade template language by default. Code in board.jade defines template for custom view of our board. It defines panelBody block that describes how the card will look like. We should get following result for main.js
If you run this code sample you'll see Kanban board with status where you can move cards between statuses. Order of statuses is defined by order field because of sorting.
Polishing
To polish things you probably want to add icons for menu items and the app. You could do it by simply referring to Font Awesome icons. Let's add appIcon and icons for menu and we'll get final version for the app.