CORD-582, CORD-734 Registering events listeners for keyboard shortcuts
and displaying them in the side panel

Change-Id: Ifbb227b3a425be5c33d1fe211abd473209414896
diff --git a/src/app/core/header/header.spec.ts b/src/app/core/header/header.spec.ts
index 0bd6877..c1d6062 100644
--- a/src/app/core/header/header.spec.ts
+++ b/src/app/core/header/header.spec.ts
@@ -42,6 +42,10 @@
   }
 };
 
+const MockXosKeyboardShortcut = {
+  registerKeyBinding: jasmine.createSpy('registerKeyBinding')
+};
+
 describe('header component', () => {
   beforeEach(() => {
     angular
@@ -52,6 +56,7 @@
       .value('toastrConfig', MockToastrConfig)
       .value('AuthService', MockAuth)
       .value('NavigationService', {})
+      .value('XosKeyboardShortcut', MockXosKeyboardShortcut)
       .value('StyleConfig', {
         logo: 'cord-logo.png',
       })
@@ -77,6 +82,17 @@
     expect(header.trim()).not.toBeNull();
   });
 
+  it('should register a keyboard shortcut', () => {
+    expect(MockXosKeyboardShortcut.registerKeyBinding).toHaveBeenCalled();
+    // expect(MockXosKeyboardShortcut.registerKeyBinding).toHaveBeenCalledWith({
+    //   key: 'f',
+    //   description: 'Select search box',
+    //   cb: () => {
+    //     $('.navbar-form input').focus();
+    //   },
+    // }, 'global');
+  });
+
   it('should print user email', () => {
     expect($('.profile-address', element).text()).toBe('test@xos.us');
   });
diff --git a/src/app/core/header/header.ts b/src/app/core/header/header.ts
index 1f01bf8..8926080 100644
--- a/src/app/core/header/header.ts
+++ b/src/app/core/header/header.ts
@@ -7,13 +7,14 @@
 import * as $ from 'jquery';
 import {IXosStyleConfig} from '../../../index';
 import {IXosSearchService, IXosSearchResult} from '../../datasources/helpers/search.service';
+import {IXosKeyboardShortcutService} from '../services/keyboard-shortcut';
 
 export interface INotification extends IWSEvent {
   viewed?: boolean;
 }
 
 class HeaderController {
-  static $inject = ['$scope', '$rootScope', '$state', 'AuthService', 'SynchronizerStore', 'toastr', 'toastrConfig', 'NavigationService', 'StyleConfig', 'SearchService'];
+  static $inject = ['$scope', '$rootScope', '$state', 'AuthService', 'SynchronizerStore', 'toastr', 'toastrConfig', 'NavigationService', 'StyleConfig', 'SearchService', 'XosKeyboardShortcut'];
   public notifications: INotification[] = [];
   public newNotifications: INotification[] = [];
   public version: string;
@@ -33,7 +34,8 @@
     private toastrConfig: ng.toastr.IToastrConfig,
     private NavigationService: IXosNavigationService,
     private StyleConfig: IXosStyleConfig,
-    private SearchService: IXosSearchService
+    private SearchService: IXosSearchService,
+    private XosKeyboardShortcut: IXosKeyboardShortcutService
   ) {
     this.version = require('../../../../package.json').version;
     angular.extend(this.toastrConfig, {
@@ -53,11 +55,13 @@
     };
 
     // listen for keypress
-    $(document).on('keyup', (e) => {
-      if (e.key === 'f') {
+    this.XosKeyboardShortcut.registerKeyBinding({
+      key: 'f',
+      description: 'Select search box',
+      cb: () => {
         $('.navbar-form input').focus();
-      }
-    });
+      },
+    }, 'global');
 
     // redirect to selected page
     this.routeSelected = (item: IXosSearchResult) => {
diff --git a/src/app/core/index.ts b/src/app/core/index.ts
index 4b89ac8..a31aa96 100644
--- a/src/app/core/index.ts
+++ b/src/app/core/index.ts
@@ -19,6 +19,8 @@
 import {xosSidePanel} from './side-panel/side-panel';
 import {XosSidePanel} from './side-panel/side-panel.service';
 import {XosComponentInjector} from './services/helpers/component-injector.helpers';
+import {XosKeyboardShortcut} from './services/keyboard-shortcut';
+import {xosKeyBindingPanel} from './key-binding/key-binding-panel';
 
 export const xosCore = 'xosCore';
 
@@ -36,6 +38,7 @@
   .service('ConfigHelpers', ConfigHelpers)
   .service('ModelSetup', ModelSetup)
   .service('XosSidePanel', XosSidePanel)
+  .service('XosKeyboardShortcut', XosKeyboardShortcut)
   .service('XosComponentInjector', XosComponentInjector)
   .directive('xosLinkWrapper', xosLinkWrapper)
   .component('xosHeader', xosHeader)
@@ -47,4 +50,5 @@
   .component('xosField', xosField)
   .component('xosAlert', xosAlert)
   .component('xosValidation', xosValidation)
-  .component('xosSidePanel', xosSidePanel);
+  .component('xosSidePanel', xosSidePanel)
+  .component('xosKeyBindingPanel', xosKeyBindingPanel);
diff --git a/src/app/core/key-binding/key-binding-panel.html b/src/app/core/key-binding/key-binding-panel.html
new file mode 100644
index 0000000..7ce982a
--- /dev/null
+++ b/src/app/core/key-binding/key-binding-panel.html
@@ -0,0 +1,25 @@
+<div class="row">
+    <div class="col-xs-12">
+        <h3>Active Key Bindings</h3>
+    </div>
+</div>
+<div class="row" ng-repeat="(k, v) in vm.bindings">
+    <div class="col-xs-12" ng-if="v.length > 0">
+        <h5>{{k | capitalize}}</h5>
+    </div>
+    <div class="col-xs-12" ng-repeat="binding in v" ng-if="binding.description">
+        <div class="row">
+            <div class="col-xs-5">
+                <code class="text-center">
+                    <span ng-repeat="m in binding.modifiers">
+                        {{m}} +
+                    </span>
+                    {{binding.key}}
+                </code>
+            </div>
+            <div class="col-xs-7">
+                <p>{{binding.description}}</p>
+            </div>
+        </div>
+    </div>
+</div>
\ No newline at end of file
diff --git a/src/app/core/key-binding/key-binding-panel.scss b/src/app/core/key-binding/key-binding-panel.scss
new file mode 100644
index 0000000..0fde571
--- /dev/null
+++ b/src/app/core/key-binding/key-binding-panel.scss
@@ -0,0 +1,7 @@
+@import './../../style/vars.scss';
+
+xos-key-binding-panel {
+  code {
+    background: $background-light-color;
+  }
+}
\ No newline at end of file
diff --git a/src/app/core/key-binding/key-binding-panel.ts b/src/app/core/key-binding/key-binding-panel.ts
new file mode 100644
index 0000000..4ee31eb
--- /dev/null
+++ b/src/app/core/key-binding/key-binding-panel.ts
@@ -0,0 +1,19 @@
+import {IXosKeyboardShortcutService, IXosKeyboardShortcutMap} from '../services/keyboard-shortcut';
+import './key-binding-panel.scss';
+
+class XosKeyBindingPanelController {
+  static $inject = ['$scope', 'XosKeyboardShortcut'];
+  public bindings: IXosKeyboardShortcutMap;
+  constructor (
+    private $scope: ng.IScope,
+    private XosKeyboardShortcut: IXosKeyboardShortcutService
+  ) {
+    this.bindings = this.XosKeyboardShortcut.keyMapping;
+  }
+}
+
+export const xosKeyBindingPanel: angular.IComponentOptions = {
+  template: require('./key-binding-panel.html'),
+  controllerAs: 'vm',
+  controller: XosKeyBindingPanelController
+};
diff --git a/src/app/core/services/helpers/component-injector.helpers.ts b/src/app/core/services/helpers/component-injector.helpers.ts
index e70e257..908e4c7 100644
--- a/src/app/core/services/helpers/component-injector.helpers.ts
+++ b/src/app/core/services/helpers/component-injector.helpers.ts
@@ -16,7 +16,7 @@
 }
 
 export class XosComponentInjector implements IXosComponentInjectorService {
-  static $inject = ['$rootScope', '$compile', '$transitions', '$log'];
+  static $inject = ['$rootScope', '$compile', '$transitions', '$log', '$timeout'];
 
   public injectedComponents: IXosInjectedComponent[] = [];
 
@@ -24,7 +24,8 @@
     private $rootScope: ng.IRootScopeService,
     private $compile: ng.ICompileService,
     private $transitions: any,
-    private $log: ng.ILogService
+    private $log: ng.ILogService,
+    private $timeout: ng.ITimeoutService
   ) {
     $transitions.onFinish({ to: '**' }, (transtion) => {
       // wait for route transition to complete
@@ -79,6 +80,10 @@
     const componentTag = `<${componentTagName} ${attr}>${transclude || ''}</${componentTagName}>`;
     const element = this.$compile(componentTag)(scope);
 
+    this.$timeout(function() {
+      scope.$apply();
+    });
+
     targetEl.append(element);
 
     // store a reference for the element
diff --git a/src/app/core/services/keyboard-shortcut.ts b/src/app/core/services/keyboard-shortcut.ts
new file mode 100644
index 0000000..e649abf
--- /dev/null
+++ b/src/app/core/services/keyboard-shortcut.ts
@@ -0,0 +1,164 @@
+import * as $ from 'jquery';
+import * as _ from 'lodash';
+import {IXosSidePanelService} from '../side-panel/side-panel.service';
+
+export interface IXosKeyboardShortcutService {
+  keyMapping: IXosKeyboardShortcutMap;
+  registerKeyBinding(binding: IXosKeyboardShortcutBinding, target?: string);
+  setup(): void;
+}
+
+export interface IXosKeyboardShortcutMap {
+  global: IXosKeyboardShortcutBinding[];
+  view: IXosKeyboardShortcutBinding[];
+}
+
+export interface IXosKeyboardShortcutBinding {
+  key: string;
+  cb: any;
+  modifiers?: string[];
+  description?: string;
+  onInput?: boolean;
+}
+
+export class XosKeyboardShortcut implements IXosKeyboardShortcutService {
+  static $inject = ['$log', '$transitions', 'XosSidePanel'];
+  public keyMapping: IXosKeyboardShortcutMap = {
+    global: [],
+    view: []
+  };
+  public allowedModifiers: string[] = ['Meta', 'Alt', 'Shift', 'Control'];
+  public activeModifiers: string[] = [];
+
+  private toggleKeyBindingPanel = (): void => {
+    if (!this.isPanelOpen) {
+      this.XosSidePanel.injectComponent('xosKeyBindingPanel');
+      this.isPanelOpen = true;
+    }
+    else {
+      this.XosSidePanel.removeInjectedComponents();
+      this.isPanelOpen = false;
+    }
+  };
+
+  /* tslint:disable */
+  public baseBindings: IXosKeyboardShortcutBinding[] = [
+    {
+      key: '?',
+      description: 'Toggle Shortcut Panel',
+      cb: this.toggleKeyBindingPanel,
+    },
+    {
+      key: '/',
+      description: 'Toggle Shortcut Panel',
+      cb: this.toggleKeyBindingPanel,
+    },
+    {
+      key: 'Escape',
+      cb: (event) => {
+        // NOTE removing focus from input elements on Esc
+        event.target.blur();
+      },
+      onInput: true
+    }
+  ];
+  /* tslint:enable */
+
+  private isPanelOpen: boolean;
+
+  constructor(
+    private $log: ng.ILogService,
+    $transitions: any,
+    private XosSidePanel: IXosSidePanelService
+  ) {
+    this.keyMapping.global = this.keyMapping.global.concat(this.baseBindings);
+
+    $transitions.onStart({ to: '**' }, (transtion) => {
+      // delete view keys before that a new view is loaded
+        this.$log.info(`[XosKeyboardShortcut] Deleting view keys`);
+        this.keyMapping.view = [];
+    });
+  }
+
+
+  public setup(): void {
+    this.$log.info(`[XosKeyboardShortcut] Setup`);
+    $('body').on('keydown', (e) => {
+
+      let pressedKey = null;
+
+      if (e.key.length === 1 && e.key.match(/[a-z]/i) || String.fromCharCode(e.keyCode).toLowerCase().match(/[a-z]/i)) {
+        // alphabet letters found
+        pressedKey = String.fromCharCode(e.keyCode).toLowerCase();
+      }
+      else {
+        pressedKey = e.key;
+      }
+
+      if (this.allowedModifiers.indexOf(e.key) > -1) {
+        this.addActiveModifierKey(e.key);
+        return;
+      }
+
+      // NOTE e.key change if we are using some modifiers (eg: Alt) while getting the value from the keyCode works
+      const binding = this.findBindedShortcut(pressedKey);
+      if (angular.isDefined(binding) && angular.isFunction(binding.cb)) {
+        // NOTE disable binding if they come from an input or textarea
+        // if not different specified
+        const t = e.target.tagName.toLowerCase();
+        if ((t === 'input' || t === 'textarea') && !binding.onInput) {
+          return;
+        }
+        binding.cb(e);
+        e.preventDefault();
+      }
+    });
+
+    $('body').on('keyup', (e) => {
+      if (this.allowedModifiers.indexOf(e.key) > -1) {
+        this.removeActiveModifierKey(e.key);
+        return;
+      }
+    });
+  }
+
+  public registerKeyBinding(binding: IXosKeyboardShortcutBinding, target: string = 'view'): void {
+    // NOTE check for already taken binding (by key)
+    // NOTE check target is either 'view' or 'global'
+    this.$log.info(`[XosKeyboardShortcut] Registering binding for key: ${binding.key}`);
+    if (!_.find(this.keyMapping[target], {key: binding.key})) {
+      this.keyMapping[target].push(binding);
+    }
+  }
+
+  private addActiveModifierKey(key: string) {
+    if (this.activeModifiers.indexOf(key) === -1) {
+      this.activeModifiers.push(key);
+    }
+  }
+
+  private removeActiveModifierKey(key: string) {
+    _.remove(this.activeModifiers, k => k === key);
+  }
+
+  private findBindedShortcut(key: string): IXosKeyboardShortcutBinding {
+    // NOTE search for binding in the global map
+    let target =  _.find(this.keyMapping.global, {key: key});
+
+    // NOTE if it is not there look in the view map
+    if (!angular.isDefined(target)) {
+      target = _.find(this.keyMapping.view, {key: key});
+    }
+
+
+    if (target && target.modifiers) {
+      // if not all the modifier keys for that binding are pressed
+      if (_.difference(target.modifiers, this.activeModifiers).length > 0) {
+        // do not match
+        return;
+      };
+    }
+    return target;
+  }
+
+}
diff --git a/src/app/core/side-panel/side-panel.service.ts b/src/app/core/side-panel/side-panel.service.ts
index a7471f5..96e4162 100644
--- a/src/app/core/side-panel/side-panel.service.ts
+++ b/src/app/core/side-panel/side-panel.service.ts
@@ -5,10 +5,11 @@
   open(): void;
   close(): void;
   injectComponent(componentName: string, attributes?: any, transclude?: string): void;
+  removeInjectedComponents(): void;
 }
 
 export class XosSidePanel implements IXosSidePanelService {
-  static $inject = ['$rootScope', '$compile', 'XosComponentInjector'];
+  static $inject = ['$rootScope', '$compile', '$timeout', 'XosComponentInjector'];
   public sidePanelElName = 'xos-side-panel';
   public sidePanelElClass = '.xos-side-panel';
   public sidePanelEl: JQuery;
@@ -16,6 +17,7 @@
   constructor (
     private $rootScope: ng.IRootScopeService,
     private $compile: ng.ICompileService,
+    private $timeout: ng.ITimeoutService,
     private XosComponentInjector: IXosComponentInjectorService
   ) {
     this.sidePanelEl = $(`${this.sidePanelElName} > ${this.sidePanelElClass}`);
@@ -33,4 +35,11 @@
     this.XosComponentInjector.injectComponent('#side-panel-container', componentName, attributes, transclude, true);
     this.open();
   }
+
+  public removeInjectedComponents() {
+    this.close();
+    this.$timeout(() => {
+      this.XosComponentInjector.removeInjectedComponents('#side-panel-container');
+    }, 500);
+  }
 }