Formatting labels

Change-Id: I131f27f2f6fcd5cd76f4fbc13c632f7cd1aa17d0
diff --git a/package.json b/package.json
index ff66a45..ef2db62 100644
--- a/package.json
+++ b/package.json
@@ -6,6 +6,7 @@
     "angular-ui-router": "1.0.0-beta.1",
     "jquery": "^3.1.1",
     "lodash": "^4.17.2",
+    "pluralize": "^3.1.0",
     "rxjs": "^5.0.1",
     "socket.io-client": "^1.7.2"
   },
diff --git a/src/app/core/index.ts b/src/app/core/index.ts
index d93e627..2a9603c 100644
--- a/src/app/core/index.ts
+++ b/src/app/core/index.ts
@@ -7,6 +7,7 @@
 import {RuntimeStates} from './services/runtime-states';
 import {NavigationService} from './services/navigation';
 import {PageTitle} from './services/page-title';
+import {ConfigHelpers} from './services/helpers/config.helpers';
 
 export const xosCore = 'xosCore';
 
@@ -16,6 +17,7 @@
   .provider('RuntimeStates', RuntimeStates)
   .service('NavigationService', NavigationService)
   .service('PageTitle', PageTitle)
+  .service('ConfigHelpers', ConfigHelpers)
   .component('xosHeader', xosHeader)
   .component('xosFooter', xosFooter)
   .component('xosNav', xosNav)
diff --git a/src/app/core/services/helpers/config.helpers.spec.ts b/src/app/core/services/helpers/config.helpers.spec.ts
new file mode 100644
index 0000000..44260d8
--- /dev/null
+++ b/src/app/core/services/helpers/config.helpers.spec.ts
@@ -0,0 +1,63 @@
+import * as angular from 'angular';
+import 'angular-mocks';
+import 'angular-ui-router';
+
+import {IXosConfigHelpersService} from './config.helpers';
+import {xosCore} from '../../index';
+
+let service: IXosConfigHelpersService;
+describe('The ConfigHelpers service', () => {
+
+  beforeEach(angular.mock.module(xosCore));
+
+  beforeEach(angular.mock.inject((
+    ConfigHelpers: IXosConfigHelpersService,
+  ) => {
+    service = ConfigHelpers;
+  }));
+
+  describe('The pluralize function', () => {
+    it('should pluralize string', () => {
+      expect(service.pluralize('test')).toEqual('tests');
+      expect(service.pluralize('test', 1)).toEqual('test');
+      expect(service.pluralize('xos')).toEqual('xosses');
+      expect(service.pluralize('slice')).toEqual('slices');
+    });
+
+    it('should preprend count to string', () => {
+      expect(service.pluralize('test', 6, true)).toEqual('6 tests');
+      expect(service.pluralize('test', 1, true)).toEqual('1 test');
+    });
+  });
+
+  describe('the label formatter', () => {
+    it('should format a camel case string', () => {
+      expect(service.toLabel('camelCase')).toEqual('Camel case');
+    });
+
+    it('should format a snake case string', () => {
+      expect(service.toLabel('snake_case')).toEqual('Snake case');
+    });
+
+    it('should format a kebab case string', () => {
+      expect(service.toLabel('kebab-case')).toEqual('Kebab case');
+    });
+
+    it('should set plural', () => {
+      expect(service.toLabel('kebab-case', true)).toEqual('Kebab cases');
+    });
+
+    it('should format an array of strings', () => {
+      let strings: string[] = ['camelCase', 'snake_case', 'kebab-case'];
+      let labels = ['Camel case', 'Snake case', 'Kebab case'];
+      expect(service.toLabel(strings)).toEqual(labels);
+    });
+
+    it('should set plural on an array of strings', () => {
+      let strings: string[] = ['camelCase', 'snake_case', 'kebab-case'];
+      let labels = ['Camel cases', 'Snake cases', 'Kebab cases'];
+      expect(service.toLabel(strings, true)).toEqual(labels);
+    });
+  });
+
+});
diff --git a/src/app/core/services/helpers/config.helpers.ts b/src/app/core/services/helpers/config.helpers.ts
new file mode 100644
index 0000000..44d3cdc
--- /dev/null
+++ b/src/app/core/services/helpers/config.helpers.ts
@@ -0,0 +1,107 @@
+import * as _ from 'lodash';
+import * as pluralize from 'pluralize';
+import {IXosTableColumn} from '../../table/table';
+
+export interface IXosModelDefsField {
+  name: string;
+  type: string;
+}
+
+export interface IXosConfigHelpersService {
+  modeldefToTableCfg(fields: IXosModelDefsField[]): any[]; // TODO use a proper interface
+  pluralize(string: string, quantity?: number, count?: boolean): string;
+  toLabel(string: string, pluralize?: boolean): string;
+}
+
+export class ConfigHelpers {
+
+  constructor() {
+    pluralize.addIrregularRule('xos', 'xosses');
+    pluralize.addPluralRule(/slice$/i, 'slices');
+  }
+
+  pluralize(string: string, quantity?: number, count?: boolean): string {
+    return pluralize(string, quantity, count);
+  }
+
+  toLabel(string: string, pluralize?: boolean): string {
+
+    if (angular.isArray(string)) {
+      return _.map(string, s => {
+        return this.toLabel(s, pluralize);
+      });
+    }
+
+    if (pluralize) {
+      string = this.pluralize(string);
+    }
+
+    string = this.fromCamelCase(string);
+    string = this.fromSnakeCase(string);
+    string = this.fromKebabCase(string);
+
+    return this.capitalizeFirst(string);
+  }
+
+  modeldefToTableCfg(fields: IXosModelDefsField[]): IXosTableColumn[] {
+    const excluded_fields = [
+      'created',
+      'updated',
+      'enacted',
+      'policed',
+      'backend_register',
+      'deleted',
+      'write_protect',
+      'lazy_blocked',
+      'no_sync',
+      'no_policy',
+      'omf_friendly',
+      'enabled'
+    ];
+    const cfg =  _.map(fields, (f) => {
+      if (excluded_fields.indexOf(f.name) > -1) {
+        return;
+      }
+      const col: IXosTableColumn =  {
+        label: this.toLabel(f.name),
+        prop: f.name
+      };
+
+      if (f.name === 'backend_status') {
+        col.type = 'icon';
+        col.formatter = (item) => {
+          if (item.backend_status.indexOf('1') > -1) {
+            return 'check';
+          }
+          if (item.backend_status.indexOf('2') > -1) {
+            return 'exclamation-circle';
+          }
+          if (item.backend_status.indexOf('0') > -1) {
+            return 'clock-o';
+          }
+        };
+      }
+      return col;
+    })
+      .filter(v => angular.isDefined(v));
+
+    return cfg;
+  };
+
+  private fromCamelCase(string: string): string {
+    return string.split(/(?=[A-Z])/).map(w => w.toLowerCase()).join(' ');
+  }
+
+  private fromSnakeCase(string: string): string {
+    return string.split('_').join(' ').trim();
+  }
+
+  private fromKebabCase(string: string): string {
+    return string.split('-').join(' ').trim();
+  }
+
+  private capitalizeFirst(string: string): string {
+    return string.slice(0, 1).toUpperCase() + string.slice(1);
+  }
+}
+
diff --git a/src/app/core/table/table.html b/src/app/core/table/table.html
index 58a46e0..f425c0b 100644
--- a/src/app/core/table/table.html
+++ b/src/app/core/table/table.html
@@ -15,10 +15,10 @@
                 {{col.label}}
                 <span ng-if="vm.config.order">
                     <a href="" ng-click="vm.orderBy = col.prop; vm.reverse = false">
-                      <i class="glyphicon glyphicon-chevron-up"></i>
+                      <i class="fa fa-chevron-up"></i>
                     </a>
                     <a href="" ng-click="vm.orderBy = col.prop; vm.reverse = true">
-                      <i class="glyphicon glyphicon-chevron-down"></i>
+                      <i class="fa fa-chevron-down"></i>
                     </a>
                   </span>
             </th>
@@ -51,8 +51,8 @@
             <td ng-repeat="col in vm.columns">
                 <span ng-if="!col.type || col.type === 'text'">{{item[col.prop]}}</span>
                 <span ng-if="col.type === 'boolean'">
-                    <i class="glyphicon"
-                       ng-class="{'glyphicon-ok': item[col.prop], 'glyphicon-remove': !item[col.prop]}">
+                    <i class="fa"
+                       ng-class="{'fa-ok': item[col.prop], 'fa-remove': !item[col.prop]}">
                     </i>
                   </span>
                 <span ng-if="col.type === 'date'">
@@ -73,7 +73,7 @@
                     {{col.formatter(item)}}
                   </span>
                 <span ng-if="col.type === 'icon'">
-                    <i class="glyphicon glyphicon-{{col.formatter(item)}}">
+                    <i class="fa fa-{{col.formatter(item)}}">
                     </i>
                   </span>
             </td>
@@ -83,7 +83,7 @@
                    ng-click="action.cb(item)"
                    title="{{action.label}}">
                     <i
-                            class="glyphicon glyphicon-{{action.icon}}"
+                            class="fa fa-{{action.icon}}"
                             style="color: {{action.color}};"></i>
                 </a>
             </td>
diff --git a/src/app/core/table/table.ts b/src/app/core/table/table.ts
index 1d40092..77067fa 100644
--- a/src/app/core/table/table.ts
+++ b/src/app/core/table/table.ts
@@ -4,6 +4,23 @@
 import './table.scss';
 import * as _ from 'lodash';
 
+enum EXosTableColType {
+  'boolean',
+  'array',
+  'object',
+  'custom',
+  'date' ,
+  'icon'
+}
+
+export interface IXosTableColumn {
+  label: string;
+  prop: string;
+  type?: string; // understand why enum does not work
+  formatter?(item: any): string;
+  link?(item: any): string;
+}
+
 interface IXosTableCgfOrder {
   reverse: boolean;
   field: string;
diff --git a/src/app/datasources/stores/model.store.spec.ts b/src/app/datasources/stores/model.store.spec.ts
index 48798df..797685b 100644
--- a/src/app/datasources/stores/model.store.spec.ts
+++ b/src/app/datasources/stores/model.store.spec.ts
@@ -57,7 +57,7 @@
     WebSocket = _WebSocket_;
 
     // ModelRest will call the backend
-    httpBackend.expectGET(`${AppConfig.apiEndpoint}/core/tests`)
+    httpBackend.expectGET(`${AppConfig.apiEndpoint}/core/samples`)
       .respond(queryData);
   }));
 
@@ -67,7 +67,7 @@
 
   it('the first event should be the resource response', (done) => {
     let event = 0;
-    service.query('test')
+    service.query('sample')
       .subscribe(collection => {
         event++;
         if (event === 2) {
@@ -83,7 +83,7 @@
   describe('when a web-socket event is received for that model', () => {
     it('should update the collection', (done) => {
       let event = 0;
-      service.query('test')
+      service.query('sample')
         .subscribe(
           collection => {
             event++;
@@ -102,7 +102,7 @@
         );
       window.setTimeout(() => {
         WebSocket.next({
-          model: 'test',
+          model: 'sample',
           msg: {
             changed_fields: ['id'],
             object: {id: 3, name: 'baz'},
diff --git a/src/app/datasources/stores/model.store.ts b/src/app/datasources/stores/model.store.ts
index d306f9d..e33d7cd 100644
--- a/src/app/datasources/stores/model.store.ts
+++ b/src/app/datasources/stores/model.store.ts
@@ -33,7 +33,7 @@
   }
 
   private loadInitialData(model: string) {
-    const endpoint = `/core/${model.toLowerCase()}s/`;
+    const endpoint = `/core/${model.toLowerCase()}s`; // NOTE check is pluralize is better
     this.ModelRest.getResource(endpoint).query().$promise
       .then(
         res => {
diff --git a/src/app/views/crud/crud.ts b/src/app/views/crud/crud.ts
index 4663b59..d1e4d0e 100644
--- a/src/app/views/crud/crud.ts
+++ b/src/app/views/crud/crud.ts
@@ -1,12 +1,13 @@
 import {IXosTableCfg} from '../../core/table/table';
 import {IModelStoreService} from '../../datasources/stores/model.store';
+import {IXosConfigHelpersService} from '../../core/services/helpers/config.helpers';
 export interface IXosCrudData {
   model: string;
   xosTableCfg: IXosTableCfg;
 }
 
 class CrudController {
-  static $inject = ['$state', '$scope', 'ModelStore'];
+  static $inject = ['$state', '$scope', 'ModelStore', 'ConfigHelpers'];
 
   public data: IXosCrudData;
   public tableCfg: IXosTableCfg;
@@ -16,17 +17,19 @@
   constructor(
     private $state: angular.ui.IStateService,
     private $scope: angular.IScope,
-    private store: IModelStoreService
+    private store: IModelStoreService,
+    private ConfigHelpers: IXosConfigHelpersService
   ) {
     this.data = this.$state.current.data;
     this.tableCfg = this.data.xosTableCfg;
-    this.title = this.data.model;
+    this.title = this.ConfigHelpers.pluralize(this.data.model);
 
     this.store.query(this.data.model)
       .subscribe(
         (event) => {
           // NOTE Observable mess with $digest cycles, we need to schedule the expression later
           $scope.$evalAsync(() => {
+            this.title = this.ConfigHelpers.pluralize(this.data.model, event.length);
             this.tableData = event;
           });
         }
diff --git a/src/index.ts b/src/index.ts
index 77955b3..7021f86 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -23,40 +23,12 @@
 import * as _ from 'lodash';
 import {IXosNavigationService} from './app/core/services/navigation';
 import {IXosPageTitleService} from './app/core/services/page-title';
+import {IXosConfigHelpersService} from './app/core/services/helpers/config.helpers';
 
 export interface IXosState extends angular.ui.IState {
   data: IXosCrudData;
 };
 
-const modeldefToTableCfg = (fields: {name: string, type: string}[]): any[] => {
-  const excluded_fields = [
-    'created',
-    'updated',
-    'enacted',
-    'policed',
-    'backend_register',
-    'deleted',
-    'write_protect',
-    'lazy_blocked',
-    'no_sync',
-    'no_policy',
-    'omf_friendly',
-    'enabled'
-  ];
-  const cfg =  _.map(fields, (f) => {
-    if (excluded_fields.indexOf(f.name) > -1) {
-      return;
-    }
-    return {
-      label: `${f.name}`, // TODO confert name to label
-      prop: f.name
-    };
-  })
-    .filter(v => angular.isDefined(v));
-
-  return cfg;
-};
-
 angular
   .module('app', [xosCore, xosDataSources, xosViews, 'ui.router', 'ngResource'])
   .config(routesConfig)
@@ -69,25 +41,27 @@
     ModelDefs: IModeldefsService,
     RuntimeStates: IRuntimeStatesService,
     NavigationService: IXosNavigationService,
+    ConfigHelpers: IXosConfigHelpersService,
     PageTitle: IXosPageTitleService
   ) => {
     // Dinamically add a  core states
     ModelDefs.get()
       .then((models: IModeldef[]) => {
+        // TODO move in a separate service and test
         _.forEach(models, (m: IModeldef) => {
           const state: IXosState = {
             parent: 'xos',
-            url: `${m.name.toLowerCase()}s`, // TODO use https://github.com/blakeembrey/pluralize
+            url: ConfigHelpers.pluralize(m.name.toLowerCase()),
             component: 'xosCrud',
             data: {
               model: m.name,
               xosTableCfg: {
-                columns: modeldefToTableCfg(m.fields)
+                columns: ConfigHelpers.modeldefToTableCfg(m.fields)
               }
             }
           };
-          RuntimeStates.addState(`${m.name.toLowerCase()}s`, state);
-          NavigationService.add({label: `${m.name}s`, url: `${m.name.toLowerCase()}s`});
+          RuntimeStates.addState(ConfigHelpers.pluralize(m.name.toLowerCase()), state);
+          NavigationService.add({label: ConfigHelpers.pluralize(m.name), url: ConfigHelpers.pluralize(m.name.toLowerCase())});
         });
       });
   });
diff --git a/src/interceptors.ts b/src/interceptors.ts
index 4db7b9c..53fd7df 100644
--- a/src/interceptors.ts
+++ b/src/interceptors.ts
@@ -45,6 +45,7 @@
   return {
     request: (req) => {
       if (req.url.indexOf('.html') === -1) {
+        // NOTE this may fail if there are already query params
         req.url += '?no_hyperlinks=1';
       }
       return req;
diff --git a/src/routes.ts b/src/routes.ts
index 35723c5..c6de435 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -7,6 +7,10 @@
   $locationProvider.html5Mode(false).hashPrefix('');
   $urlRouterProvider.otherwise('/');
 
+  // TODO onload redirect to correct URL
+  // routes are created asynchronously so by default any time you reload
+  // you end up in /
+
   $stateProvider
     .state('xos', {
       abstract: true,