From 5a5b61a6f66b8587c8646bfcb3cbf65b50ef4acd Mon Sep 17 00:00:00 2001
From: "Matt Kleiman, Sarah McAlear and Shruti Iyer"
 <pair+mkleiman+smcalear+siyer@pivotal.io>
Date: Mon, 15 May 2017 16:42:23 -0400
Subject: [PATCH 2/2] Creates query history list

 - Creates a journey test for the query output
---
 web/.eslintrc.js                                   |   7 +-
 web/Gruntfile.js                                   |   4 +-
 web/karma.conf.js                                  |   2 +-
 web/package.json                                   |   4 +
 .../feature_tests/query_tool_journey_test.py       | 112 ++++++++++++++
 .../xss_checks_pgadmin_debugger_test.py            |   2 +-
 .../static/js/history/history_collection.js        |  34 ++++
 web/pgadmin/static/js/history/index.js             |  14 ++
 web/pgadmin/static/jsx/components.jsx              |   2 +
 web/pgadmin/static/jsx/history/query_history.jsx   |  49 ++++++
 .../static/jsx/history/query_history_entry.jsx     |  93 +++++++++++
 .../sqleditor/templates/sqleditor/js/sqleditor.js  | 171 +++------------------
 web/regression/feature_utils/app_starter.py        |   2 +
 web/regression/feature_utils/pgadmin_page.py       |  28 ++--
 ...blity_spec.js => check_node_visibility_spec.js} |  16 +-
 .../javascript/history/history_collection_spec.js  |  83 ++++++++++
 .../history/query_history_entry_spec.jsx           |  50 ++++++
 .../javascript/history/query_history_spec.jsx      | 103 +++++++++++++
 web/regression/python_test_utils/test_utils.py     |   2 +
 web/webpack.config.js                              |  11 +-
 web/webpack.test.config.js                         |  10 +-
 web/yarn.lock                                      |  71 +++++++++
 22 files changed, 692 insertions(+), 178 deletions(-)
 create mode 100644 web/pgadmin/feature_tests/query_tool_journey_test.py
 create mode 100644 web/pgadmin/static/js/history/history_collection.js
 create mode 100644 web/pgadmin/static/js/history/index.js
 create mode 100644 web/pgadmin/static/jsx/history/query_history.jsx
 create mode 100644 web/pgadmin/static/jsx/history/query_history_entry.jsx
 rename web/regression/javascript/{check_node_visiblity_spec.js => check_node_visibility_spec.js} (60%)
 create mode 100644 web/regression/javascript/history/history_collection_spec.js
 create mode 100644 web/regression/javascript/history/query_history_entry_spec.jsx
 create mode 100644 web/regression/javascript/history/query_history_spec.jsx

diff --git a/web/.eslintrc.js b/web/.eslintrc.js
index c60569db..98486dcd 100644
--- a/web/.eslintrc.js
+++ b/web/.eslintrc.js
@@ -5,7 +5,10 @@ module.exports = {
     'amd': true,
     'jasmine': true,
   },
-  'extends': 'eslint:recommended',
+  'extends': [
+    'eslint:recommended',
+    "plugin:react/recommended",
+  ],
   'parserOptions': {
     'ecmaFeatures': {
       'experimentalObjectRestSpread': true,
@@ -40,6 +43,6 @@ module.exports = {
     'comma-dangle': [
       'error',
       'always-multiline'
-    ]
+    ],
   }
 };
\ No newline at end of file
diff --git a/web/Gruntfile.js b/web/Gruntfile.js
index 000224e4..46cd3593 100644
--- a/web/Gruntfile.js
+++ b/web/Gruntfile.js
@@ -1,9 +1,9 @@
 const webpackConfig = require('./webpack.config.js');
 
 var srcFiles = [
-  'pgadmin/static/jsx/*.jsx',
+  'pgadmin/static/jsx/**/*.jsx',
   'pgadmin/static/js/selection/*.js',
-  'regression/javascript/**.js',
+  'regression/javascript/**/*.jsx',
   'regression/javascript/**/*.js',
   '*.js',
 ];
diff --git a/web/karma.conf.js b/web/karma.conf.js
index 7fa51b50..01eb4224 100644
--- a/web/karma.conf.js
+++ b/web/karma.conf.js
@@ -16,7 +16,7 @@ module.exports = function (config) {
     ],
     preprocessors: {
       'regression/javascript/**/*.js': ['webpack'],
-      // 'regression/javascript/**/*.jsx': ['webpack'],
+      'regression/javascript/**/*.jsx': ['webpack'],
     },
     webpack: webpackConfig,
     webpackMiddleware: {
diff --git a/web/package.json b/web/package.json
index 760e1e0c..a775057d 100644
--- a/web/package.json
+++ b/web/package.json
@@ -5,6 +5,7 @@
     "babel-preset-es2015": "~6.24.0",
     "babel-preset-react": "~6.23.0",
     "enzyme": "~2.8.2",
+    "enzyme-matchers": "^3.1.0",
     "eslint": "^3.19.0",
     "eslint-plugin-react": "^6.10.3",
     "grunt": "~1.0.1",
@@ -14,6 +15,7 @@
     "grunt-karma": "^2.0.0",
     "grunt-webpack": "~2.0.1",
     "jasmine-core": "~2.5.2",
+    "jasmine-enzyme": "^3.1.0",
     "karma": "~1.5.0",
     "karma-babel-preprocessor": "^6.0.1",
     "karma-browserify": "~5.1.1",
@@ -34,7 +36,9 @@
     "babelify": "~7.3.0",
     "browserify": "~14.1.0",
     "exports-loader": "~0.6.4",
+    "immutability-helper": "^2.2.0",
     "imports-loader": "git+https://github.com/webpack-contrib/imports-loader.git#44d6f48463b256a17c1ba6fd9b5cc1449b4e379d",
+    "moment": "^2.18.1",
     "react": "~15.4.2",
     "react-dom": "~15.4.2",
     "requirejs": "~2.3.3",
diff --git a/web/pgadmin/feature_tests/query_tool_journey_test.py b/web/pgadmin/feature_tests/query_tool_journey_test.py
new file mode 100644
index 00000000..530ed8b2
--- /dev/null
+++ b/web/pgadmin/feature_tests/query_tool_journey_test.py
@@ -0,0 +1,112 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2017, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+import pyperclip
+import time
+
+from selenium.webdriver import ActionChains
+
+from regression.python_test_utils import test_utils
+from regression.feature_utils.base_feature_test import BaseFeatureTest
+
+
+class QueryToolJourneyTest(BaseFeatureTest):
+    """
+    Tests the path through the query tool
+    """
+
+    scenarios = [
+        ("Tests the path through the query tool", dict())
+    ]
+
+    def before(self):
+        connection = test_utils.get_db_connection(self.server['db'],
+                                                  self.server['username'],
+                                                  self.server['db_password'],
+                                                  self.server['host'],
+                                                  self.server['port'])
+        test_utils.drop_database(connection, "acceptance_test_db")
+        test_utils.create_database(self.server, "acceptance_test_db")
+        test_utils.create_table(self.server, "acceptance_test_db", "test_table")
+        self.page.add_server(self.server)
+
+    def runTest(self):
+        self._navigate_to_query_tool()
+        self._execute_query("SELECT * FROM test_table ORDER BY value")
+
+        self._test_copies_rows()
+        self._test_copies_columns()
+        self._test_history_tab()
+
+    def _test_copies_rows(self):
+        pyperclip.copy("old clipboard contents")
+        time.sleep(5)
+        self.page.driver.switch_to.default_content()
+        self.page.driver.switch_to_frame(self.page.driver.find_element_by_tag_name("iframe"))
+        self.page.find_by_xpath("//*[contains(@class, 'sr')]/*[1]/input[@type='checkbox']").click()
+        self.page.find_by_xpath("//*[@id='btn-copy-row']").click()
+
+        self.assertEqual("'Some-Name','6'",
+                         pyperclip.paste())
+
+    def _test_copies_columns(self):
+        pyperclip.copy("old clipboard contents")
+
+        self.page.driver.switch_to.default_content()
+        self.page.driver.switch_to_frame(self.page.driver.find_element_by_tag_name("iframe"))
+        self.page.find_by_xpath("//*[@data-test='output-column-header' and contains(., 'some_column')]/input").click()
+        self.page.find_by_xpath("//*[@id='btn-copy-row']").click()
+
+        self.assertEqual(
+            """'Some-Name'
+'Some-Other-Name'""",
+            pyperclip.paste())
+
+    def _test_history_tab(self):
+        self.__clear_query_tool()
+
+        editor_input = self.page.find_by_id("output-panel")
+        self.page.click_element(editor_input)
+        self._execute_query("SELECT * FROM shoes")
+
+        self.page.click_tab("History")
+        history_element = self.page.find_by_id("history_grid")
+        self.assertIn("SELECT * FROM test_table", history_element.text)
+        self.assertIn("SELECT * FROM shoes", history_element.text)
+
+    def __clear_query_tool(self):
+        self.page.click_element(self.page.find_by_xpath("//*[@id='btn-edit']"))
+        self.page.click_modal('Yes')
+
+    def _navigate_to_query_tool(self):
+        self.page.toggle_open_tree_item(self.server['name'])
+        self.page.toggle_open_tree_item('Databases')
+        self.page.toggle_open_tree_item('acceptance_test_db')
+        time.sleep(5)
+        self.page.find_by_partial_link_text("Tools").click()
+        self.page.find_by_partial_link_text("Query Tool").click()
+        self.page.click_tab('Query-1')
+        time.sleep(5)
+
+    def _execute_query(self, query):
+        ActionChains(self.page.driver).send_keys(query).perform()
+        self.page.driver.switch_to.default_content()
+        self.page.driver.switch_to_frame(self.page.driver.find_element_by_tag_name("iframe"))
+        self.page.find_by_id("btn-flash").click()
+
+    def after(self):
+        self.page.close_query_tool()
+        self.page.remove_server(self.server)
+
+        connection = test_utils.get_db_connection(self.server['db'],
+                                                  self.server['username'],
+                                                  self.server['db_password'],
+                                                  self.server['host'],
+                                                  self.server['port'])
+        test_utils.drop_database(connection, "acceptance_test_db")
diff --git a/web/pgadmin/feature_tests/xss_checks_pgadmin_debugger_test.py b/web/pgadmin/feature_tests/xss_checks_pgadmin_debugger_test.py
index 17bc7bf5..ae3af820 100644
--- a/web/pgadmin/feature_tests/xss_checks_pgadmin_debugger_test.py
+++ b/web/pgadmin/feature_tests/xss_checks_pgadmin_debugger_test.py
@@ -83,7 +83,7 @@ class CheckDebuggerForXssFeatureTest(BaseFeatureTest):
 
         # If debugger plugin is not found
         if is_error and is_error == "Debugger Error":
-            self.page.click_modal_ok()
+            self.page.click_modal('OK')
             self.skipTest("Please make sure that debugger plugin is properly configured")
         else:
             time.sleep(2)
diff --git a/web/pgadmin/static/js/history/history_collection.js b/web/pgadmin/static/js/history/history_collection.js
new file mode 100644
index 00000000..f7b6acd7
--- /dev/null
+++ b/web/pgadmin/static/js/history/history_collection.js
@@ -0,0 +1,34 @@
+/////////////////////////////////////////////////////////////
+//
+// pgAdmin 4 - PostgreSQL Tools
+//
+// Copyright (C) 2013 - 2017, The pgAdmin Development Team
+// This software is released under the PostgreSQL Licence
+//
+//////////////////////////////////////////////////////////////
+
+export default class HistoryCollection {
+
+  constructor(history_model) {
+    this.historyList = history_model;
+    this.onChange(() => {});
+  }
+
+  length() {
+    return this.historyList.length;
+  }
+
+  add(object) {
+    this.historyList.push(object);
+    this.onChangeHandler(this.historyList);
+  }
+
+  reset() {
+    this.historyList = [];
+    this.onChangeHandler(this.historyList);
+  }
+
+  onChange(onChangeHandler) {
+    this.onChangeHandler = onChangeHandler;
+  }
+}
\ No newline at end of file
diff --git a/web/pgadmin/static/js/history/index.js b/web/pgadmin/static/js/history/index.js
new file mode 100644
index 00000000..834878a8
--- /dev/null
+++ b/web/pgadmin/static/js/history/index.js
@@ -0,0 +1,14 @@
+/////////////////////////////////////////////////////////////
+//
+// pgAdmin 4 - PostgreSQL Tools
+//
+// Copyright (C) 2013 - 2017, The pgAdmin Development Team
+// This software is released under the PostgreSQL Licence
+//
+//////////////////////////////////////////////////////////////
+
+import historyCollection from './history_collection';
+
+export {
+  historyCollection,
+};
diff --git a/web/pgadmin/static/jsx/components.jsx b/web/pgadmin/static/jsx/components.jsx
index 5bcb5208..6ff34e4c 100644
--- a/web/pgadmin/static/jsx/components.jsx
+++ b/web/pgadmin/static/jsx/components.jsx
@@ -1,8 +1,10 @@
 
 import React from 'react';
 import {render} from 'react-dom';
+import QueryHistory from './history/query_history';
 
 export {
   render,
   React,
+  QueryHistory,
 };
\ No newline at end of file
diff --git a/web/pgadmin/static/jsx/history/query_history.jsx b/web/pgadmin/static/jsx/history/query_history.jsx
new file mode 100644
index 00000000..d36f5ce9
--- /dev/null
+++ b/web/pgadmin/static/jsx/history/query_history.jsx
@@ -0,0 +1,49 @@
+/////////////////////////////////////////////////////////////
+//
+// pgAdmin 4 - PostgreSQL Tools
+//
+// Copyright (C) 2013 - 2017, The pgAdmin Development Team
+// This software is released under the PostgreSQL Licence
+//
+//////////////////////////////////////////////////////////////
+
+import React from 'react';
+import QueryHistoryEntry from './query_history_entry';
+
+const liStyle = {
+  borderBottom: '1px solid #cccccc',
+};
+
+export default class QueryHistory extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      history: [],
+    };
+  }
+
+  componentWillMount() {
+    this.setState({history: this.props.historyCollection.historyList});
+    this.props.historyCollection.onChange((historyList) => this.setState({history: historyList}));
+  }
+
+  render() {
+    return <ul>
+      {_.chain(this.state.history)
+        .sortBy(historyEntry => historyEntry.start_time)
+        .reverse()
+        .map((entry, index) =>
+        <li key={index} style={liStyle}>
+          <QueryHistoryEntry historyEntry={entry}/>
+        </li>)
+        .value()
+      }
+    </ul>;
+  }
+}
+
+QueryHistory.propTypes = {
+  historyCollection: React.PropTypes.object.isRequired,
+};
\ No newline at end of file
diff --git a/web/pgadmin/static/jsx/history/query_history_entry.jsx b/web/pgadmin/static/jsx/history/query_history_entry.jsx
new file mode 100644
index 00000000..d66cb3a7
--- /dev/null
+++ b/web/pgadmin/static/jsx/history/query_history_entry.jsx
@@ -0,0 +1,93 @@
+/////////////////////////////////////////////////////////////
+//
+// pgAdmin 4 - PostgreSQL Tools
+//
+// Copyright (C) 2013 - 2017, The pgAdmin Development Team
+// This software is released under the PostgreSQL Licence
+//
+//////////////////////////////////////////////////////////////
+
+import React from 'react';
+import update from 'immutability-helper';
+import moment from 'moment';
+
+const outerDivStyle = {
+  paddingLeft: '10px',
+  fontFamily: 'monospace',
+  paddingRight: '20px',
+  fontSize: '14px',
+  backgroundColor: '#FFF',
+};
+const sqlStyle = {
+  textOverflow: 'ellipsis',
+  overflow: 'hidden',
+  whiteSpace: 'nowrap',
+  userSelect: 'auto',
+};
+const secondLineStyle = {
+  display: 'flex',
+  flexDirection: 'row',
+  justifyContent: 'space-between',
+  fontSize: '13px',
+  color: '#888888',
+};
+const timestampStyle = {
+  alignSelf: 'flex-start',
+};
+const rowsAffectedStyle = {
+  alignSelf: 'flex-end',
+};
+const errorMessageStyle = {
+  textOverflow: 'ellipsis',
+  overflow: 'hidden',
+  whiteSpace: 'nowrap',
+  userSelect: 'auto',
+  fontSize: '13px',
+  color: '#888888',
+};
+
+export default class QueryHistoryEntry extends React.Component {
+  formatDate(date) {
+    return (moment(date).format('MMM D YYYY [–] HH:mm:ss'));
+  }
+
+  render() {
+    return (
+      <div style={this.queryEntryBackgroundColor()}>
+        <div style={sqlStyle}>
+          {this.props.historyEntry.query}
+        </div>
+        <div style={secondLineStyle}>
+          <div style={timestampStyle}>
+            {this.formatDate(this.props.historyEntry.start_time)} /
+            total time: {this.props.historyEntry.total_time}
+          </div>
+          <div style={rowsAffectedStyle}>
+            {this.props.historyEntry.row_affected} rows affected
+          </div>
+        </div>
+        <div style={errorMessageStyle}>
+          {this.props.historyEntry.message}
+        </div>
+      </div>
+    );
+  }
+
+  queryEntryBackgroundColor() {
+    if (!this.props.historyEntry.status) {
+      return update(outerDivStyle, {$merge: {backgroundColor: '#F7D0D5'}});
+    }
+    return outerDivStyle;
+  }
+}
+
+QueryHistoryEntry.propTypes = {
+  historyEntry: React.PropTypes.shape({
+    query: React.PropTypes.string,
+    start_time: React.PropTypes.instanceOf(Date),
+    status: React.PropTypes.bool,
+    total_time: React.PropTypes.string,
+    row_affected: React.PropTypes.int,
+    message: React.PropTypes.string,
+  }),
+};
\ No newline at end of file
diff --git a/web/pgadmin/tools/sqleditor/templates/sqleditor/js/sqleditor.js b/web/pgadmin/tools/sqleditor/templates/sqleditor/js/sqleditor.js
index f3ca7b09..c7b6e03f 100644
--- a/web/pgadmin/tools/sqleditor/templates/sqleditor/js/sqleditor.js
+++ b/web/pgadmin/tools/sqleditor/templates/sqleditor/js/sqleditor.js
@@ -4,6 +4,8 @@ define(
     'backbone', 'backgrid', 'codemirror', 'pgadmin.misc.explain',
     'sources/selection/grid_selector', 'sources/selection/clipboard',
     'sources/selection/copy_data',
+    'sources/generated/history',
+    'sources/generated/reactComponents',
 
     'slickgrid', 'bootstrap', 'pgadmin.browser', 'wcdocker',
     'codemirror/mode/sql/sql', 'codemirror/addon/selection/mark-selection',
@@ -27,7 +29,7 @@ define(
     'slickgrid/slick.grid'
   ],
   function(
-    $, _, S, alertify, pgAdmin, Backbone, Backgrid, CodeMirror, pgExplain, GridSelector, clipboard, copyData
+    $, _, S, alertify, pgAdmin, Backbone, Backgrid, CodeMirror, pgExplain, GridSelector, clipboard, copyData, HistoryBundle, reactComponents
   ) {
     /* Return back, this has been called more than once */
     if (pgAdmin.SqlEditor)
@@ -226,6 +228,7 @@ define(
         self.messages_panel = main_docker.addPanel('messages', wcDocker.DOCK.STACKED, self.data_output_panel);
         self.history_panel = main_docker.addPanel('history', wcDocker.DOCK.STACKED, self.data_output_panel);
 
+
         self.render_history_grid();
 
         if (!self.handler.is_new_browser_tab) {
@@ -941,147 +944,14 @@ define(
 
         // Remove any existing grid first
         if (self.history_grid) {
-            self.history_grid.remove();
+          self.history_grid.remove();
         }
 
-        var history_model = Backbone.Model.extend({
-          defaults: {
-            status: undefined,
-            start_time: undefined,
-            query: undefined,
-            row_affected: 0,
-            row_retrieved: 0,
-            total_time: undefined,
-            message: ''
-          }
-        });
-
-        var history_collection = self.history_collection = new (Backbone.Collection.extend({
-            model: history_model,
-            // comparator to sort the history in reverse order of the start_time
-            comparator: function(a, b) {
-              return -a.get('start_time').localeCompare(b.get('start_time'));
-            }
-        }));
-        var columns = [{
-            name: "status",
-            label: "",
-            cell: Backgrid.Cell.extend({
-              class: 'sql-status-cell',
-              render: function() {
-                this.$el.empty();
-                var $btn = $('<button></button>', {
-                  class: 'btn btn-circle'
-                }).appendTo(this.$el);
-                var $circleDiv = $('<i></i>', {class: 'fa'}).appendTo($btn);
-                if (this.model.get('status')) {
-                  $btn.addClass('btn-success');
-                  $circleDiv.addClass('fa-check');
-                } else {
-                  $btn.addClass('btn-danger');
-                  $circleDiv.addClass('fa-times');
-                }
-
-                return this;
-              },
-              editable: false
-            }),
-            editable: false
-          }, {
-            name: "start_time",
-            label: "Date",
-            cell: "string",
-            editable: false,
-            resizeable: true
-          }, {
-            name: "query",
-            label: "Query",
-            cell: "string",
-            editable: false,
-            resizeable: true
-          }, {
-            name: "row_affected",
-            label: "Rows affected",
-            cell: "integer",
-            editable: false,
-            resizeable: true
-          }, {
-            name: "total_time",
-            label: "Total Time",
-            cell: "string",
-            editable: false,
-            resizeable: true
-          }, {
-            name: "message",
-            label: "Message",
-            cell: "string",
-            editable: false,
-            resizeable: true
-        }];
-
-
-        // Create Collection of Backgrid columns
-        var columnsColl = new Backgrid.Columns(columns);
-        var $history_grid = self.$el.find('#history_grid');
-
-        var grid = self.history_grid = new Backgrid.Grid({
-            columns: columnsColl,
-            collection: history_collection,
-            className: "backgrid table-bordered presentation table backgrid-striped"
-        });
-
-        // Render the grid
-        $history_grid.append(grid.render().$el);
-
-        var sizeAbleCol = new Backgrid.Extension.SizeAbleColumns({
-          collection: history_collection,
-          columns: columnsColl,
-          grid: self.history_grid
-        });
+        self.history_collection = new HistoryBundle.historyCollection([]);
 
-        $history_grid.find('thead').before(sizeAbleCol.render().el);
-
-        // Add resize handlers
-        var sizeHandler = new Backgrid.Extension.SizeAbleColumnsHandlers({
-          sizeAbleColumns: sizeAbleCol,
-          grid: self.history_grid,
-          saveColumnWidth: true
-        });
-
-        // sizeHandler should render only when table grid loaded completely.
-        setTimeout(function() {
-          $history_grid.find('thead').before(sizeHandler.render().el);
-        }, 1000);
-
-        // re render sizeHandler whenever history panel tab becomes visible
-        self.history_panel.on(wcDocker.EVENT.VISIBILITY_CHANGED, function(ev) {
-          $history_grid.find('thead').before(sizeHandler.render().el);
-        });
-
-        // Initialized table width 0 still not calculated
-        var table_width = 0;
-        // Listen to resize events
-        columnsColl.on('resize',
-          function(columnModel, newWidth, oldWidth, offset) {
-            var $grid_el = $history_grid.find('table'),
-                tbl_orig_width = $grid_el.width(),
-                offset = oldWidth - newWidth,
-                tbl_new_width = tbl_orig_width - offset;
-
-            if (table_width == 0) {
-              table_width = tbl_orig_width
-            }
-            // Table new width cannot be less than original width
-            if (tbl_new_width >= table_width) {
-              $($grid_el).css('width', tbl_new_width + 'px');
-            }
-            else {
-              // reset if calculated tbl_new_width is less than original
-              // table width
-              tbl_new_width = table_width;
-              $($grid_el).css('width', tbl_new_width + 'px');
-            }
-        });
+        let queryHistoryElement = reactComponents.React.createElement(
+          reactComponents.QueryHistory, {historyCollection: self.history_collection});
+        reactComponents.render(queryHistoryElement, $('#history_grid')[0]);
       },
 
       // Callback function for Add New Row button click.
@@ -1384,7 +1254,7 @@ define(
         this._stopEventPropogation(ev);
         this._closeDropDown(ev);
         // ask for confirmation only if anything to clear
-        if(!self.history_collection.length) { return; }
+        if(!self.history_collection.length()) { return; }
 
         alertify.confirm("{{ _('Clear history') }}",
           "{{ _('Are you sure you wish to clear the history?') }}",
@@ -2205,11 +2075,13 @@ define(
             $("#btn-flash").prop('disabled', false);
             self.trigger('pgadmin-sqleditor:loading-icon:hide');
             self.gridView.history_collection.add({
-              'status' : status, 'start_time': self.query_start_time.toString(),
-              'query': self.query, 'row_affected': self.rows_affected,
-              'total_time': self.total_time, 'message':msg
+              'status' : status,
+              'start_time': self.query_start_time,
+              'query': self.query,
+              'row_affected': self.rows_affected,
+              'total_time': self.total_time,
+              'message':msg,
             });
-            self.gridView.history_collection.sort();
           }
         },
 
@@ -2475,10 +2347,13 @@ define(
 
                 // Update the sql results in history tab
                 _.each(res.data.query_result, function(r) {
-                  self.gridView.history_collection.add(
-                    {'status' : r.status, 'start_time': self.query_start_time.toString(),
-                    'query': r.sql, 'row_affected': r.rows_affected,
-                    'total_time': self.total_time, 'message': r.result
+                  self.gridView.history_collection.add({
+                    'status': r.status,
+                    'start_time': self.query_start_time,
+                    'query': r.sql,
+                    'row_affected': r.rows_affected,
+                    'total_time': self.total_time,
+                    'message': r.result,
                   });
                 });
                 self.trigger('pgadmin-sqleditor:loading-icon:hide');
diff --git a/web/regression/feature_utils/app_starter.py b/web/regression/feature_utils/app_starter.py
index 96cc516b..f40d6921 100644
--- a/web/regression/feature_utils/app_starter.py
+++ b/web/regression/feature_utils/app_starter.py
@@ -11,6 +11,7 @@ import subprocess
 import signal
 import random
 
+import time
 
 class AppStarter:
     """ Helper for starting the full pgadmin4 app and loading the page via
@@ -40,6 +41,7 @@ class AppStarter:
         )
 
         self.driver.set_window_size(1024, 1024)
+        time.sleep(10)
         self.driver.get(
             "http://" + self.app_config.DEFAULT_SERVER + ":" +
             random_server_port)
diff --git a/web/regression/feature_utils/pgadmin_page.py b/web/regression/feature_utils/pgadmin_page.py
index f5d0ac79..4f15cd1a 100644
--- a/web/regression/feature_utils/pgadmin_page.py
+++ b/web/regression/feature_utils/pgadmin_page.py
@@ -30,15 +30,16 @@ class PgadminPage:
     def reset_layout(self):
         self.click_element(self.find_by_partial_link_text("File"))
         self.find_by_partial_link_text("Reset Layout").click()
-        self.click_modal_ok()
+        self.click_modal('OK')
         self.wait_for_reloading_indicator_to_disappear()
 
-    def click_modal_ok(self):
+    def click_modal(self, button_text):
         time.sleep(0.5)
         # Find active alertify dialog in case of multiple alertify dialog & click on that dialog
-        self.click_element(
-            self.find_by_xpath("//div[contains(@class, 'alertify') and not(contains(@class, 'ajs-hidden'))]//button[.='OK']")
-        )
+        modal_button = self.find_by_xpath(
+            "//div[contains(@class, 'alertify') and not(contains(@class, 'ajs-hidden'))]//button[.='%s']"
+            % button_text)
+        self.click_element(modal_button)
 
     def add_server(self, server_config):
         self.find_by_xpath("//*[@class='aciTreeText' and contains(.,'Servers')]").click()
@@ -70,10 +71,13 @@ class PgadminPage:
 
     def remove_server(self, server_config):
         self.driver.switch_to.default_content()
-        self.find_by_xpath("//*[@id='tree']//*[.='" + server_config['name'] + "' and @class='aciTreeItem']").click()
-        self.find_by_partial_link_text("Object").click()
-        self.find_by_partial_link_text("Delete/Drop").click()
-        self.click_modal_ok()
+        server_to_remove = self.find_by_xpath("//*[@id='tree']//*[.='" + server_config['name'] + "' and @class='aciTreeItem']")
+        self.click_element(server_to_remove)
+        object_menu_item = self.find_by_partial_link_text("Object")
+        self.click_element(object_menu_item)
+        delete_menu_item = self.find_by_partial_link_text("Delete/Drop")
+        self.click_element(delete_menu_item)
+        self.click_modal('OK')
 
     def select_tree_item(self, tree_item_text):
         self.find_by_xpath("//*[@id='tree']//*[.='" + tree_item_text + "' and @class='aciTreeItem']").click()
@@ -94,6 +98,7 @@ class PgadminPage:
         )
 
     def click_element(self, element):
+        # driver must be here to adhere to the method contract in selenium.webdriver.support.wait.WebDriverWait.until()
         def click_succeeded(driver):
             try:
                 element.click()
@@ -118,8 +123,9 @@ class PgadminPage:
         ActionChains(self.driver).send_keys(field_content).perform()
 
     def click_tab(self, tab_name):
-        self.find_by_xpath("//*[contains(@class,'wcTabTop')]//*[contains(@class,'wcPanelTab') "
-                           "and contains(.,'" + tab_name + "')]").click()
+        tab = self.find_by_xpath("//*[contains(@class,'wcTabTop')]//*[contains(@class,'wcPanelTab') "
+                           "and contains(.,'" + tab_name + "')]")
+        self.click_element(tab)
 
     def wait_for_input_field_content(self, field_name, content):
         def input_field_has_content(driver):
diff --git a/web/regression/javascript/check_node_visiblity_spec.js b/web/regression/javascript/check_node_visibility_spec.js
similarity index 60%
rename from web/regression/javascript/check_node_visiblity_spec.js
rename to web/regression/javascript/check_node_visibility_spec.js
index 130b1c21..96a8961f 100644
--- a/web/regression/javascript/check_node_visiblity_spec.js
+++ b/web/regression/javascript/check_node_visibility_spec.js
@@ -7,24 +7,24 @@
 //
 //////////////////////////////////////////////////////////////////////////
 
-define(["sources/check_node_visibility"],
-function (checkNodeVisibility, pgBrowser) {
-  describe("checkNodeVisibility", function () {
+define(['sources/check_node_visibility'],
+function (checkNodeVisibility) {
+  describe('checkNodeVisibility', function () {
 
     var browser;
 
     browser = jasmine.createSpyObj('browser', [
-                    'node_preference_data', 'get_preference']
+      'node_preference_data', 'get_preference']
                 );
 
-    describe("when node is server collection", function () {
-      it("returns true", function () {
+    describe('when node is server collection', function () {
+      it('returns true', function () {
         expect(checkNodeVisibility(browser, 'coll-server')).toEqual(true);
       });
     });
 
-    describe("when node is server", function () {
-      it("returns true", function () {
+    describe('when node is server', function () {
+      it('returns true', function () {
         expect(checkNodeVisibility(browser, 'server')).toEqual(true);
       });
     });
diff --git a/web/regression/javascript/history/history_collection_spec.js b/web/regression/javascript/history/history_collection_spec.js
new file mode 100644
index 00000000..e1baa554
--- /dev/null
+++ b/web/regression/javascript/history/history_collection_spec.js
@@ -0,0 +1,83 @@
+/////////////////////////////////////////////////////////////
+//
+// pgAdmin 4 - PostgreSQL Tools
+//
+// Copyright (C) 2013 - 2017, The pgAdmin Development Team
+// This software is released under the PostgreSQL Licence
+//
+//////////////////////////////////////////////////////////////
+
+import HistoryCollection from '../../../pgadmin/static/js/history/history_collection';
+
+describe('historyCollection', function () {
+  let historyCollection, historyModel, onChangeSpy;
+  beforeEach(() => {
+    historyModel = [{some: 'thing', someOther: ['array element']}];
+    historyCollection = new HistoryCollection(historyModel);
+    onChangeSpy = jasmine.createSpy('onChangeHandler');
+
+    historyCollection.onChange(onChangeSpy);
+  });
+
+  describe('length', function () {
+    it('returns 0 when underlying history model has no elements', function () {
+      historyCollection = new HistoryCollection([]);
+
+      expect(historyCollection.length()).toBe(0);
+    });
+
+    it('returns the length of the underlying history model', function () {
+      expect(historyCollection.length()).toBe(1);
+    });
+  });
+
+  describe('add', function () {
+    let expectedHistory;
+    beforeEach(() => {
+      historyCollection.add({some: 'new thing', someOther: ['value1', 'value2']});
+
+      expectedHistory = [
+        {some: 'thing', someOther: ['array element']},
+        {some: 'new thing', someOther: ['value1', 'value2']},
+      ];
+    });
+
+    it('adds a passed entry', function () {
+      expect(historyCollection.historyList).toEqual(expectedHistory);
+    });
+
+    it('calls the onChange function', function () {
+      expect(onChangeSpy).toHaveBeenCalledWith(expectedHistory);
+    });
+  });
+
+  describe('reset', function () {
+    beforeEach(() => {
+      historyCollection.reset();
+    });
+
+    it('drops the history', function () {
+      expect(historyCollection.historyList).toEqual([]);
+      expect(historyCollection.length()).toBe(0);
+    });
+
+    it('calls the onChange function', function () {
+      expect(onChangeSpy).toHaveBeenCalledWith([]);
+    });
+  });
+
+  describe('sort', function () {
+    it('doesn\'t sort');
+  });
+
+  describe('when instantiated', function () {
+    describe('from a history model', function () {
+      it('has the historyModel', () => {
+        let content = historyCollection.historyList;
+
+        expect(content).toEqual(historyModel);
+      });
+
+    });
+  });
+});
\ No newline at end of file
diff --git a/web/regression/javascript/history/query_history_entry_spec.jsx b/web/regression/javascript/history/query_history_entry_spec.jsx
new file mode 100644
index 00000000..c86a1cfc
--- /dev/null
+++ b/web/regression/javascript/history/query_history_entry_spec.jsx
@@ -0,0 +1,50 @@
+/////////////////////////////////////////////////////////////
+//
+// pgAdmin 4 - PostgreSQL Tools
+//
+// Copyright (C) 2013 - 2017, The pgAdmin Development Team
+// This software is released under the PostgreSQL Licence
+//
+//////////////////////////////////////////////////////////////
+
+import React from 'react';
+
+import QueryHistoryEntry from '../../../pgadmin/static/jsx/history/query_history_entry';
+
+import {mount} from 'enzyme';
+import jasmineEnzyme from 'jasmine-enzyme';
+
+describe('QueryHistoryEntry', () => {
+  let historyWrapper;
+  beforeEach(() => {
+    jasmineEnzyme();
+  });
+
+  describe('for a failed query', () => {
+    beforeEach(() => {
+      const historyEntry = {
+        query: 'second sql statement',
+        start_time: new Date(2016, 11, 11, 1, 33, 5, 99),
+        status: false,
+      };
+      historyWrapper = mount(<QueryHistoryEntry historyEntry={historyEntry}/>);
+    });
+    it('displays a pink background color', () => {
+      expect(historyWrapper.find('div').first()).toHaveStyle('backgroundColor', '#F7D0D5');
+    });
+  });
+
+  describe('for a successful query', () => {
+    beforeEach(() => {
+      const historyEntry = {
+        query: 'second sql statement',
+        start_time: new Date(2016, 11, 11, 1, 33, 5, 99),
+        status: true,
+      };
+      historyWrapper = mount(<QueryHistoryEntry historyEntry={historyEntry}/>);
+    });
+    it('does not display a pink background color', () => {
+      expect(historyWrapper.find('div').first()).toHaveStyle('backgroundColor', '#FFF');
+    });
+  });
+});
diff --git a/web/regression/javascript/history/query_history_spec.jsx b/web/regression/javascript/history/query_history_spec.jsx
new file mode 100644
index 00000000..e36988a8
--- /dev/null
+++ b/web/regression/javascript/history/query_history_spec.jsx
@@ -0,0 +1,103 @@
+/////////////////////////////////////////////////////////////
+//
+// pgAdmin 4 - PostgreSQL Tools
+//
+// Copyright (C) 2013 - 2017, The pgAdmin Development Team
+// This software is released under the PostgreSQL Licence
+//
+//////////////////////////////////////////////////////////////
+
+import React from 'react';
+import QueryHistory from '../../../pgadmin/static/jsx/history/query_history';
+import QueryHistoryEntry from '../../../pgadmin/static/jsx/history/query_history_entry';
+import HistoryCollection from '../../../pgadmin/static/js/history/history_collection';
+import jasmineEnzyme from 'jasmine-enzyme';
+
+import {mount, shallow} from 'enzyme';
+
+describe('QueryHistory', () => {
+  let historyWrapper;
+  beforeEach(() => {
+    jasmineEnzyme();
+    const historyCollection = new HistoryCollection([]);
+    historyWrapper = shallow(<QueryHistory historyCollection={historyCollection}/>);
+  });
+
+  describe('on construction', () => {
+    it('has no entries', (done) => {
+      let foundChildren = historyWrapper.find(QueryHistoryEntry);
+      expect(foundChildren.length).toBe(0);
+      done();
+    });
+  });
+
+  describe('when it has history', () => {
+    describe('when two SQL queries were executed', () => {
+      let foundChildren;
+
+      beforeEach(() => {
+        const historyObjects = [
+          {
+            query: 'second sql statement',
+            start_time: new Date(2016, 11, 11, 1, 33, 5, 99),
+            status: false,
+            row_affected: 1,
+            total_time: '234 msec',
+            message: 'some other message',
+          },
+          {
+            query: 'first sql statement',
+            start_time: new Date(2017, 5, 3, 14, 3, 15, 150),
+            status: true,
+            row_affected: 2,
+            total_time: '14 msec',
+            message: 'a very important message',
+          },
+        ];
+        const historyCollection = new HistoryCollection(historyObjects);
+
+        historyWrapper = mount(<QueryHistory historyCollection={historyCollection}/>);
+
+        foundChildren = historyWrapper.find(QueryHistoryEntry);
+      });
+
+      it('has two query history entries', () => {
+        expect(foundChildren.length).toBe(2);
+      });
+
+      it('displays the SQL of the queries in order', () => {
+        expect(foundChildren.at(0).text()).toContain('first sql statement');
+        expect(foundChildren.at(1).text()).toContain('second sql statement');
+      });
+
+      it('displays the formatted timestamp of the queries in chronological order by most recent first', () => {
+        expect(foundChildren.at(0).text()).toContain('Jun 3 2017 – 14:03:15');
+        expect(foundChildren.at(1).text()).toContain('Dec 11 2016 – 01:33:05');
+      });
+
+      it('displays the number of rows affected', () => {
+        expect(foundChildren.at(1).text()).toContain('1 rows affected');
+        expect(foundChildren.at(0).text()).toContain('2 rows affected');
+      });
+
+      it('displays the total time', () => {
+        expect(foundChildren.at(0).text()).toContain('total time: 14 msec');
+        expect(foundChildren.at(1).text()).toContain('total time: 234 msec');
+      });
+
+      it('displays the truncated message', () => {
+        expect(foundChildren.at(0).text()).toContain('a very important message');
+        expect(foundChildren.at(1).text()).toContain('some other message');
+      });
+
+      describe('when there are one failing and one successful query each', () => {
+        it('adds a white background color for the successful query', () => {
+          expect(foundChildren.at(0).find('div').first()).toHaveStyle('backgroundColor', '#FFF');
+        });
+        it('adds a red background color for the failed query', () => {
+          expect(foundChildren.at(1).find('div').first()).toHaveStyle('backgroundColor', '#F7D0D5');
+        });
+      });
+    });
+  });
+});
\ No newline at end of file
diff --git a/web/regression/python_test_utils/test_utils.py b/web/regression/python_test_utils/test_utils.py
index 2b7c6954..202802a3 100644
--- a/web/regression/python_test_utils/test_utils.py
+++ b/web/regression/python_test_utils/test_utils.py
@@ -172,6 +172,7 @@ def create_table(server, db_name, table_name):
     except Exception:
         traceback.print_exc(file=sys.stderr)
 
+
 def create_constraint(
         server, db_name, table_name,
         constraint_type="unique", constraint_name="test_unique"):
@@ -196,6 +197,7 @@ def create_constraint(
     except Exception:
         traceback.print_exc(file=sys.stderr)
 
+
 def create_debug_function(server, db_name, function_name="test_func"):
     try:
         connection = get_db_connection(db_name,
diff --git a/web/webpack.config.js b/web/webpack.config.js
index 91586592..fc6d29e0 100644
--- a/web/webpack.config.js
+++ b/web/webpack.config.js
@@ -1,18 +1,21 @@
 /* eslint-env node */
 
 module.exports = {
-  context: __dirname + '/pgadmin/static/jsx',
-  entry: './components.jsx',
+  context: __dirname + '/pgadmin/static',
+  entry: {
+    reactComponents: './jsx/components.jsx',
+    history: './js/history/index.js',
+  },
   output: {
     libraryTarget: 'amd',
     path: __dirname + '/pgadmin/static/js/generated',
-    filename: 'reactComponents.js',
+    filename: '[name].js',
   },
 
   module: {
     rules: [{
       test: /\.jsx?$/,
-      exclude: /node_modules/,
+      exclude: [/node_modules/, /vendor/],
       use: {
         loader: 'babel-loader',
         options: {
diff --git a/web/webpack.test.config.js b/web/webpack.test.config.js
index 49bef069..2d87ad04 100644
--- a/web/webpack.test.config.js
+++ b/web/webpack.test.config.js
@@ -21,7 +21,7 @@ module.exports = {
         use: {
           loader: 'babel-loader',
           options: {
-            presets: ['es2015'],
+            presets: ['es2015', 'react'],
           },
         },
       },
@@ -50,6 +50,7 @@ module.exports = {
   },
 
   resolve: {
+    extensions: ['.js', '.jsx'],
     alias: {
       'alertify': sourcesDir + '/vendor/alertifyjs/alertify',
       'jquery': sourcesDir + '/vendor/jquery/jquery-1.11.2',
@@ -62,4 +63,11 @@ module.exports = {
       'slickgrid.rowselectionmodel': sourcesDir + '/vendor/slickgrid/plugins/slick.rowselectionmodel',
     },
   },
+  externals: {
+    'react/addons': true,
+    'react/lib/ReactContext': true,
+    'react/lib/ExecutionEnvironment': true,
+    'react-dom/test-utils': true,
+    'react-test-renderer/shallow': true,
+  },
 };
\ No newline at end of file
diff --git a/web/yarn.lock b/web/yarn.lock
index 47f2e13c..fdbcfb4e 100644
--- a/web/yarn.lock
+++ b/web/yarn.lock
@@ -1431,6 +1431,12 @@ decamelize@^1.0.0, decamelize@^1.1.1, decamelize@^1.1.2:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
 
+deep-equal-ident@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/deep-equal-ident/-/deep-equal-ident-1.1.1.tgz#06f4b89e53710cd6cea4a7781c7a956642de8dc9"
+  dependencies:
+    lodash.isequal "^3.0"
+
 deep-extend@~0.4.0:
   version "0.4.1"
   resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.4.1.tgz#efe4113d08085f4e6f9687759810f807469e2253"
@@ -1668,6 +1674,12 @@ entities@^1.1.1, entities@~1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.1.tgz#6e5c2d0a5621b5dadaecef80b90edfb5cd7772f0"
 
+enzyme-matchers@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/enzyme-matchers/-/enzyme-matchers-3.1.0.tgz#097fe2b734a7493ed0f8aea727d3976c2d6eac29"
+  dependencies:
+    deep-equal-ident "^1.1.1"
+
 enzyme@~2.8.2:
   version "2.8.2"
   resolved "https://registry.yarnpkg.com/enzyme/-/enzyme-2.8.2.tgz#6c8bcb05012abc4aa4bc3213fb23780b9b5b1714"
@@ -2542,6 +2554,12 @@ ignore@^3.2.0:
   version "3.2.7"
   resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.2.7.tgz#4810ca5f1d8eca5595213a34b94f2eb4ed926bbd"
 
+immutability-helper@^2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/immutability-helper/-/immutability-helper-2.2.0.tgz#c4385ad4f68315843efaf0cff3575ee82ffa405f"
+  dependencies:
+    invariant "^2.2.0"
+
 "imports-loader@git+https://github.com/webpack-contrib/imports-loader.git#44d6f48463b256a17c1ba6fd9b5cc1449b4e379d":
   version "0.7.1"
   resolved "git+https://github.com/webpack-contrib/imports-loader.git#44d6f48463b256a17c1ba6fd9b5cc1449b4e379d"
@@ -2817,6 +2835,12 @@ jasmine-core@~2.5.2:
   version "2.5.2"
   resolved "https://registry.yarnpkg.com/jasmine-core/-/jasmine-core-2.5.2.tgz#6f61bd79061e27f43e6f9355e44b3c6cab6ff297"
 
+jasmine-enzyme@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/jasmine-enzyme/-/jasmine-enzyme-3.1.0.tgz#99cdc5bd850c6a741332928fe988b4f00dcee7d2"
+  dependencies:
+    enzyme-matchers "^3.1.0"
+
 jodid25519@^1.0.0:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/jodid25519/-/jodid25519-1.0.2.tgz#06d4912255093419477d425633606e0e90782967"
@@ -3081,6 +3105,22 @@ loader-utils@^1.0.2:
     emojis-list "^2.0.0"
     json5 "^0.5.0"
 
+lodash._baseisequal@^3.0.0:
+  version "3.0.7"
+  resolved "https://registry.yarnpkg.com/lodash._baseisequal/-/lodash._baseisequal-3.0.7.tgz#d8025f76339d29342767dcc887ce5cb95a5b51f1"
+  dependencies:
+    lodash.isarray "^3.0.0"
+    lodash.istypedarray "^3.0.0"
+    lodash.keys "^3.0.0"
+
+lodash._bindcallback@^3.0.0:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/lodash._bindcallback/-/lodash._bindcallback-3.0.1.tgz#e531c27644cf8b57a99e17ed95b35c748789392e"
+
+lodash._getnative@^3.0.0:
+  version "3.9.1"
+  resolved "https://registry.yarnpkg.com/lodash._getnative/-/lodash._getnative-3.9.1.tgz#570bc7dede46d61cdcde687d65d3eecbaa3aaff5"
+
 lodash.assignin@^4.0.9:
   version "4.2.0"
   resolved "https://registry.yarnpkg.com/lodash.assignin/-/lodash.assignin-4.2.0.tgz#ba8df5fb841eb0a3e8044232b0e263a8dc6a28a2"
@@ -3105,6 +3145,33 @@ lodash.foreach@^4.3.0:
   version "4.5.0"
   resolved "https://registry.yarnpkg.com/lodash.foreach/-/lodash.foreach-4.5.0.tgz#1a6a35eace401280c7f06dddec35165ab27e3e53"
 
+lodash.isarguments@^3.0.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz#2f573d85c6a24289ff00663b491c1d338ff3458a"
+
+lodash.isarray@^3.0.0:
+  version "3.0.4"
+  resolved "https://registry.yarnpkg.com/lodash.isarray/-/lodash.isarray-3.0.4.tgz#79e4eb88c36a8122af86f844aa9bcd851b5fbb55"
+
+lodash.isequal@^3.0:
+  version "3.0.4"
+  resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-3.0.4.tgz#1c35eb3b6ef0cd1ff51743e3ea3cf7fdffdacb64"
+  dependencies:
+    lodash._baseisequal "^3.0.0"
+    lodash._bindcallback "^3.0.0"
+
+lodash.istypedarray@^3.0.0:
+  version "3.0.6"
+  resolved "https://registry.yarnpkg.com/lodash.istypedarray/-/lodash.istypedarray-3.0.6.tgz#c9a477498607501d8e8494d283b87c39281cef62"
+
+lodash.keys@^3.0.0:
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/lodash.keys/-/lodash.keys-3.1.2.tgz#4dbc0472b156be50a0b286855d1bd0b0c656098a"
+  dependencies:
+    lodash._getnative "^3.0.0"
+    lodash.isarguments "^3.0.0"
+    lodash.isarray "^3.0.0"
+
 lodash.map@^4.4.0:
   version "4.6.0"
   resolved "https://registry.yarnpkg.com/lodash.map/-/lodash.map-4.6.0.tgz#771ec7839e3473d9c4cde28b19394c3562f4f6d3"
@@ -3309,6 +3376,10 @@ module-deps@^4.0.8:
     through2 "^2.0.0"
     xtend "^4.0.0"
 
+moment@^2.18.1:
+  version "2.18.1"
+  resolved "https://registry.yarnpkg.com/moment/-/moment-2.18.1.tgz#c36193dd3ce1c2eed2adb7c802dbbc77a81b1c0f"
+
 ms@0.7.1:
   version "0.7.1"
   resolved "https://registry.yarnpkg.com/ms/-/ms-0.7.1.tgz#9cd13c03adbff25b65effde7ce864ee952017098"
-- 
2.12.0

